mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge PR #147 from aakhter/cod-168-pty-exit-breaker: scrub TMUX vars + PTY-exit circuit breaker (COD-115/COD-118)
Includes review fixes: breaker reset only on explicit clearBreaker restarts (auto-reattach never clears), trip observability survives listener detach, push notification wired into PUSH_EVENT_MAP. # Conflicts: # src/session.ts
This commit is contained in:
@@ -14,6 +14,18 @@ import { program } from './cli.js';
|
||||
// In web mode, we should NOT exit on transient errors — log and continue
|
||||
const isWebMode = process.argv.includes('web');
|
||||
|
||||
// COD-115: Codeman IS a tmux controller; it must never present as a tmux *client*.
|
||||
// If the web server is launched from inside a tmux pane it inherits TMUX/TMUX_PANE,
|
||||
// and tmux's nesting guard then kills every new attach-bridge PTY (exit 1 → respawn
|
||||
// loop, crash-looping any new tmux-backed session). Scrub at the root so every
|
||||
// downstream `{...process.env}` spread (attach, send-keys, create) is clean regardless
|
||||
// of launch context. `delete` (not `= undefined`, which node-pty serializes as the
|
||||
// literal string "undefined" and fails to clear).
|
||||
if (isWebMode) {
|
||||
delete process.env.TMUX;
|
||||
delete process.env.TMUX_PANE;
|
||||
}
|
||||
|
||||
import { MAX_CONSECUTIVE_ERRORS, ERROR_RESET_MS } from './config/server-timing.js';
|
||||
|
||||
// Track consecutive unhandled errors in web mode — restart after too many
|
||||
|
||||
@@ -102,14 +102,12 @@ export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildClaudeEnv(sessionId: string): Record<string, string | undefined> {
|
||||
return {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
@@ -117,6 +115,11 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
delete env.COLORTERM;
|
||||
delete env.CLAUDECODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,17 +127,32 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
|
||||
* since the mux session already has those set.
|
||||
*
|
||||
* @param truecolorEnabled - When true, set COLORTERM=truecolor (COD-75 opt-in);
|
||||
* otherwise leave COLORTERM unset. Mirrors buildEnvExports() so both paths agree.
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildMuxAttachEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
export function buildMuxAttachEnv(truecolorEnabled?: boolean): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
};
|
||||
// COD-115: keys to UNSET must be `delete`d, NOT set to `undefined`. On a
|
||||
// `{...process.env}` spread the key stays present with value undefined, and node-pty
|
||||
// serializes it as the literal string "TMUX=undefined" — a non-empty value that still
|
||||
// trips tmux's nesting guard, killing the attach-bridge PTY (exit 1 → respawn loop).
|
||||
// The server can be launched from inside tmux; attach clients must never inherit that
|
||||
// parent tmux context. (Same fix the working create path uses in tmux-manager.ts.)
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
delete env.CLAUDECODE;
|
||||
if (truecolorEnabled) {
|
||||
env.COLORTERM = 'truecolor';
|
||||
} else {
|
||||
delete env.COLORTERM; // COD-75: unset for non-truecolor (was `: undefined`, same node-pty quirk)
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @fileoverview Circuit breaker bounding repeated non-zero interactive-PTY exits (COD-118).
|
||||
*
|
||||
* Defense-in-depth after COD-115: if the interactive PTY exits non-zero repeatedly,
|
||||
* external recovery/reconnect paths recreate it indefinitely (COD-115 observed 114
|
||||
* `exited with code: 1` events + orphan sessions). This breaker tracks recent
|
||||
* non-zero exits within a sliding window and "trips" once they exceed a threshold,
|
||||
* so the Session can refuse to respawn and surface an error state instead of looping.
|
||||
*
|
||||
* Design notes:
|
||||
* - PURE + dependency-free. Time is INJECTED (`nowMs` passed to `recordExit`); the
|
||||
* breaker never calls `Date.now()` itself, so trip/window logic is deterministically
|
||||
* unit-testable with no real timers.
|
||||
* - A clean (exit code 0) exit resets the counter — a session that exited normally is
|
||||
* not on a crash-loop. (It does NOT clear an already-tripped breaker; only an explicit
|
||||
* `reset()` — e.g. a user-initiated restart — does that.)
|
||||
* - Once tripped, stays tripped until `reset()`.
|
||||
*
|
||||
* @consumedby session (instantiates one per session; records exits in the interactive
|
||||
* PTY `onExit` handler; gates `startInteractive()` when tripped; `reset()` on restart)
|
||||
* @module session-pty-exit-breaker
|
||||
*/
|
||||
|
||||
/** Non-zero interactive-PTY exits within the window required to trip the breaker. */
|
||||
export const DEFAULT_BREAKER_THRESHOLD = 5;
|
||||
|
||||
/** Sliding window (ms) over which non-zero exits accumulate toward the threshold. */
|
||||
export const DEFAULT_BREAKER_WINDOW_MS = 10_000;
|
||||
|
||||
export interface InteractivePtyExitBreakerOptions {
|
||||
/** Trip after this many non-zero exits within `windowMs` (default 5). */
|
||||
threshold?: number;
|
||||
/** Sliding window length in ms (default 10_000). */
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
export interface RecordExitResult {
|
||||
/** True once the breaker has tripped (stays true until `reset()`). */
|
||||
tripped: boolean;
|
||||
/** Number of non-zero exits currently inside the window. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window counter that trips on rapid repeated non-zero exits.
|
||||
*
|
||||
* 5 within 10s safely clears normal usage (a single exit, an intentional restart)
|
||||
* while tripping fast on a real loop — COD-115 saw 114 exits, far above 5.
|
||||
*/
|
||||
export class InteractivePtyExitBreaker {
|
||||
private readonly _threshold: number;
|
||||
private readonly _windowMs: number;
|
||||
|
||||
/** Timestamps (ms, injected) of recent non-zero exits, oldest first. */
|
||||
private _exitTimes: number[] = [];
|
||||
|
||||
private _tripped = false;
|
||||
|
||||
constructor(opts: InteractivePtyExitBreakerOptions = {}) {
|
||||
this._threshold = opts.threshold ?? DEFAULT_BREAKER_THRESHOLD;
|
||||
this._windowMs = opts.windowMs ?? DEFAULT_BREAKER_WINDOW_MS;
|
||||
}
|
||||
|
||||
/** Whether the breaker has tripped (respawn should be blocked). */
|
||||
get tripped(): boolean {
|
||||
return this._tripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a PTY exit. A zero (clean) exit resets the non-zero counter; a non-zero
|
||||
* exit is added to the window, stale entries are evicted, and the breaker trips
|
||||
* once the in-window count reaches the threshold.
|
||||
*
|
||||
* @param exitCode the PTY exit code (0 = clean)
|
||||
* @param nowMs injected current time in ms (never read from a real clock)
|
||||
*/
|
||||
recordExit(exitCode: number, nowMs: number): RecordExitResult {
|
||||
if (exitCode === 0) {
|
||||
// Clean exit: a normal stop, not a crash-loop. Clear accumulated non-zero
|
||||
// exits. Does NOT un-trip an already-tripped breaker (only reset() does).
|
||||
this._exitTimes = [];
|
||||
return { tripped: this._tripped, count: 0 };
|
||||
}
|
||||
|
||||
// Evict exits strictly older than the window, then record this one.
|
||||
const cutoff = nowMs - this._windowMs;
|
||||
this._exitTimes = this._exitTimes.filter((t) => t > cutoff);
|
||||
this._exitTimes.push(nowMs);
|
||||
|
||||
if (this._exitTimes.length >= this._threshold) {
|
||||
this._tripped = true;
|
||||
}
|
||||
|
||||
return { tripped: this._tripped, count: this._exitTimes.length };
|
||||
}
|
||||
|
||||
/** Clear the tripped state and the non-zero counter (e.g. on intentional restart). */
|
||||
reset(): void {
|
||||
this._exitTimes = [];
|
||||
this._tripped = false;
|
||||
}
|
||||
}
|
||||
+57
-1
@@ -83,6 +83,7 @@ import {
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
import {
|
||||
sanitizeAttachmentHistory,
|
||||
@@ -293,6 +294,13 @@ export class Session extends EventEmitter {
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
private _currentTaskId: string | null = null;
|
||||
|
||||
// COD-118: bound repeated non-zero interactive-PTY exits. Recorded in the
|
||||
// interactive PTY onExit handler; when it trips, the session flips to 'error'
|
||||
// and startInteractive() refuses to respawn until an explicit user restart
|
||||
// calls resetRespawnBreaker(). Defense-in-depth over the COD-115 crash-loop.
|
||||
private readonly _ptyExitBreaker = new InteractivePtyExitBreaker();
|
||||
private _respawnBlocked = false;
|
||||
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
|
||||
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
|
||||
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
||||
@@ -1033,6 +1041,11 @@ export class Session extends EventEmitter {
|
||||
geminiConfig: this._geminiConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
// intent before restarting a crash-looped session. Deliberately NOT restored
|
||||
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
||||
// recovery can re-attach.
|
||||
respawnBlocked: this._respawnBlocked || undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
@@ -1184,7 +1197,9 @@ export class Session extends EventEmitter {
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
|
||||
env: buildMuxAttachEnv(),
|
||||
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
@@ -1268,6 +1283,16 @@ export class Session extends EventEmitter {
|
||||
throw new Error('Session already has a running process');
|
||||
}
|
||||
|
||||
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
||||
// short window), refuse to respawn. This is the uniform choke point that stops
|
||||
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
||||
// An explicit user restart clears it via resetRespawnBreaker().
|
||||
if (this._respawnBlocked) {
|
||||
throw new Error(
|
||||
'Respawn blocked: interactive PTY exited non-zero too many times in a short window (circuit breaker tripped). Restart the session to clear it.'
|
||||
);
|
||||
}
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
const modeLabel = getModeLabel(this.mode);
|
||||
@@ -1510,6 +1535,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this.ptyProcess.onExit(({ exitCode }) => {
|
||||
console.log('[Session] Interactive PTY exited with code:', exitCode);
|
||||
// COD-118: record the exit in the circuit breaker BEFORE status bookkeeping.
|
||||
// A clean (0) exit resets the counter; rapid non-zero repeats trip it.
|
||||
const breakerResult = this._ptyExitBreaker.recordExit(exitCode, Date.now());
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
this._status = 'idle';
|
||||
@@ -1537,10 +1565,38 @@ export class Session extends EventEmitter {
|
||||
if (this._muxSession && this._mux) {
|
||||
this._mux.setAttached(this.id, false);
|
||||
}
|
||||
// COD-118: if the breaker tripped, surface an error state and block the NEXT
|
||||
// respawn so recovery/reconnect callers stop looping. Still emit 'exit' below
|
||||
// for normal cleanup. Cleared by an explicit user restart (resetRespawnBreaker()).
|
||||
if (breakerResult.tripped && !this._respawnBlocked) {
|
||||
this._respawnBlocked = true;
|
||||
this._status = 'error';
|
||||
console.error(
|
||||
`[Session] PTY exit circuit breaker tripped for ${this.id} (${breakerResult.count} non-zero exits within window); blocking respawn.`
|
||||
);
|
||||
this.emit('respawnBreakerTripped', { count: breakerResult.count });
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the interactive-PTY exit circuit breaker (COD-118).
|
||||
*
|
||||
* Called on an EXPLICIT, user-initiated (re)start so an intentional restart is
|
||||
* never blocked by a prior crash-loop trip. Automatic recovery/reconnect paths
|
||||
* must NOT call this — that's the whole point of the breaker.
|
||||
*/
|
||||
resetRespawnBreaker(): void {
|
||||
this._ptyExitBreaker.reset();
|
||||
this._respawnBlocked = false;
|
||||
}
|
||||
|
||||
/** Whether the interactive-PTY exit circuit breaker is currently tripped (COD-118). */
|
||||
get respawnBlocked(): boolean {
|
||||
return this._respawnBlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
||||
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
||||
|
||||
@@ -291,6 +291,11 @@ export interface SessionState {
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/**
|
||||
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
|
||||
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
|
||||
*/
|
||||
respawnBlocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-11
@@ -174,6 +174,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'],
|
||||
|
||||
@@ -2019,6 +2020,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) {
|
||||
@@ -3738,17 +3748,40 @@ class CodemanApp {
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
: `/api/sessions/${sessionId}/interactive`;
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
// Update local session state
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to attach to restored session:', err);
|
||||
if (session.respawnBlocked) {
|
||||
// COD-118: the PTY-exit circuit breaker tripped for this session — the
|
||||
// automatic re-attach must NOT silently clear it (that would re-arm the
|
||||
// crash loop on every tab click / page load). Restart only on explicit
|
||||
// user confirmation; the confirmed request carries clearBreaker:true.
|
||||
const label = session.name || 'Session';
|
||||
if (window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}/interactive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clearBreaker: true }),
|
||||
});
|
||||
session.respawnBlocked = false;
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to restart crash-looped session:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
// Deliberately NO body: this automatic path must never clear a tripped
|
||||
// PTY-exit breaker (COD-118).
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
: `/api/sessions/${sessionId}/interactive`;
|
||||
await fetch(endpoint, { method: 'POST' });
|
||||
// Update local session state
|
||||
session.status = 'busy';
|
||||
} catch (err) {
|
||||
console.error('Failed to attach to restored session:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,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',
|
||||
|
||||
@@ -246,6 +246,10 @@ export function registerRespawnRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Re-attach listener wiring if a prior PTY exit detached it (the wiring exit
|
||||
// handler removes ALL session listeners; idempotent — no-op while still attached).
|
||||
await ctx.setupSessionListeners(session);
|
||||
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
QuickStartSchema,
|
||||
InteractiveStartSchema,
|
||||
} from '../schemas.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -622,6 +623,14 @@ export function registerSessionRoutes(
|
||||
|
||||
app.post('/api/sessions/:id/interactive', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
// Body is optional (auto-reattach callers send none) — same idiom as /interactive-respawn.
|
||||
const bodyResult = req.body
|
||||
? InteractiveStartSchema.safeParse(req.body)
|
||||
: { success: true as const, data: {} as { clearBreaker?: boolean } };
|
||||
if (!bodyResult.success) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||||
}
|
||||
const { clearBreaker } = bodyResult.data;
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
if (session.isBusy()) {
|
||||
@@ -644,6 +653,20 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// COD-118: ONLY an explicit user-initiated restart (body {clearBreaker:true})
|
||||
// clears a tripped PTY-exit circuit breaker. This endpoint is ALSO the frontend's
|
||||
// automatic re-attach path (selectSession auto-POSTs it for any pid===null
|
||||
// session), so an unconditional reset here would re-arm the exact crash loop
|
||||
// the breaker exists to stop — auto-reattach sends no body and must not clear.
|
||||
if (clearBreaker) {
|
||||
session.resetRespawnBreaker();
|
||||
}
|
||||
// Re-attach listener wiring if a prior PTY exit detached it: the wiring exit
|
||||
// handler removes ALL session listeners (incl. respawnBreakerTripped), and only
|
||||
// session-create/boot-recovery paths ran setupSessionListeners before this fix —
|
||||
// without this, a re-attached session's SSE/terminal/trip events go unobserved.
|
||||
// setupSessionListeners is idempotent (no-op while refs are still attached).
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
@@ -671,6 +694,8 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-attach listener wiring if a prior PTY exit detached it (see /interactive).
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startShell();
|
||||
getLifecycleLog().log({
|
||||
event: 'started',
|
||||
|
||||
@@ -828,6 +828,16 @@ export const SubagentWindowStatesSchema = z
|
||||
/** PUT /api/subagent-parents */
|
||||
export const SubagentParentMapSchema = z.record(z.string(), z.string());
|
||||
|
||||
/** POST /api/sessions/:id/interactive */
|
||||
export const InteractiveStartSchema = z.object({
|
||||
/**
|
||||
* COD-118: explicit user-initiated restart — clears a tripped PTY-exit circuit
|
||||
* breaker before starting. Automatic reconnect/re-attach callers (e.g. the
|
||||
* frontend's selectSession auto-attach) must NOT send this flag.
|
||||
*/
|
||||
clearBreaker: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/interactive-respawn */
|
||||
export const InteractiveRespawnSchema = z.object({
|
||||
respawnConfig: RespawnConfigSchema.optional(),
|
||||
|
||||
@@ -1293,6 +1293,13 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
|
||||
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
|
||||
// /interactive-respawn, /shell) call this again to restore observability
|
||||
// (terminal SSE, error/exit broadcasts, the COD-118 respawnBreakerTripped
|
||||
// handler). Skip when the refs are still attached to avoid double-wiring.
|
||||
if (this.sessionListenerRefs.has(session.id)) return;
|
||||
|
||||
// Create run summary tracker for this session
|
||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||||
@@ -1794,6 +1801,7 @@ export class WebServer extends EventEmitter {
|
||||
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
|
||||
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
|
||||
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
|
||||
[SseEvent.SessionRespawnBreakerTripped]: { title: 'Session crash loop stopped', urgency: 'critical' },
|
||||
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
|
||||
};
|
||||
|
||||
@@ -1826,6 +1834,9 @@ export class WebServer extends EventEmitter {
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
|
||||
@@ -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). */
|
||||
@@ -395,6 +397,7 @@ export const SseEvent = {
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionRespawnBreakerTripped,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
|
||||
Reference in New Issue
Block a user