fix: harden resume and SSE replay
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# Pre-deployment Fix Wave Report
|
||||
|
||||
Date: 2026-07-14
|
||||
Worktree: `/home/chirone/ThothII/.worktrees/activity-log-cte-layout`
|
||||
Base: `e5366d14a6da8fb331d94be60b8929cefb1fe3e0`
|
||||
|
||||
## Outcome
|
||||
|
||||
All three reviewed findings are implemented in one coherent backend/frontend wave:
|
||||
|
||||
1. Resume leaves the prior selection, Zustand state, document panel, and EventSource untouched
|
||||
until `POST /resume` succeeds. Cold Resume changes state and reconnects only after backend
|
||||
clear/rebind; already-active same-session Resume preserves the existing binding; failure is a
|
||||
no-op apart from the fixed toast.
|
||||
2. SSE uses monotonically increasing per-session ids, cursor-filtered replay, native and manual
|
||||
reconnect cursors, id continuity across `hub.clear`, and descriptor-id pending-gate
|
||||
idempotence at both backend and frontend layers.
|
||||
3. Generic Pi system events and readiness errors are projected through explicit public
|
||||
allowlists. Sentinel URLs, paths, tokens, stderr, commands, and extra fields do not reach HTTP
|
||||
or SSE.
|
||||
|
||||
No harness, workflow, persistence, model, CTE viewer, CTE card, or shared Card file changed.
|
||||
|
||||
## Interfaces
|
||||
|
||||
- Frontend `resumeSession(id)` now returns
|
||||
`Promise<{ id: string; alreadyActive: boolean }>` via `ResumeSessionResult`.
|
||||
- Backend successful Resume always returns the same shape:
|
||||
- running/waiting runtime: `{ id, alreadyActive: true }`
|
||||
- validated cold runtime: `{ id, alreadyActive: false }`
|
||||
- `SseHub.publish(sessionId, event, data): number` returns the assigned SSE id.
|
||||
- `SseHub.subscribe(sessionId, send, { afterId, pending })` calls
|
||||
`send(event, data, id)` for replay/live frames with `id > afterId`.
|
||||
- `GET /sessions/:id/events` accepts native `Last-Event-ID` and manual
|
||||
`?lastEventId=<integer>`; when both are valid it uses the greater cursor.
|
||||
- Every emitted SSE frame is `id: <n>\nevent: <name>\ndata: <json>\n\n`.
|
||||
- Public readiness failure is exactly:
|
||||
`Session services are not ready. Check configuration and connectivity, then try again.`
|
||||
- Generic Pi system events are exactly `{ type: "system_event", event }`, and `event` must be a
|
||||
non-empty string.
|
||||
|
||||
## Files
|
||||
|
||||
Backend production:
|
||||
|
||||
- `backend/src/bridge/session-bridge.ts`
|
||||
- `backend/src/routes/sessions.ts`
|
||||
- `backend/src/sse/sse-hub.ts`
|
||||
|
||||
Backend tests:
|
||||
|
||||
- `backend/test/routes-sessions.test.ts`
|
||||
- `backend/test/session-bridge.test.ts`
|
||||
- `backend/test/sse-hub.test.ts`
|
||||
- `backend/test/sse-route.test.ts` (new)
|
||||
|
||||
Frontend production/support:
|
||||
|
||||
- `frontend/src/api/sessions.ts`
|
||||
- `frontend/src/api/types.ts`
|
||||
- `frontend/src/shell/AppShell.tsx`
|
||||
- `frontend/src/store/sessionStore.ts`
|
||||
- `frontend/src/stream/useSessionStream.ts`
|
||||
- `frontend/src/test/fakeEventSource.ts`
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- `frontend/src/api/sessions.test.ts`
|
||||
- `frontend/src/shell/AppShell.session-mgmt.test.tsx`
|
||||
- `frontend/src/store/sessionStore.test.ts`
|
||||
- `frontend/src/stream/useSessionStream.test.tsx`
|
||||
|
||||
## TDD RED/GREEN evidence
|
||||
|
||||
### 1. Backend Resume result and client-boundary allowlists
|
||||
|
||||
RED command:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts
|
||||
```
|
||||
|
||||
RED output (exit 1):
|
||||
|
||||
```text
|
||||
Test Files 2 failed (2)
|
||||
Tests 6 failed | 35 passed (41)
|
||||
|
||||
expected { id: 's1' } to deeply equal { id: 's1', alreadyActive: false }
|
||||
expected raw readiness URL/token/path to equal the fixed public message
|
||||
expected three raw generic system events to equal [{ type: 'system_event', event: 'session_exit' }]
|
||||
```
|
||||
|
||||
GREEN command:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts
|
||||
```
|
||||
|
||||
GREEN output (exit 0):
|
||||
|
||||
```text
|
||||
✓ test/session-bridge.test.ts (14 tests)
|
||||
✓ test/routes-sessions.test.ts (27 tests)
|
||||
Test Files 2 passed (2)
|
||||
Tests 41 passed (41)
|
||||
```
|
||||
|
||||
### 2. Backend exact-once SseHub and route framing
|
||||
|
||||
RED command:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run test/sse-hub.test.ts test/sse-route.test.ts
|
||||
```
|
||||
|
||||
RED output (exit 1):
|
||||
|
||||
```text
|
||||
Test Files 2 failed (2)
|
||||
Tests 7 failed (7)
|
||||
|
||||
expected [undefined, undefined, undefined] to deeply equal [1, 2, 3]
|
||||
expected unconditional replay not to contain "one" / "two"
|
||||
expected one buffered pending gate, received replay plus a second pending emission
|
||||
```
|
||||
|
||||
GREEN command:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run test/sse-hub.test.ts test/sse-route.test.ts
|
||||
```
|
||||
|
||||
GREEN output (exit 0):
|
||||
|
||||
```text
|
||||
✓ test/sse-hub.test.ts (4 tests)
|
||||
✓ test/sse-route.test.ts (3 tests)
|
||||
Test Files 2 passed (2)
|
||||
Tests 7 passed (7)
|
||||
```
|
||||
|
||||
### 3. Frontend cursor tracking and gate idempotence
|
||||
|
||||
RED command:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx src/store/sessionStore.test.ts
|
||||
```
|
||||
|
||||
RED output (exit 1):
|
||||
|
||||
```text
|
||||
Test Files 2 failed (2)
|
||||
Tests 2 failed | 28 passed (30)
|
||||
|
||||
expected /sessions/s1/events to be /sessions/s1/events?lastEventId=7
|
||||
expected duplicate gate pendingWidget to remain null, received gate-1
|
||||
```
|
||||
|
||||
GREEN command:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx src/store/sessionStore.test.ts
|
||||
```
|
||||
|
||||
GREEN output (exit 0):
|
||||
|
||||
```text
|
||||
✓ src/store/sessionStore.test.ts (23 tests)
|
||||
✓ src/stream/useSessionStream.test.tsx (7 tests)
|
||||
Test Files 2 passed (2)
|
||||
Tests 30 passed (30)
|
||||
```
|
||||
|
||||
### 4. Frontend typed Resume and AppShell ordering/preservation
|
||||
|
||||
Typed API RED command:
|
||||
|
||||
```text
|
||||
cd frontend && npx tsc -b
|
||||
```
|
||||
|
||||
Typed API RED output (exit 1):
|
||||
|
||||
```text
|
||||
src/api/sessions.test.ts(43,9): error TS2322: Type 'void' is not assignable to type
|
||||
'{ id: string; alreadyActive: boolean; }'.
|
||||
```
|
||||
|
||||
Lifecycle RED command:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run src/api/sessions.test.ts src/shell/AppShell.session-mgmt.test.tsx
|
||||
```
|
||||
|
||||
Lifecycle RED output (exit 1):
|
||||
|
||||
```text
|
||||
✓ src/api/sessions.test.ts (9 tests)
|
||||
❯ src/shell/AppShell.session-mgmt.test.tsx (15 tests | 4 failed)
|
||||
Test Files 1 failed | 1 passed (2)
|
||||
Tests 4 failed | 20 passed (24)
|
||||
|
||||
already-active same-session Resume created two EventSources instead of one
|
||||
deferred cold Resume closed the document panel before POST completion
|
||||
failed same-session Resume closed the prior EventSource
|
||||
failed Resume with no active session opened an EventSource
|
||||
```
|
||||
|
||||
GREEN commands:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run src/api/sessions.test.ts src/shell/AppShell.session-mgmt.test.tsx
|
||||
cd frontend && npx tsc -b
|
||||
```
|
||||
|
||||
GREEN output (exit 0):
|
||||
|
||||
```text
|
||||
✓ src/api/sessions.test.ts (9 tests)
|
||||
✓ src/shell/AppShell.session-mgmt.test.tsx (15 tests)
|
||||
Test Files 2 passed (2)
|
||||
Tests 24 passed (24)
|
||||
TypeScript: no output, exit 0
|
||||
```
|
||||
|
||||
The AppShell cold-reconnect test additionally proves that the old source accepts an event while
|
||||
Resume is pending, the replacement URL carries `lastEventId=8`, the replacement receives one
|
||||
post-resume transcript/activity row, and two deliveries of the same descriptor id yield one gate.
|
||||
|
||||
## Affected verification
|
||||
|
||||
Backend command:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts \
|
||||
test/sse-hub.test.ts test/sse-route.test.ts test/health.test.ts test/e2e-f1.test.ts
|
||||
```
|
||||
|
||||
Output (exit 0):
|
||||
|
||||
```text
|
||||
Test Files 6 passed (6)
|
||||
Tests 52 passed (52)
|
||||
```
|
||||
|
||||
Backend typecheck:
|
||||
|
||||
```text
|
||||
cd backend && npx tsc --noEmit -p .
|
||||
```
|
||||
|
||||
Output: no output, exit 0.
|
||||
|
||||
Frontend command:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run src/api/sessions.test.ts src/store/sessionStore.test.ts \
|
||||
src/stream/useSessionStream.test.tsx src/shell/AppShell.session-mgmt.test.tsx \
|
||||
src/shell/CentralStatus.test.tsx src/shell/ModelActivityPanel.test.tsx \
|
||||
src/shell/f1-loop.test.tsx src/shell/AppShell.new-session.test.tsx
|
||||
```
|
||||
|
||||
Output (exit 0):
|
||||
|
||||
```text
|
||||
Test Files 8 passed (8)
|
||||
Tests 74 passed (74)
|
||||
```
|
||||
|
||||
Frontend typecheck:
|
||||
|
||||
```text
|
||||
cd frontend && npx tsc -b
|
||||
```
|
||||
|
||||
Output: no output, exit 0.
|
||||
|
||||
## Full verification
|
||||
|
||||
Backend full suite:
|
||||
|
||||
```text
|
||||
cd backend && npx vitest run
|
||||
```
|
||||
|
||||
```text
|
||||
Test Files 22 passed (22)
|
||||
Tests 177 passed (177)
|
||||
```
|
||||
|
||||
Frontend full suite:
|
||||
|
||||
```text
|
||||
cd frontend && npx vitest run
|
||||
```
|
||||
|
||||
```text
|
||||
Test Files 43 passed (43)
|
||||
Tests 271 passed (271)
|
||||
```
|
||||
|
||||
Backend production build:
|
||||
|
||||
```text
|
||||
cd backend && npm run build
|
||||
> tsc -p tsconfig.json
|
||||
exit 0
|
||||
```
|
||||
|
||||
Frontend production build:
|
||||
|
||||
```text
|
||||
cd frontend && npm run build
|
||||
> tsc -b && vite build
|
||||
✓ 4835 modules transformed.
|
||||
✓ built in 8.25s
|
||||
exit 0
|
||||
```
|
||||
|
||||
Final whitespace verification:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
no output, exit 0
|
||||
```
|
||||
|
||||
## Self-review
|
||||
|
||||
- Resume sequencing: backend `clear` and runtime binding precede the HTTP success; frontend state
|
||||
mutation and stream generation follow it. Failure catch only emits fixed UI copy.
|
||||
- Already active: same-session returns before reset/generation/manifest repaint; different session
|
||||
resets the single-session store and binds the new id only after success.
|
||||
- SSE exact-once: ids are transport identity, not content hashes; replay is strictly `id > cursor`;
|
||||
`clear` retains the counter; pending gate matching uses only descriptor id.
|
||||
- Cursor behavior: hook tracks `MessageEvent.lastEventId`, carries it only to a same-id generation,
|
||||
and resets it on session-id change. Native EventSource reconnect remains supported by the route
|
||||
header.
|
||||
- Gate defense: the Zustand set survives pending clear but resets with the session store.
|
||||
- Client boundary: raw `ensure.error` is unused in public responses; generic Pi system events are
|
||||
reconstructed rather than spread; frontend type mirrors the two-field event.
|
||||
- Scope: `git diff` contains no CTE/Card/harness/workflow/persistence/model changes. Four pre-existing
|
||||
modified `.superpowers/sdd/{progress,task-2-report,task-3-report,task-4-report}.md` files are user
|
||||
work and are excluded from staging.
|
||||
|
||||
## Remaining concerns
|
||||
|
||||
- The 200-event SSE ring limit remains intentional. A brand-new page can reconstruct only retained
|
||||
backlog; an in-memory same-session reconnect is exact-once from its cursor.
|
||||
- Per-session sequence counters remain in backend memory after `clear` by design so later cold
|
||||
same-id Resume cannot reuse ids. This is one numeric map entry per session id for the process
|
||||
lifetime.
|
||||
- Frontend tests still print pre-existing MSW unhandled-request and React ref/`act` warnings even
|
||||
though all 271 tests pass. The frontend production build still reports pre-existing large chunk
|
||||
warnings. Neither warning class was introduced or expanded by this change.
|
||||
- No live Pi/DWH smoke was run; this wave changes only REST/SSE/frontend lifecycle boundaries and
|
||||
is covered by fake-Pi, live Fastify SSE, component, full-suite, typecheck, and production-build
|
||||
gates.
|
||||
Reference in New Issue
Block a user