Skip to content

Commit 7099da8

Browse files
alari76claude
andcommitted
fix: prevent message loss when Claude process exits before system_init
waitForReady() resolved on process exit, causing sendInput() to fire on a dead process and silently lose the user's first message. The auto-restart then showed a duplicate "Session started" without ever responding. - waitForReady() now returns boolean (true=ready, false=exited/timeout) - sendInput() checks the return value before sending - Restart handler re-sends pending _lastUserInput for fresh sessions that crashed before initialization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd5d4b4 commit 7099da8

File tree

3 files changed

+43
-19
lines changed

3 files changed

+43
-19
lines changed

server/session-lifecycle.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -186,23 +186,26 @@ export class SessionLifecycle {
186186
* session already has a claudeSessionId (process previously initialized).
187187
* Times out after `timeoutMs` (default 30s) to avoid hanging indefinitely.
188188
*/
189-
waitForReady(sessionId: string, timeoutMs = 30_000): Promise<void> {
189+
waitForReady(sessionId: string, timeoutMs = 30_000): Promise<boolean> {
190190
const session = this.deps.getSession(sessionId)
191-
if (!session?.claudeProcess) return Promise.resolve()
191+
if (!session?.claudeProcess) return Promise.resolve(false)
192192
// If the process is already fully initialized, resolve immediately.
193193
// Uses isReady() which accounts for provider differences: Claude is ready
194194
// as soon as alive (stdin buffered), OpenCode needs alive + opencodeSessionId.
195-
if (session.claudeProcess.isReady()) return Promise.resolve()
195+
if (session.claudeProcess.isReady()) return Promise.resolve(true)
196196

197-
return new Promise<void>((resolve) => {
198-
const done = () => { clearTimeout(timer); resolve() }
197+
return new Promise<boolean>((resolve) => {
198+
const onReady = () => { clearTimeout(timer); cp.removeListener('exit', onExit); resolve(true) }
199+
const onExit = () => { clearTimeout(timer); cp.removeListener('system_init', onReady); resolve(false) }
200+
const cp = session.claudeProcess!
199201
const timer = setTimeout(() => {
200202
console.warn(`[waitForReady] Timed out waiting for system_init on ${sessionId} after ${timeoutMs}ms`)
201-
session.claudeProcess?.removeListener('exit', done)
202-
resolve()
203+
cp.removeListener('system_init', onReady)
204+
cp.removeListener('exit', onExit)
205+
resolve(false)
203206
}, timeoutMs)
204-
session.claudeProcess!.once('system_init', done)
205-
session.claudeProcess!.once('exit', done) // fail-fast if process dies during init
207+
cp.once('system_init', onReady)
208+
cp.once('exit', onExit) // fail-fast if process dies during init
206209
})
207210
}
208211

@@ -407,13 +410,26 @@ export class SessionLifecycle {
407410
// Fallback: if claudeSessionId was already null (fresh session that
408411
// crashed before system_init), inject a context summary so the new
409412
// session has some awareness of prior conversation.
410-
if (!session.claudeSessionId && session.claudeProcess && session.outputHistory.length > 0) {
413+
if (!session.claudeSessionId && session.claudeProcess) {
414+
const pendingInput = session._lastUserInput
415+
const inputAge = session._lastUserInputAt ? Date.now() - session._lastUserInputAt : Infinity
416+
const hasPendingInput = pendingInput && inputAge < 60_000
417+
411418
session.claudeProcess.once('system_init', () => {
412-
const context = this.deps.buildSessionContext(session)
413-
if (context) {
414-
session.claudeProcess?.sendMessage(
415-
context + '\n\n[Session resumed after process restart. Continue where you left off. If you were in the middle of a task, resume it.]',
416-
)
419+
if (session.outputHistory.length > 0) {
420+
const context = this.deps.buildSessionContext(session)
421+
if (context) {
422+
const msg = hasPendingInput
423+
? context + '\n\n' + pendingInput
424+
: context + '\n\n[Session resumed after process restart. Continue where you left off. If you were in the middle of a task, resume it.]'
425+
session.claudeProcess?.sendMessage(msg)
426+
return
427+
}
428+
}
429+
// No output history but pending input (brand new session that
430+
// crashed before responding) — re-send the user's message.
431+
if (hasPendingInput) {
432+
session.claudeProcess?.sendMessage(pendingInput)
417433
}
418434
})
419435
}

server/session-manager.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,7 @@ export class SessionManager {
872872
* Wait for a session's Claude process to emit its system_init event.
873873
* Delegates to SessionLifecycle.
874874
*/
875-
waitForReady(sessionId: string, timeoutMs = 30_000): Promise<void> {
875+
waitForReady(sessionId: string, timeoutMs = 30_000): Promise<boolean> {
876876
return this.sessionLifecycle.waitForReady(sessionId, timeoutMs)
877877
}
878878

@@ -1131,7 +1131,11 @@ export class SessionManager {
11311131
this._globalBroadcast?.({ type: 'sessions_updated' })
11321132
}
11331133
if (session.claudeProcess && !session.claudeProcess.isReady()) {
1134-
void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(combined))
1134+
void this.waitForReady(sessionId).then((ready) => {
1135+
if (ready) session.claudeProcess?.sendMessage(combined)
1136+
// If not ready (process exited), message stays in _lastUserInput
1137+
// and will be re-sent on auto-restart
1138+
})
11351139
} else {
11361140
session.claudeProcess?.sendMessage(combined)
11371141
}
@@ -1149,7 +1153,11 @@ export class SessionManager {
11491153
session.isProcessing = true
11501154
this._globalBroadcast?.({ type: 'sessions_updated' })
11511155
}
1152-
void this.waitForReady(sessionId).then(() => session.claudeProcess?.sendMessage(data))
1156+
void this.waitForReady(sessionId).then((ready) => {
1157+
if (ready) session.claudeProcess?.sendMessage(data)
1158+
// If not ready (process exited), message stays in _lastUserInput
1159+
// and will be re-sent on auto-restart
1160+
})
11531161
return
11541162
}
11551163
}

server/workflow-loader.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function fakeSessionManager() {
105105
startClaude: vi.fn(),
106106
stopClaude: vi.fn(),
107107
sendInput: vi.fn(),
108-
waitForReady: vi.fn(() => Promise.resolve()),
108+
waitForReady: vi.fn(() => Promise.resolve(true)),
109109
} as any
110110
}
111111

0 commit comments

Comments
 (0)