Skip to content

Commit 565c9a9

Browse files
alari76claude
andcommitted
fix: overhaul session lifecycle to fix crashes, message loss, and duplicate notifications
Root cause: ClaudeProcess.isReady() returned `alive` (set immediately on spawn), so sendInput() bypassed readiness checks and sent messages before the process had initialized. This caused message loss, process crashes, infinite restart loops, and broken session naming. Five fixes: 1. ClaudeProcess.isReady() now requires system_init to have been received, not just process alive — fulfills the CodingProcess interface contract. 2. sendInput() restructured: readiness check applies to ALL paths, not just the auto-start branch. Messages are always queued via waitForReady() when the process hasn't initialized yet. 3. Lifetime restart cap (10 total) prevents infinite restart loops. Previously the 5-min cooldown reset allowed sessions to restart hundreds of times. 4. Duplicate "Session started" eliminated: frontend no longer shows a message for claude_started; only the system_init (with model info) serves as the "session ready" notification. 5. Session naming env includes XDG_* dirs for proper Claude CLI config/auth resolution (was stripped to just PATH+HOME). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 63b1c06 commit 565c9a9

13 files changed

Lines changed: 106 additions & 85 deletions

server/claude-process.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
115115
*/
116116
private _receivedOutput = false
117117

118+
/**
119+
* Set to true once the process emits a system init event (type=system, subtype=init).
120+
* Until this fires, the process is still loading configs/hooks and is not ready
121+
* to process user messages reliably.
122+
*/
123+
private _systemInitReceived = false
124+
118125
// Grouped streaming state — reset per content block
119126
private thinking: ThinkingState = { active: false, text: '', summaryEmitted: false }
120127
private tool: ToolState = { name: null, input: '' }
@@ -325,6 +332,7 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
325332
case 'system':
326333
if (event.subtype === 'init') {
327334
this.sessionId = (event as ClaudeSystemInit).session_id || this.sessionId
335+
this._systemInitReceived = true
328336
const model = ('model' in event ? (event as Record<string, unknown>).model : 'unknown') as string
329337
this.emit('system_init', model)
330338
}
@@ -736,8 +744,9 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
736744
}
737745

738746
isReady(): boolean {
739-
// Claude CLI stdin is always buffered — ready as soon as alive
740-
return this.alive
747+
// Ready only after system_init has been received, proving the process
748+
// has finished loading configs/hooks and is accepting user messages.
749+
return this.alive && this._systemInitReceived
741750
}
742751

743752
getSessionId(): string {

server/prompt-router.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
5151
_apiRetryCount: 0,
5252
_processGeneration: 0,
5353
_noOutputExitCount: 0,
54+
_lifetimeRestarts: 0,
5455
_lastActivityAt: Date.now(),
5556
planManager: makePlanManager() as any,
5657
...overrides,

server/session-lifecycle.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
8787
_apiRetryCount: 0,
8888
_processGeneration: 0,
8989
_noOutputExitCount: 0,
90+
_lifetimeRestarts: 0,
9091
_lastActivityAt: Date.now(),
9192
planManager: makePlanManager() as any,
9293
...overrides,

server/session-lifecycle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ export class SessionLifecycle {
343343
stoppedByUser: session._stoppedByUser || sessionConflict,
344344
exitCode: code,
345345
exitSignal: signal,
346+
lifetimeRestarts: session._lifetimeRestarts,
346347
})
347348

348349
if (action.kind === 'non_retryable') {
@@ -377,6 +378,7 @@ export class SessionLifecycle {
377378
if (action.kind === 'restart') {
378379
session.restartCount = action.updatedCount
379380
session.lastRestartAt = action.updatedLastRestartAt
381+
session._lifetimeRestarts = action.updatedLifetimeCount
380382

381383
for (const listener of this.deps.exitListeners) {
382384
try { listener(sessionId, code, signal, true) } catch { /* listener error */ }

server/session-manager.ts

Lines changed: 28 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ export class SessionManager {
304304
_namingAttempts: 0,
305305
_processGeneration: 0,
306306
_noOutputExitCount: 0,
307+
_lifetimeRestarts: 0,
307308
isProcessing: false,
308309
pendingControlRequests: new Map(),
309310
pendingToolApprovals: new Map(),
@@ -900,11 +901,10 @@ export class SessionManager {
900901

901902
private onSystemInit(cp: CodingProcess, session: Session, model: string): void {
902903
session.claudeSessionId = cp.getSessionId()
903-
// Only show model message on first init or when model actually changes
904-
if (!session._lastReportedModel || session._lastReportedModel !== model) {
905-
session._lastReportedModel = model
906-
this.broadcastAndHistory(session, { type: 'system_message', subtype: 'init', text: `Model: ${model}`, model })
907-
}
904+
session._lastReportedModel = model
905+
// Always broadcast — this is the single "session is ready" notification.
906+
// The text includes the model so the user knows which model is active.
907+
this.broadcastAndHistory(session, { type: 'system_message', subtype: 'init', text: `Model: ${model}`, model })
908908
}
909909

910910
private onTextEvent(session: Session, sessionId: string, text: string): void {
@@ -1104,66 +1104,30 @@ export class SessionManager {
11041104
// Reset stopped-by-user flag so idle-reaped sessions can auto-start
11051105
session._stoppedByUser = false
11061106

1107+
// --- Phase 1: ensure process is alive ---
11071108
if (!session.claudeProcess?.isAlive()) {
11081109
// Race guard: prevent concurrent startClaude calls when multiple sendInput
11091110
// requests arrive for an inactive session
11101111
if (session._isStarting) return
11111112
session._isStarting = true
11121113
// Claude not running (e.g. after server restart or idle reap) — auto-start first.
1113-
// Claude CLI in -p mode waits for first input before emitting init,
1114-
// so we write directly to the stdin pipe buffer (no waiting for init).
11151114
try {
11161115
this.startClaude(sessionId)
11171116
} finally {
11181117
session._isStarting = false
11191118
}
1119+
}
11201120

1121-
// If we have a saved claudeSessionId, Claude CLI resumes with full
1122-
// conversation history from its own session storage — no need for our
1123-
// lossy 4000-char context summary. Only fall back to buildSessionContext
1124-
// for sessions without a saved Claude session ID.
1125-
if (!session.claudeSessionId) {
1126-
const context = this.buildSessionContext(session)
1127-
if (context) {
1128-
const combined = context + '\n\n' + data
1129-
session._lastUserInput = combined
1130-
session._lastUserInputAt = Date.now()
1131-
if (!session._namingUserInput) session._namingUserInput = data
1132-
session._apiRetry.count = 0
1133-
if (!session.isProcessing) {
1134-
session.isProcessing = true
1135-
this._globalBroadcast?.({ type: 'sessions_updated' })
1136-
}
1137-
if (session.claudeProcess && !session.claudeProcess.isReady()) {
1138-
void this.waitForReady(sessionId).then((ready) => {
1139-
if (ready) session.claudeProcess?.sendMessage(combined)
1140-
// If not ready (process exited), message stays in _lastUserInput
1141-
// and will be re-sent on auto-restart
1142-
})
1143-
} else {
1144-
session.claudeProcess?.sendMessage(combined)
1145-
}
1146-
return
1147-
}
1148-
}
1121+
// --- Phase 2: determine message content (with context injection if needed) ---
1122+
let messageToSend = data
11491123

1150-
// Process just started — if not ready yet (OpenCode needs server init),
1151-
// queue the message via waitForReady.
1152-
if (session.claudeProcess && !session.claudeProcess.isReady()) {
1153-
session._lastUserInput = data
1154-
session._lastUserInputAt = Date.now()
1155-
if (!session._namingUserInput) session._namingUserInput = data
1156-
session._apiRetry.count = 0
1157-
if (!session.isProcessing) {
1158-
session.isProcessing = true
1159-
this._globalBroadcast?.({ type: 'sessions_updated' })
1160-
}
1161-
void this.waitForReady(sessionId).then((ready) => {
1162-
if (ready) session.claudeProcess?.sendMessage(data)
1163-
// If not ready (process exited), message stays in _lastUserInput
1164-
// and will be re-sent on auto-restart
1165-
})
1166-
return
1124+
// If we auto-started above and have no saved claudeSessionId, Claude CLI
1125+
// starts a fresh session without conversation history. Inject a context
1126+
// summary so the new process has awareness of prior conversation.
1127+
if (!session.claudeSessionId) {
1128+
const context = this.buildSessionContext(session)
1129+
if (context) {
1130+
messageToSend = context + '\n\n' + data
11671131
}
11681132
}
11691133

@@ -1175,15 +1139,25 @@ export class SessionManager {
11751139
this.retrySessionNamingOnInteraction(sessionId)
11761140
}
11771141

1178-
session._lastUserInput = data
1142+
session._lastUserInput = messageToSend
11791143
session._lastUserInputAt = Date.now()
11801144
if (!session._namingUserInput) session._namingUserInput = data
11811145
session._apiRetry.count = 0
11821146
if (!session.isProcessing) {
11831147
session.isProcessing = true
11841148
this._globalBroadcast?.({ type: 'sessions_updated' })
11851149
}
1186-
session.claudeProcess?.sendMessage(data)
1150+
1151+
// --- Phase 3: send, waiting for readiness if needed ---
1152+
if (session.claudeProcess && !session.claudeProcess.isReady()) {
1153+
void this.waitForReady(sessionId).then((ready) => {
1154+
if (ready) session.claudeProcess?.sendMessage(messageToSend)
1155+
// If not ready (process exited), message stays in _lastUserInput
1156+
// and will be re-sent on auto-restart
1157+
})
1158+
} else {
1159+
session.claudeProcess?.sendMessage(messageToSend)
1160+
}
11871161
}
11881162

11891163
/**

server/session-naming.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,33 @@ export interface SessionNamingDeps {
2626
rename(sessionId: string, newName: string): boolean
2727
}
2828

29+
/** Build a minimal env for claude -p that includes auth/config paths
30+
* without leaking the full parent env (e.g. CODEKIN_* session vars). */
31+
function buildNamingEnv(): Record<string, string> {
32+
const env: Record<string, string> = {}
33+
// Core paths
34+
if (process.env.PATH) env.PATH = process.env.PATH
35+
if (process.env.HOME) env.HOME = process.env.HOME
36+
// XDG dirs — Claude CLI uses these for config/credential resolution
37+
for (const key of ['XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME']) {
38+
if (process.env[key]) env[key] = process.env[key]!
39+
}
40+
// SHELL and TERM for proper subprocess behavior
41+
if (process.env.SHELL) env.SHELL = process.env.SHELL
42+
if (process.env.TERM) env.TERM = process.env.TERM
43+
// Suppress Node.js warnings in child
44+
env.NODE_NO_WARNINGS = '1'
45+
return env
46+
}
47+
2948
/** Generate a session name by spawning `claude -p` in one-shot mode. */
3049
function generateNameViaCLI(prompt: string): Promise<string> {
3150
return new Promise((resolve, reject) => {
3251
// --max-turns 2: one turn to receive/process the prompt, one to reply with the name.
3352
// Using 1 would sometimes cause the CLI to exit before producing output.
3453
const proc = spawn(CLAUDE_BINARY, ['-p', '--max-turns', '2', '--model', 'haiku'], {
3554
stdio: ['pipe', 'pipe', 'pipe'],
36-
env: { PATH: process.env.PATH, HOME: process.env.HOME },
55+
env: buildNamingEnv(),
3756
})
3857

3958
let stdout = ''

server/session-persistence.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export class SessionPersistence {
135135
_namingAttempts: 0,
136136
_processGeneration: 0,
137137
_noOutputExitCount: 0,
138+
_lifetimeRestarts: 0,
138139
isProcessing: false,
139140
pendingControlRequests: new Map(),
140141
pendingToolApprovals: new Map(),

server/session-restart-scheduler.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
* Returns an action descriptor that the caller (SessionManager) executes.
77
*/
88

9-
/** Max auto-restart attempts before requiring manual intervention. */
9+
/** Max auto-restart attempts per cooldown window before pausing. */
1010
const MAX_RESTARTS = 3
11-
/** Window after which the restart counter resets (5 minutes). */
11+
/** Window after which the per-window restart counter resets (5 minutes). */
1212
const RESTART_COOLDOWN_MS = 5 * 60 * 1000
13+
/** Hard cap on total lifetime restarts. Once reached, the session stops
14+
* permanently regardless of cooldown windows. Prevents sessions from
15+
* restarting indefinitely (3 per window × many windows = hundreds). */
16+
const MAX_LIFETIME_RESTARTS = 10
1317
/** Delay between crash and auto-restart attempt. */
1418
const RESTART_DELAY_MS = 2000
1519

@@ -30,12 +34,14 @@ export interface RestartState {
3034
exitCode?: number | null
3135
/** Signal that killed the process, if any. */
3236
exitSignal?: string | null
37+
/** Total number of restarts over the entire session lifetime. */
38+
lifetimeRestarts?: number
3339
}
3440

3541
export type RestartAction =
3642
| { kind: 'stopped_by_user' }
3743
| { kind: 'non_retryable'; exitCode: number }
38-
| { kind: 'restart'; attempt: number; maxAttempts: number; delayMs: number; updatedCount: number; updatedLastRestartAt: number }
44+
| { kind: 'restart'; attempt: number; maxAttempts: number; delayMs: number; updatedCount: number; updatedLastRestartAt: number; updatedLifetimeCount: number }
3945
| { kind: 'exhausted'; maxAttempts: number }
4046

4147
/**
@@ -52,10 +58,16 @@ export function evaluateRestart(state: RestartState): RestartAction {
5258
return { kind: 'non_retryable', exitCode: state.exitCode }
5359
}
5460

61+
// Hard lifetime cap: prevent sessions from restarting indefinitely
62+
const lifetime = state.lifetimeRestarts ?? 0
63+
if (lifetime >= MAX_LIFETIME_RESTARTS) {
64+
return { kind: 'exhausted', maxAttempts: MAX_LIFETIME_RESTARTS }
65+
}
66+
5567
const now = Date.now()
5668
let { restartCount } = state
5769

58-
// Reset counter if cooldown has elapsed
70+
// Reset per-window counter if cooldown has elapsed
5971
if (state.lastRestartAt && (now - state.lastRestartAt) > RESTART_COOLDOWN_MS) {
6072
restartCount = 0
6173
}
@@ -69,6 +81,7 @@ export function evaluateRestart(state: RestartState): RestartAction {
6981
delayMs: RESTART_DELAY_MS,
7082
updatedCount,
7183
updatedLastRestartAt: now,
84+
updatedLifetimeCount: lifetime + 1,
7285
}
7386
}
7487

server/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ export interface Session {
114114
/** Number of consecutive no-output exits with the same claudeSessionId.
115115
* Only after reaching the threshold do we clear claudeSessionId. */
116116
_noOutputExitCount: number
117+
/** Total lifetime restart count. Unlike restartCount (which resets after
118+
* the cooldown window), this never resets and provides a hard cap to
119+
* prevent sessions from restarting indefinitely. */
120+
_lifetimeRestarts: number
117121
/** Grace period timer before auto-denying prompts after last client leaves. */
118122
_leaveGraceTimer?: ReturnType<typeof setTimeout> | null
119123
/** Timestamp of last meaningful activity (input, prompt response, client join). Used by idle reaper. */

server/ws-message-handler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function mockSession(overrides: Partial<Session> = {}): Session {
4141
_namingAttempts: 0,
4242
_processGeneration: 0,
4343
_noOutputExitCount: 0,
44+
_lifetimeRestarts: 0,
4445
_apiRetry: { count: 0 },
4546
...overrides,
4647
}

0 commit comments

Comments
 (0)