fix(claude): /clear no longer kills the session on CLI 2.1.177 (T-437)

/clear tore the session down and respawned on the same deterministic
--session-id BEFORE the old claude process had actually exited. The
orchestrator's close() ran conversation.dispose() unawaited and kill()
only sent SIGTERM without awaiting exitCode, so the respawn raced a
still-alive holder of the id — claude 2.1.177 rejects it as "Session ID
… is already in use" and exits 1.

Root cause confirmed from clide's own crash log + isolated probes against
2.1.177: the id frees the instant the holder dies (SIGTERM cleans the new
~/.claude/sessions/<pid>.json registry), so awaiting real death is the
fix — and it preserves T-268's deterministic-id continuity (chosen over
minting a fresh id, which would change the continuity model).

- stream_json_session: kill() awaits exitCode (SIGTERM → 2s → SIGKILL →
  await); dispose() idempotent (shared cached future); new
  SessionEnd.reason getter (last non-empty stderr line, capped).
- session_orchestrator: close() awaits session.dispose() so teardown
  returns only once the process is truly dead, before clear + respawn.
- claude_pane: surface end.reason in the status line — no more opaque
  "code 1".
- session_naming: correct the stale clearSessionTranscript doc (real
  sidecar is the shared memory/ dir) + the await-death precondition.
- tests: close() blocks until process exit; SessionEnd.reason.

CLI 2.1.177 re-probe (folded-in scope): sessions/ registry characterized
(PID-keyed, cleaned on exit); init cache auto-refreshes; advertised
slash_commands show no routing-table drift. No further code change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:32:02 +02:00
co-authored by Claude Opus 4.8
parent 708d4c6f95
commit 081678e2f7
9 changed files with 499 additions and 9 deletions
@@ -33,6 +33,26 @@ class _FakeProc extends StreamJsonProcess {
Future<void> kill() async => killed = true;
}
/// A fake whose [kill] blocks until [gate] completes — models a real `claude`
/// that hasn't actually exited yet, so a test can prove `close()` waits for the
/// process's real death before returning (T-437).
class _GatedProc extends StreamJsonProcess {
_GatedProc(this._gate);
final Completer<void> _gate;
final _ctl = StreamController<String>.broadcast();
int killCount = 0;
@override
Stream<String> get lines => _ctl.stream;
@override
void writeLine(String line) {}
@override
Future<void> kill() async {
killCount++;
await _gate.future;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -207,6 +227,37 @@ void main() {
});
});
// ---- close awaits the process's real death (T-437) ----------------------
group('close — awaits real process death before returning (T-437)', () {
test('close() does not complete until the process exit resolves', () async {
final gate = Completer<void>();
final created = <_GatedProc>[];
final orch = ClaudeSessionOrchestrator(
processFactory: ({required sessionArgs, required cwd, env}) async {
final p = _GatedProc(gate);
created.add(p);
return p;
},
);
await orch.spawn(_spec('primary'));
var closed = false;
final closing = orch.close('primary').then((_) => closed = true);
await pumpEventQueue();
// The process hasn't exited yet, so /clear must not have proceeded to
// delete the transcript + respawn — close() is still awaiting death.
expect(closed, isFalse, reason: 'close must block until the old process is truly dead');
gate.complete(); // the claude process finally exits
await closing;
expect(closed, isTrue);
expect(created.single.killCount, 1, reason: 'idempotent dispose kills exactly once');
orch.dispose();
});
});
// ---- claude.kill-all-sessions via orchestrator --------------------------
group('claude.kill-all-sessions via orchestrator (T-167)', () {
@@ -937,6 +937,24 @@ void main() {
});
});
group('SessionEnd.reason (T-437)', () {
test('is the last non-empty stderr line — the CLI error', () {
const end = SessionEnd(exitCode: 1, stderrTail: ['warming up', '', 'Error: Session ID abc is already in use.', ' ']);
expect(end.reason, 'Error: Session ID abc is already in use.');
});
test('is empty when stderr was silent', () {
expect(const SessionEnd(exitCode: 1, stderrTail: []).reason, isEmpty);
expect(const SessionEnd(exitCode: 1, stderrTail: ['', ' ']).reason, isEmpty);
});
test('caps a very long line so it cannot blow out the status line', () {
final end = SessionEnd(exitCode: 1, stderrTail: ['x' * 500]);
expect(end.reason.length, 201); // 200 chars + ellipsis
expect(end.reason.endsWith(''), isTrue);
});
});
group('BoundedLineBuffer', () {
test('keeps only the last cap lines', () {
final b = BoundedLineBuffer(cap: 3);