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:
+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