mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
COD-118 feat: circuit breaker bounding repeated non-zero interactive-PTY exits
Defense-in-depth after COD-115. If the interactive PTY exits non-zero repeatedly within a short window, recovery/reconnect paths recreate it indefinitely (COD-115 saw 114 'exited with code: 1' events + orphans). - New pure InteractivePtyExitBreaker (session-pty-exit-breaker.ts): injectable time, sliding window, clean-exit resets counter, stays tripped until reset(). Defaults: threshold 5, window 10s. - Session records each interactive PTY exit in the breaker; on trip it flips _status to 'error', sets _respawnBlocked, emits respawnBreakerTripped. startInteractive() refuses to respawn while blocked, so all recovery/reconnect callers stop looping uniformly. - Explicit user restart (POST /api/sessions/:id/interactive) calls resetRespawnBreaker() so intentional restarts are never blocked. - New SSE event session:respawnBreakerTripped wired in sse-events.ts + constants.js (registries in sync) + session-listener-wiring.ts; minimal diagnostic toast in app.js. - Tests: test/respawn-pty-breaker.test.ts (pure trip/reset/window + MockSession session-level trip/reset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3c0e6286f6
commit
286cf0768d
@@ -156,6 +156,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
|
||||
@@ -1852,6 +1853,15 @@ class CodemanApp {
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
// COD-118: the interactive PTY exit circuit breaker tripped (repeated non-zero exits).
|
||||
// The errored status itself arrives via session:updated; this just surfaces a toast for
|
||||
// diagnostic clarity so a silently-looping session is obvious. Restart clears the breaker.
|
||||
_onSessionRespawnBreakerTripped(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
const label = session?.name || 'Session';
|
||||
this.showToast?.(`${label} stopped: repeated crashes detected. Restart to retry.`, 'error');
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) {
|
||||
|
||||
@@ -262,6 +262,7 @@ const SSE_EVENTS = {
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
|
||||
@@ -633,6 +633,9 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// COD-118: an explicit user-initiated start clears any tripped PTY-exit
|
||||
// circuit breaker so an intentional restart is never blocked by a prior crash-loop.
|
||||
session.resetRespawnBreaker();
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface SessionListenerRefs {
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
|
||||
limitResume: (data: { attempt: number }) => void;
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
respawnBreakerTripped: (data: { count: number }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
@@ -270,6 +271,27 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/**
|
||||
* Broadcasts `session:respawnBreakerTripped` (COD-118) — repeated non-zero PTY exits
|
||||
* tripped the circuit breaker; the session is now errored and respawn is blocked.
|
||||
* Also pushes the errored state (`session:updated`) so the tab renders the error,
|
||||
* persists it, and notifies for diagnostic visibility.
|
||||
*/
|
||||
respawnBreakerTripped: (data: { count: number }) => {
|
||||
deps.broadcast(SseEvent.SessionRespawnBreakerTripped, { sessionId: session.id, ...data });
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
deps.persistSessionState(session);
|
||||
deps.sendPushNotifications(SseEvent.SessionRespawnBreakerTripped, {
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
count: data.count,
|
||||
});
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordError('Respawn circuit breaker tripped', `${data.count} non-zero PTY exits within window`);
|
||||
}
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
@@ -387,6 +409,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.on('limitResume', refs.limitResume);
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
@@ -420,6 +443,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.off('limitResume', refs.limitResume);
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
|
||||
@@ -80,6 +80,8 @@ export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const
|
||||
export const SessionLimitResume = 'session:limitResume' as const;
|
||||
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
|
||||
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
|
||||
/** Interactive-PTY exit circuit breaker tripped (COD-118): repeated non-zero exits; respawn blocked, session errored. */
|
||||
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
@@ -384,6 +386,7 @@ export const SseEvent = {
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
|
||||
Reference in New Issue
Block a user