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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user