mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
fix(review): breaker reset semantics, trip observability, push template (PR #147)
- Breaker reset is now explicit-only: POST /api/sessions/:id/interactive no
longer unconditionally resets the PTY-exit breaker (that endpoint IS the
frontend's automatic re-attach path, so the breaker could never trip on the
COD-115 crash loop and any tab click silently re-armed it). The route accepts
a schema-validated optional body flag {clearBreaker:true}
(InteractiveStartSchema) and resets only when it is sent.
- Frontend restart control: app.js selectSession keeps the bare auto-attach
(no body, never clears); when the selected session has respawnBlocked it asks
for explicit user confirmation and only then re-POSTs with clearBreaker:true.
respawnBlocked is surfaced via SessionState/toState() (runtime-only, not
restored on boot so recovery can re-attach).
- Trip observability: WebServer.setupSessionListeners() is now idempotent
(skips while refs are attached) and the re-attach routes (/interactive,
/interactive-respawn, /shell) re-run it, restoring the wiring that the exit
handler detaches on every PTY exit — without this the 5th-exit trip had
guaranteed zero listeners (no SSE, no push, no persist, no run-summary).
- Push notification: added SessionRespawnBreakerTripped to PUSH_EVENT_MAP
('Session crash loop stopped', urgency critical) with an exit-count body
branch; previously sendPushNotifications silently no-oped.
- Minor: buildMuxAttachEnv() truecolor param is now actually passed
(codex/gemini, mirrors buildEnvExports); buildClaudeEnv() uses delete for
COLORTERM/CLAUDECODE (same node-pty "KEY=undefined" quirk as COD-115).
- Tests: route tests assert auto-reattach does NOT reset, clearBreaker resets,
invalid flag rejected, and listener re-wiring on /interactive + /shell;
real-wiring lifecycle tests (createSessionListeners/attach/detach) prove the
exit-detach gap and that re-setup keeps the 5th-exit trip observable;
PUSH_EVENT_MAP regression guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,25 @@
|
||||
* INJECTED time (no real timers, fully deterministic), plus a Session-level
|
||||
* assertion via MockSession that repeated non-zero exits flip the session to
|
||||
* `error` + block respawn, and that an explicit reset re-enables spawning.
|
||||
* Also covers the REAL listener wiring lifecycle (createSessionListeners /
|
||||
* attach / detach): the wiring exit handler detaches everything on each PTY
|
||||
* exit, so the re-attach routes must re-wire or the trip goes unobserved.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
InteractivePtyExitBreaker,
|
||||
DEFAULT_BREAKER_THRESHOLD,
|
||||
DEFAULT_BREAKER_WINDOW_MS,
|
||||
} from '../src/session-pty-exit-breaker.js';
|
||||
import { MockSession } from './mocks/index.js';
|
||||
import {
|
||||
createSessionListeners,
|
||||
attachSessionListeners,
|
||||
detachSessionListeners,
|
||||
type SessionListenerRefs,
|
||||
} from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
import type { Session } from '../src/session.js';
|
||||
|
||||
describe('InteractivePtyExitBreaker — pure logic', () => {
|
||||
it('exports sane default constants', () => {
|
||||
@@ -188,3 +199,103 @@ describe('Session-level trip/reset (AC#4, via MockSession)', () => {
|
||||
expect(breaker.tripped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trip observability through the REAL listener wiring (COD-118)', () => {
|
||||
// Mirrors the server: refs map + removeSessionListenerRefs (called by the wiring
|
||||
// exit handler on EVERY PTY exit) detaching all listeners, and an idempotent
|
||||
// setup() like WebServer.setupSessionListeners that the re-attach routes
|
||||
// (/interactive, /interactive-respawn, /shell) now re-run.
|
||||
function makeHarness() {
|
||||
const session = new MockSession('wiring-breaker-session');
|
||||
const refsMap = new Map<string, SessionListenerRefs>();
|
||||
const deps = {
|
||||
broadcast: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
persistSessionState: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => ({ id: session.id })),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
stopTranscriptWatcher: vi.fn(),
|
||||
cleanupSessionBatches: vi.fn(),
|
||||
cancelPersistDebounce: vi.fn(),
|
||||
removeRunSummaryTracker: vi.fn(),
|
||||
// Same as server.ts removeSessionListenerRefs: detach ALL wiring listeners.
|
||||
removeSessionListenerRefs: (id: string) => {
|
||||
const refs = refsMap.get(id);
|
||||
if (refs) detachSessionListeners(session as unknown as Session, refs);
|
||||
refsMap.delete(id);
|
||||
},
|
||||
cleanupRespawnOnExit: vi.fn(),
|
||||
getStore: vi.fn(),
|
||||
registerAttachment: vi.fn(async () => {}),
|
||||
};
|
||||
const setup = () => {
|
||||
if (refsMap.has(session.id)) return; // idempotence guard, as in server.ts
|
||||
const refs = createSessionListeners(
|
||||
session as unknown as Session,
|
||||
deps as unknown as Parameters<typeof createSessionListeners>[1]
|
||||
);
|
||||
refsMap.set(session.id, refs);
|
||||
attachSessionListeners(session as unknown as Session, refs);
|
||||
};
|
||||
return { session, deps, setup };
|
||||
}
|
||||
|
||||
it('every PTY exit detaches ALL wiring listeners (the gap the re-attach routes must close)', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
session.emit('exit', 1);
|
||||
// After exit #1 the trip listener is gone — a later trip would be unobserved.
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(0);
|
||||
expect(session.listenerCount('terminal')).toBe(0);
|
||||
});
|
||||
|
||||
it('re-running setup() after each exit keeps the 5th-exit trip observable (SSE + push + persist)', () => {
|
||||
const { session, deps, setup } = makeHarness();
|
||||
setup(); // session-create wiring
|
||||
// Exits 1–4: each detaches the wiring; the /interactive re-attach re-wires it.
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
session.emit('exit', 1);
|
||||
setup(); // what the fixed re-attach routes now do
|
||||
}
|
||||
// 5th rapid non-zero exit: the real Session emits respawnBreakerTripped
|
||||
// (inside its onExit handler) BEFORE emitting 'exit'.
|
||||
session.emit('respawnBreakerTripped', { count: 5 });
|
||||
session.emit('exit', 1);
|
||||
|
||||
expect(deps.broadcast).toHaveBeenCalledWith(SseEvent.SessionRespawnBreakerTripped, {
|
||||
sessionId: session.id,
|
||||
count: 5,
|
||||
});
|
||||
expect(deps.sendPushNotifications).toHaveBeenCalledWith(
|
||||
SseEvent.SessionRespawnBreakerTripped,
|
||||
expect.objectContaining({ sessionId: session.id, count: 5 })
|
||||
);
|
||||
expect(deps.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setup() is idempotent — re-running while still wired must not double-attach', () => {
|
||||
const { session, setup } = makeHarness();
|
||||
setup();
|
||||
setup(); // e.g. POST /interactive on a freshly created session
|
||||
expect(session.listenerCount('respawnBreakerTripped')).toBe(1);
|
||||
expect(session.listenerCount('exit')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('push template registration (COD-118)', () => {
|
||||
it('SessionRespawnBreakerTripped has a PUSH_EVENT_MAP entry (sendPushNotifications silently no-ops without one)', async () => {
|
||||
const { WebServer } = await import('../src/web/server.js');
|
||||
const map = (WebServer as unknown as Record<string, Record<string, { title: string; urgency: string }>>)[
|
||||
'PUSH_EVENT_MAP'
|
||||
];
|
||||
expect(map).toBeDefined();
|
||||
const entry = map[SseEvent.SessionRespawnBreakerTripped];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.urgency).toBe('critical');
|
||||
expect(entry.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -608,6 +608,54 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
// COD-118: this endpoint is ALSO the frontend's automatic re-attach path, so it
|
||||
// must never clear a tripped PTY-exit breaker unless the request explicitly asks.
|
||||
it('does NOT clear the PTY-exit breaker on an automatic re-attach (no body)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the PTY-exit breaker when the explicit restart flag is sent (COD-118)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).toHaveBeenCalledTimes(1);
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-boolean clearBreaker flag', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
payload: { clearBreaker: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
|
||||
expect(harness.ctx._session.resetRespawnBreaker).not.toHaveBeenCalled();
|
||||
expect(harness.ctx._session.startInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// COD-118: the wiring exit handler detaches ALL session listeners on PTY exit;
|
||||
// re-attach must restore them or later trips/output go unobserved.
|
||||
it('re-runs session listener wiring before starting', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/shell ==========
|
||||
@@ -622,6 +670,8 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.startShell).toHaveBeenCalled();
|
||||
// COD-118: re-attach restores listener wiring detached by a prior PTY exit.
|
||||
expect(harness.ctx.setupSessionListeners).toHaveBeenCalledWith(harness.ctx._session);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
|
||||
Reference in New Issue
Block a user