fix: settle busy sessions after managed OpenCode restart (#3002)

* fix: reconcile busy sessions after managed OpenCode restart

Forced health-check restarts previously rebound the event stream without
settling in-flight turns, so sessions stayed busy with no terminal state.
Interrupt those sessions, classify health failures, and retain bounded
process diagnostics for post-restart diagnosis.

Fixes #2943

Co-authored-by: serkraser <serkraser@gmail.com>

* fix: surface interrupted chats after OpenCode restart

Complete unfinished assistant turns as aborted once the session is
authoritatively idle, and show a persistent toast so users can continue
instead of remaining silently stranded.

Fixes #2943

Co-authored-by: serkraser <serkraser@gmail.com>

* fix: redact Basic auth credentials in restart diagnostics

The key/value sanitizer stopped at whitespace, so Authorization: Basic
credentials survived in stderr tails and health snapshots. Redact the
scheme token before that rule runs.

Co-authored-by: serkraser <serkraser@gmail.com>
This commit is contained in:
Serhii Dziupin
2026-08-19 11:53:30 +03:00
committed by GitHub
parent 13a45aa5a5
commit 14d7a0ca9b
24 changed files with 806 additions and 80 deletions
@@ -130,7 +130,8 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
const now = Date.now();
const existing = sessionStates.get(sessionId);
const existingAttentionState = sessionAttentionStates.get(sessionId);
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
const isRestartInterruption = metadata.reason === 'opencode-restart';
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status && !isRestartInterruption) {
return;
}
@@ -145,7 +146,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
const attentionState = sessionAttentionStates.get(sessionId);
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
const clients = getNotificationClients();
if (!existing || existing.status !== status || attentionChanged) {
if (!existing || existing.status !== status || attentionChanged || isRestartInterruption) {
const state = sessionStates.get(sessionId);
const syntheticPayload = {
type: 'openchamber:session-status',
@@ -293,6 +294,41 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
}
};
const interruptBusySessionsAfterRestart = () => {
const interruptedSessionIds = new Set();
for (const [sessionId, state] of sessionStates) {
if (state.status === 'busy' || state.status === 'retry') {
interruptedSessionIds.add(sessionId);
}
}
for (const [sessionId, activity] of sessionActivityPhases) {
if (activity.phase === 'busy') {
interruptedSessionIds.add(sessionId);
}
}
const eventId = `opencode-restart-${Date.now()}`;
for (const sessionId of interruptedSessionIds) {
updateSessionState(sessionId, 'idle', eventId, {
message: 'Interrupted by OpenCode restart',
reason: 'opencode-restart',
});
broadcastEvent?.({
type: 'session.error',
properties: {
sessionID: sessionId,
error: {
name: 'MessageAbortedError',
message: 'The running turn was interrupted when OpenCode restarted.',
},
},
});
}
resetAllSessionActivityToIdle();
return { sessionIds: [...interruptedSessionIds] };
};
const cleanupOldSessionStates = () => {
const now = Date.now();
for (const [sessionId, data] of sessionStates) {
@@ -358,6 +394,7 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, br
markSessionUnviewed,
markUserMessageSent,
resetAllSessionActivityToIdle,
interruptBusySessionsAfterRestart,
dispose,
};
};