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
+4 -1
View File
@@ -420,7 +420,10 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return;
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
// Surface the CLI's own reason (e.g. "Session ID … is already in use")
// instead of an opaque "code 1" (T-437).
final why = end.reason.isEmpty ? '' : '${end.reason}';
setState(() => _statusLine = 'claude exited (code ${end.exitCode})$why · /clear to restart');
}
// Send composed text to Claude over the stream-json channel. Commands clide
+10 -4
View File
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
/// missing entries are not an error. The caller MUST have killed the session's
/// process first, so claude is not mid-write.
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
/// entries are not an error). Note the shared per-project `memory/` dir that
/// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
/// not per-session.
///
/// The caller MUST have AWAITED the session's process death first (T-437): a
/// still-live claude re-flushes its transcript and keeps the id registered, so
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
final file = File('$projectDir/$sessionId.jsonl');
if (await file.exists()) await file.delete();
@@ -313,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
}
/// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams.
/// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
/// return (T-437). Callers respawn the primary on the same deterministic
/// `--session-id` right after /clear — if the old process were still alive,
/// claude 2.1.177 would reject the id as "already in use" and the respawn
/// would exit 1.
Future<void> close(String id) async {
final m = _sessions.remove(id);
if (m == null) return;
broker.removeMember(id);
m.conversation.dispose();
m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
await m.session.dispose(); // idempotent — awaits the real process exit
notifyListeners();
}
@@ -107,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
@override
Future<void> kill() async {
// Await the process's ACTUAL death, not just the signal (T-437). clide
// respawns the primary on the SAME deterministic --session-id right after
// /clear; if the old process is still alive (or still flushing its
// transcript) when the new one starts, claude 2.1.177 rejects the id with
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
// (claude cleans its session registry on it), escalate to SIGKILL if it
// doesn't go, and only return once exitCode has resolved.
_proc.kill();
try {
await _proc.exitCode.timeout(const Duration(seconds: 2));
} on TimeoutException {
_proc.kill(ProcessSignal.sigkill);
await _proc.exitCode;
}
}
@override
@@ -288,6 +301,20 @@ class SessionEnd {
final int exitCode;
final List<String> stderrTail;
/// The most recent non-empty stderr line — the CLI's own error message when
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
/// stderr was silent; capped so a stray long line can't blow out the status
/// line.
String get reason {
for (final line in stderrTail.reversed) {
final t = line.trim();
if (t.isEmpty) continue;
return t.length > 200 ? '${t.substring(0, 200)}' : t;
}
return '';
}
}
class StreamJsonSession {
@@ -965,10 +992,17 @@ class StreamJsonSession {
_endCtl.add(_end!);
}
Future<void> dispose() async {
/// Idempotent: the conversation controller's [dispose] fires this
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
/// awaits it to know the process is truly dead (T-437). Caching the future
/// makes both paths share one teardown rather than killing/closing twice.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void>? _disposeFuture;
Future<void> _dispose() async {
_disposed = true; // deliberate teardown — suppress the exit-watch path
await _sub?.cancel();
await _proc.kill();
await _proc.kill(); // awaits the process's real exit (T-437)
await _items.close();
await _statusCtl.close();
await _workflowsCtl.close();