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:
Codeman maintainer
2026-07-12 18:21:42 +02:00
parent 09fd1e495f
commit 360d58ca4f
10 changed files with 265 additions and 19 deletions
+50
View File
@@ -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 () => {