From 66010569722fbc5a8ac22196c885fe8f756bf8c9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 6 May 2026 16:48:06 +0200 Subject: [PATCH] finish T-87: cold-start reap, kill-all-sessions, helper tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three remaining acceptance criteria for T-87: 1. Cold-start reap. The Claude extension's activate() now kills every leftover secondary tmux session for the current repo before any new spawn. activate runs before any UI mounts, so _nextSecondary's starting value of 1 is correct even when a previous run died abruptly (kill -9, OOM, force-quit). The deactivate() hook also calls reapSecondaries as a courtesy on explicit extension teardown — but Flutter's deactivate doesn't fire on app quit, so activate is the load-bearing path. 2. claude.kill-all-sessions actually kills server-side. The command previously called pane.close on every claude pane, which only kills the tmux client. It now also calls tmux.killAllForRepo to kill the sessions on the clide socket. 3. Tests. test/builtin/claude/tmux_session_test.dart covers killSession, listClideSessions, reapSecondaries, and killAllForRepo via the TmuxRunner override — no real shell-out in tests. Co-Authored-By: Claude --- .pql/pql-plan.json | 2 +- CHANGELOG.md | 6 ++ lib/builtin/claude/src/extension.dart | 52 ++++++++-- test/builtin/claude/tmux_session_test.dart | 106 +++++++++++++++++++++ 4 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 test/builtin/claude/tmux_session_test.dart diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index dd917222..b083d713 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T14:41:33Z", + "exported_at": "2026-05-06T14:48:06Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index d14c0698..9e6c1303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. on the clide socket, honouring D-41's "closing a secondary kills that tmux session" lifecycle. Previously `pane.close` only killed the ptyc-spawned tmux client and the server-side session leaked. +- Cold-start reap: every clide launch kills any leftover secondary + tmux sessions for the current repo before spawning new ones, so + D-41's "secondary numbering resets between runs" holds even after + an abrupt previous exit (kill -9, crash, force-quit). +- `claude.kill-all-sessions` command now actually kills the + server-side tmux sessions for the repo, not just the panes. - Terminal cell grid no longer drifts on bold text — bold rendering is suppressed at the painter level since synthetic bold (with no Bold.ttf registered) shifts glyph advance widths. diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 5b9109bf..0c497928 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -50,29 +50,63 @@ class ClaudeExtension extends ClideExtension { @override Future activate(ClideExtensionContext ctx) async { _ctx = ctx; + // Cold-start reap: kill any leftover secondary tmux sessions from + // a previous run. D-41's "secondary numbering resets between + // clide runs" only holds if the leftovers are gone before the new + // run starts. Doing this in activate (rather than the previous + // run's deactivate) guarantees cleanup even after an abrupt exit + // — Flutter's deactivate hook only fires on explicit extension + // teardown, not on app quit / kill -9 / OOM. + final primary = await _primarySessionName(); + if (primary != null) await tmux.reapSecondaries(primary); } @override Future deactivate() async { - await _killAllSessions([]); + // Best-effort cleanup on explicit extension teardown. The cold- + // start reap in activate is the actual safety net. + final primary = await _primarySessionName(); + if (primary != null) await tmux.reapSecondaries(primary); } + /// Hard-reset command: kill every clide-claude tmux session for this + /// repo, primary included. The user invokes this when they want to + /// start over — typically after a tmux/Claude wedge. Future _killAllSessions(List args) async { final ctx = _ctx; if (ctx == null) return IpcResponse.ok(id: '', data: const {}); + + // Close the UI panes first so they don't try to talk to a tmux + // server that's about to lose their sessions. final resp = await ctx.ipc.request('pane.list'); - if (!resp.ok) return resp; - final panes = resp.data['panes']; - if (panes is List) { - for (final p in panes) { - if (p is Map && p['kind'] == 'claude') { - final id = p['id'] as String?; - if (id != null) { - await ctx.ipc.request('pane.close', args: {'id': id}); + if (resp.ok) { + final panes = resp.data['panes']; + if (panes is List) { + for (final p in panes) { + if (p is Map && p['kind'] == 'claude') { + final id = p['id'] as String?; + if (id != null) { + await ctx.ipc.request('pane.close', args: {'id': id}); + } } } } } + + // Then kill the server-side sessions, primary included. + final primary = await _primarySessionName(); + if (primary != null) await tmux.killAllForRepo(primary); + return IpcResponse.ok(id: '', data: const {'status': 'killed'}); } + + Future _primarySessionName() async { + final ctx = _ctx; + if (ctx == null) return null; + final resp = await ctx.ipc.request('files.root'); + if (!resp.ok) return null; + final root = resp.data['path'] as String?; + if (root == null) return null; + return primarySessionName(root); + } } diff --git a/test/builtin/claude/tmux_session_test.dart b/test/builtin/claude/tmux_session_test.dart new file mode 100644 index 00000000..75bb154b --- /dev/null +++ b/test/builtin/claude/tmux_session_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:clide/builtin/claude/src/tmux_session.dart' as tmux; +import 'package:test/test.dart'; + +class _RecordingRunner { + final List> calls = []; + String stdout = ''; + int exitCode = 0; + + Future call(List args) async { + calls.add(List.of(args)); + return ProcessResult(0, exitCode, stdout, ''); + } +} + +void main() { + late _RecordingRunner runner; + + setUp(() { + runner = _RecordingRunner(); + tmux.tmuxRunner = runner.call; + }); + + tearDown(() { + // Restore the default runner so other tests aren't affected. + tmux.tmuxRunner = (args) => Process.run('tmux', args); + }); + + group('killSession', () { + test('invokes tmux kill-session on the clide socket', () async { + await tmux.killSession('clide-claude-foo'); + expect(runner.calls, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'], + ]); + }); + + test('does not surface non-zero exit (session already gone)', () async { + runner.exitCode = 1; + await tmux.killSession('clide-claude-foo'); + expect(runner.calls, hasLength(1)); + }); + }); + + group('listClideSessions', () { + test('parses session names from tmux output', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n'; + final names = await tmux.listClideSessions(); + expect(names, ['clide-claude-foo', 'clide-claude-foo-1', 'clide-claude-foo-2']); + }); + + test('returns empty list when server is not running', () async { + runner.exitCode = 1; + final names = await tmux.listClideSessions(); + expect(names, isEmpty); + }); + + test('strips blank lines and whitespace', () async { + runner.stdout = '\nclide-claude-foo\n\n clide-claude-foo-1 \n'; + final names = await tmux.listClideSessions(); + expect(names, ['clide-claude-foo', 'clide-claude-foo-1']); + }); + }); + + group('reapSecondaries', () { + test('kills only --suffixed sessions, leaves primary alive', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-foo-2\n'; + await tmux.reapSecondaries('clide-claude-foo'); + + // First call lists, then one kill per secondary. + expect(runner.calls.first, ['-L', 'clide', 'list-sessions', '-F', '#{session_name}']); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-2'], + ]); + }); + + test('ignores sessions for other repos', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-bar-1\nclide-claude-foo-1\n'; + await tmux.reapSecondaries('clide-claude-foo'); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ]); + }); + + test('no-op when there are no secondaries', () async { + runner.stdout = 'clide-claude-foo\n'; + await tmux.reapSecondaries('clide-claude-foo'); + expect(runner.calls, hasLength(1)); // just the list call + }); + }); + + group('killAllForRepo', () { + test('kills primary and every secondary for the repo', () async { + runner.stdout = 'clide-claude-foo\nclide-claude-foo-1\nclide-claude-bar\n'; + await tmux.killAllForRepo('clide-claude-foo'); + final kills = runner.calls.skip(1).toList(); + expect(kills, [ + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo'], + ['-L', 'clide', 'kill-session', '-t', 'clide-claude-foo-1'], + ]); + }); + }); +}