fix: make session resume atomic across restarts
This commit is contained in:
@@ -319,6 +319,152 @@ cd frontend && npm run build
|
|||||||
exit 0
|
exit 0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Integrated re-review closure (2026-07-15)
|
||||||
|
|
||||||
|
This section supersedes the earlier cold same-session assertion that the replacement URL carries
|
||||||
|
`lastEventId=8`. That behavior was correct only while the backend process and its in-memory id
|
||||||
|
sequence survived. A restarted backend begins a fresh sequence, so a successful cold Resume now
|
||||||
|
explicitly discards the browser's cursor before replacing the EventSource.
|
||||||
|
|
||||||
|
All four integrated re-review findings are closed:
|
||||||
|
|
||||||
|
1. `AppShell` passes a dedicated cursor-reset epoch to `useSessionStream`. A cold same-session
|
||||||
|
Resume increments it only after `alreadyActive: false`; a high cursor such as `901` is omitted
|
||||||
|
from the replacement URL and fresh low-id events/gates are consumed. An already-active
|
||||||
|
same-session Resume still preserves its source, cursor, and store.
|
||||||
|
2. `useSessionStream` no longer mutates the cursor ref during render. Effect setup resets cursor
|
||||||
|
state on session/reset-epoch changes, callbacks are guarded by a captured active-source
|
||||||
|
identity, and cleanup clears only its own active identity. A queued event from the replaced
|
||||||
|
source cannot write the new store or poison its next reconnect URL.
|
||||||
|
3. Backend Resume is serialized per session and rechecks runtime state inside the lock. Manifest,
|
||||||
|
readiness, and reopen validation precede the transport commit. Idle/failed replacement creates
|
||||||
|
and binds the new runtime before `hub.clear`, which occurs synchronously immediately before the
|
||||||
|
first `Resuming session` publish. Reopen/create failure returns exactly
|
||||||
|
`Session could not be resumed. Check configuration and connectivity, then try again.`, keeps the
|
||||||
|
prior hub buffer/subscribers attached, and does not expose exception sentinels. Concurrent calls
|
||||||
|
perform one cold start and the waiter returns `alreadyActive: true`.
|
||||||
|
4. `SseHub.forget(id)` removes subscribers, buffered events, and the last id. Permanent session
|
||||||
|
DELETE invokes it after disk deletion; ordinary close and Resume continue to use `clear`, which
|
||||||
|
preserves the id sequence.
|
||||||
|
|
||||||
|
### Re-review files
|
||||||
|
|
||||||
|
Production:
|
||||||
|
|
||||||
|
- `backend/src/pi/pi-process-manager.ts`
|
||||||
|
- `backend/src/routes/sessions.ts`
|
||||||
|
- `backend/src/sse/sse-hub.ts`
|
||||||
|
- `frontend/src/shell/AppShell.tsx`
|
||||||
|
- `frontend/src/stream/useSessionStream.ts`
|
||||||
|
|
||||||
|
Tests/support:
|
||||||
|
|
||||||
|
- `backend/test/pi-process-manager.test.ts`
|
||||||
|
- `backend/test/routes-sessions.test.ts`
|
||||||
|
- `backend/test/sse-hub.test.ts`
|
||||||
|
- `frontend/src/shell/AppShell.session-mgmt.test.tsx`
|
||||||
|
- `frontend/src/stream/useSessionStream.test.tsx`
|
||||||
|
- `frontend/src/test/fakeEventSource.ts`
|
||||||
|
|
||||||
|
### Re-review TDD RED/GREEN evidence
|
||||||
|
|
||||||
|
Frontend RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx \
|
||||||
|
src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 3 failed | 21 passed (24)
|
||||||
|
|
||||||
|
reset epoch: expected the old source to close, received false
|
||||||
|
cold same-session: expected /sessions/s1/events, received ?lastEventId=901
|
||||||
|
stale source: expected an empty transcript, received "stale session one"
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend GREEN command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx \
|
||||||
|
src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
cd frontend && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 24 passed (24)
|
||||||
|
TypeScript: no output, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/sse-hub.test.ts test/routes-sessions.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
RED output (exit 1):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 8 failed | 28 passed (36)
|
||||||
|
|
||||||
|
three Resume ordering assertions observed clear before reopen/create
|
||||||
|
reopen and create sentinels escaped as raw HTTP 500 responses
|
||||||
|
the concurrent waiter cold-started again instead of returning alreadyActive: true
|
||||||
|
SseHub.forget was absent and DELETE did not invoke permanent cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend GREEN command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/sse-hub.test.ts test/routes-sessions.test.ts
|
||||||
|
cd backend && npx tsc --noEmit -p .
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN output (exit 0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 36 passed (36)
|
||||||
|
TypeScript: no output, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The failure tests publish a post-failure probe through the same hub and prove that a subscriber
|
||||||
|
attached before either reopen or create rejection still receives it. The concurrency test overlaps
|
||||||
|
two same-id requests behind a deferred reopen and proves one manifest/readiness/reopen/create/clear
|
||||||
|
sequence.
|
||||||
|
|
||||||
|
### Initial re-review verification (before independent-review hardening)
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run
|
||||||
|
Test Files 22 passed (22)
|
||||||
|
Tests 182 passed (182)
|
||||||
|
|
||||||
|
cd frontend && npx vitest run
|
||||||
|
Test Files 43 passed (43)
|
||||||
|
Tests 273 passed (273)
|
||||||
|
|
||||||
|
cd backend && npm run build
|
||||||
|
> tsc -p tsconfig.json
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
cd frontend && npm run build
|
||||||
|
> tsc -b && vite build
|
||||||
|
✓ 4835 modules transformed.
|
||||||
|
✓ built in 8.46s
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`git diff --check` produced no output (exit 0). The frontend build retains its pre-existing
|
||||||
|
large-chunk warning; no new build or type errors were introduced.
|
||||||
|
|
||||||
Final whitespace verification:
|
Final whitespace verification:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -328,15 +474,15 @@ no output, exit 0
|
|||||||
|
|
||||||
## Self-review
|
## Self-review
|
||||||
|
|
||||||
- Resume sequencing: backend `clear` and runtime binding precede the HTTP success; frontend state
|
- Resume sequencing: reopen and runtime binding precede backend `clear` and HTTP success; frontend
|
||||||
mutation and stream generation follow it. Failure catch only emits fixed UI copy.
|
state mutation and cursor-reset epoch follow it. Failure catch only emits fixed UI copy.
|
||||||
- Already active: same-session returns before reset/generation/manifest repaint; different session
|
- 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.
|
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`;
|
- 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.
|
`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,
|
- Cursor behavior: hook tracks `MessageEvent.lastEventId`, carries it only to an ordinary same-id
|
||||||
and resets it on session-id change. Native EventSource reconnect remains supported by the route
|
generation, and resets it on session-id/cold-runtime epoch change. Native EventSource reconnect
|
||||||
header.
|
remains supported by the route header.
|
||||||
- Gate defense: the Zustand set survives pending clear but resets with the session store.
|
- 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
|
- 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.
|
reconstructed rather than spread; frontend type mirrors the two-field event.
|
||||||
@@ -348,12 +494,123 @@ no output, exit 0
|
|||||||
|
|
||||||
- The 200-event SSE ring limit remains intentional. A brand-new page can reconstruct only retained
|
- 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.
|
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
|
- Per-session sequence counters remain in backend memory after `clear` by design so later in-process
|
||||||
same-id Resume cannot reuse ids. This is one numeric map entry per session id for the process
|
cold same-id Resume cannot reuse ids. Permanent DELETE removes the counter via `forget`.
|
||||||
lifetime.
|
|
||||||
- Frontend tests still print pre-existing MSW unhandled-request and React ref/`act` warnings even
|
- 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
|
though all 276 tests pass. The frontend production build still reports pre-existing large chunk
|
||||||
warnings. Neither warning class was introduced or expanded by this change.
|
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
|
- 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
|
is covered by fake-Pi, live Fastify SSE, component, full-suite, typecheck, and production-build
|
||||||
gates.
|
gates.
|
||||||
|
|
||||||
|
## Independent-review hardening
|
||||||
|
|
||||||
|
The required independent review was run repeatedly against the uncommitted diff. Its first pass
|
||||||
|
found four Important lifecycle edges beyond the integrated findings: queued old-runtime callbacks,
|
||||||
|
post-spawn construction cleanup, concurrent frontend Resume completions, and the passive-effect
|
||||||
|
commit window. Its second pass confirmed those fixes and identified one remaining Important
|
||||||
|
retention issue in the new runtime-identity map. The final pass reported no Critical, Important, or
|
||||||
|
Minor findings and assessed the diff ready to merge.
|
||||||
|
|
||||||
|
The resulting hardening is:
|
||||||
|
|
||||||
|
- Runtime bridge callbacks are gated by the bound runtime identity. Replacement, close, and DELETE
|
||||||
|
invalidate the old identity, so queued old events cannot publish or call `failSession`. An active
|
||||||
|
runtime removed by the manager can still publish its complete public failure sequence; after the
|
||||||
|
terminal unmanaged `agent_end`, its binding is released and later events are rejected.
|
||||||
|
- `PiProcessManager` kills the spawned child and removes any registered map entry if either
|
||||||
|
spawn-boundary stderr setup or later RPC/bridge/map initialization throws.
|
||||||
|
- Resume completion compares against synchronously maintained current active-session identity.
|
||||||
|
Concurrent `alreadyActive: false` then `alreadyActive: true` results preserve the cold source,
|
||||||
|
cursor, store, and replayed gate.
|
||||||
|
- Stream source replacement uses a layout effect. A deterministic later-layout-effect test delivers
|
||||||
|
a queued old event inside the former commit-to-passive-cleanup window and proves it is ignored.
|
||||||
|
- Cursor tests cover both a restarted backend's fresh low ids and an in-process hub's preserved high
|
||||||
|
ids followed by a cursor-bearing ordinary reconnect.
|
||||||
|
|
||||||
|
### Hardening TDD RED/GREEN evidence
|
||||||
|
|
||||||
|
Backend identity/construction RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/pi-process-manager.test.ts test/routes-sessions.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 3 failed | 69 passed (72)
|
||||||
|
|
||||||
|
post-spawn reader initialization did not kill the child
|
||||||
|
replaced and deleted runtime callbacks still called failSession/published
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional spawn-boundary and terminal-release RED checks:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/pi-process-manager.test.ts \
|
||||||
|
-t "spawn boundary initialization"
|
||||||
|
Tests 1 failed | 38 skipped (39)
|
||||||
|
|
||||||
|
cd backend && npx vitest run test/routes-sessions.test.ts -t "terminal sequence"
|
||||||
|
Tests 1 failed | 34 skipped (35)
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend concurrency/layout RED command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx \
|
||||||
|
src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 2 failed | 25 passed (27)
|
||||||
|
|
||||||
|
the later-layout-effect event wrote "commit-window stale text"
|
||||||
|
the false→true completion pair erased pending gate "cold-gate"
|
||||||
|
```
|
||||||
|
|
||||||
|
Final focused GREEN commands:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run test/pi-process-manager.test.ts \
|
||||||
|
test/routes-sessions.test.ts test/sse-hub.test.ts
|
||||||
|
cd backend && npx tsc --noEmit -p .
|
||||||
|
|
||||||
|
Test Files 3 passed (3)
|
||||||
|
Tests 79 passed (79)
|
||||||
|
TypeScript: no output, exit 0
|
||||||
|
|
||||||
|
cd frontend && npx vitest run src/stream/useSessionStream.test.tsx \
|
||||||
|
src/shell/AppShell.session-mgmt.test.tsx
|
||||||
|
cd frontend && npx tsc -b
|
||||||
|
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 27 passed (27)
|
||||||
|
TypeScript: no output, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Final full verification after review hardening
|
||||||
|
|
||||||
|
```text
|
||||||
|
cd backend && npx vitest run
|
||||||
|
Test Files 22 passed (22)
|
||||||
|
Tests 188 passed (188)
|
||||||
|
|
||||||
|
cd frontend && npx vitest run
|
||||||
|
Test Files 43 passed (43)
|
||||||
|
Tests 276 passed (276)
|
||||||
|
|
||||||
|
cd backend && npm run build
|
||||||
|
> tsc -p tsconfig.json
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
cd frontend && npm run build
|
||||||
|
> tsc -b && vite build
|
||||||
|
✓ 4835 modules transformed.
|
||||||
|
✓ built in 8.47s
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The final frontend run retains the repository's pre-existing MSW/ref/`act` warnings, and the build
|
||||||
|
retains the pre-existing large-chunk warning. No test, typecheck, or build failures remain.
|
||||||
|
|||||||
@@ -68,9 +68,14 @@ export class PiProcessManager {
|
|||||||
cwd: this.cfg.harnessDir,
|
cwd: this.cfg.harnessDir,
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
// Log stderr for debugging (was silently drained)
|
try {
|
||||||
child.stderr.on("data", (d: Buffer) => console.error(`[pi:${sessionId}] stderr:`, d.toString().trim()));
|
// Log stderr for debugging (was silently drained)
|
||||||
return child;
|
child.stderr.on("data", (d: Buffer) => console.error(`[pi:${sessionId}] stderr:`, d.toString().trim()));
|
||||||
|
return child;
|
||||||
|
} catch (error) {
|
||||||
|
try { child.kill(); } catch { /* preserve the initialization error */ }
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
count(): number { return this.runtimes.size; }
|
count(): number { return this.runtimes.size; }
|
||||||
@@ -91,31 +96,39 @@ export class PiProcessManager {
|
|||||||
const author = o.author ?? "dev@local";
|
const author = o.author ?? "dev@local";
|
||||||
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
|
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
|
||||||
const child = this.spawnFn(sessionId, author, provider);
|
const child = this.spawnFn(sessionId, author, provider);
|
||||||
const rpc = new RpcClient(child);
|
let rt: SessionRuntime | undefined;
|
||||||
const bridge = new SessionBridge(rpc);
|
try {
|
||||||
const rt: SessionRuntime = { rpc, bridge, child };
|
const rpc = new RpcClient(child);
|
||||||
bridge.beginTurn();
|
const bridge = new SessionBridge(rpc);
|
||||||
this.runtimes.set(sessionId, rt);
|
const runtime: SessionRuntime = { rpc, bridge, child };
|
||||||
// Identity-checked: a stale child's exit must not evict a newer runtime.
|
rt = runtime;
|
||||||
// Expected teardowns (teardown()/respawn) delete the runtime from the map BEFORE the
|
bridge.beginTurn();
|
||||||
// exit event fires, so reaching this branch with `rt` still mapped means the child
|
this.runtimes.set(sessionId, runtime);
|
||||||
// died on its own: tell the client, or the UI spins forever waiting for a turn end.
|
// Identity-checked: a stale child's exit must not evict a newer runtime.
|
||||||
child.on("exit", (code) => {
|
// Expected teardowns (teardown()/respawn) delete the runtime from the map BEFORE the
|
||||||
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === rt}`);
|
// exit event fires, so reaching this branch with `rt` still mapped means the child
|
||||||
if (this.runtimes.get(sessionId) === rt) {
|
// died on its own: tell the client, or the UI spins forever waiting for a turn end.
|
||||||
rt.bridge.markFailed();
|
child.on("exit", (code) => {
|
||||||
this.runtimes.delete(sessionId);
|
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === runtime}`);
|
||||||
rt.bridge.emitClientEvent({
|
if (this.runtimes.get(sessionId) === runtime) {
|
||||||
type: "info",
|
runtime.bridge.markFailed();
|
||||||
level: "error",
|
this.runtimes.delete(sessionId);
|
||||||
text: `Pi process exited unexpectedly (code ${code ?? "?"})`,
|
runtime.bridge.emitClientEvent({
|
||||||
});
|
type: "info",
|
||||||
rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
level: "error",
|
||||||
rt.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
text: `Pi process exited unexpectedly (code ${code ?? "?"})`,
|
||||||
}
|
});
|
||||||
});
|
runtime.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
||||||
|
runtime.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return rt;
|
return runtime;
|
||||||
|
} catch (error) {
|
||||||
|
if (rt && this.runtimes.get(sessionId) === rt) this.runtimes.delete(sessionId);
|
||||||
|
try { child.kill(); } catch { /* preserve the initialization error */ }
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configure model and thinking. Safe to run alongside deterministic retrieval. */
|
/** Configure model and thinking. Safe to run alongside deterministic retrieval. */
|
||||||
|
|||||||
+109
-36
@@ -10,6 +10,8 @@ const BOOTSTRAP_FAILURE_MESSAGE =
|
|||||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||||
const READINESS_FAILURE_MESSAGE =
|
const READINESS_FAILURE_MESSAGE =
|
||||||
"Session services are not ready. Check configuration and connectivity, then try again.";
|
"Session services are not ready. Check configuration and connectivity, then try again.";
|
||||||
|
const RESUME_FAILURE_MESSAGE =
|
||||||
|
"Session could not be resumed. Check configuration and connectivity, then try again.";
|
||||||
|
|
||||||
function eventCursor(...values: unknown[]): number {
|
function eventCursor(...values: unknown[]): number {
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@@ -25,16 +27,58 @@ export function sessionRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
|
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
|
||||||
) {
|
) {
|
||||||
|
const resumeTails = new Map<string, Promise<void>>();
|
||||||
|
const boundRuntimes = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<PiProcessManager["createFor"]>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const withResumeLock = async <T>(id: string, work: () => Promise<T>): Promise<T> => {
|
||||||
|
const previous = resumeTails.get(id) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const tail = previous.then(() => gate);
|
||||||
|
resumeTails.set(id, tail);
|
||||||
|
await previous;
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (resumeTails.get(id) === tail) resumeTails.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const info = (id: string, text: string, level = "info") =>
|
const info = (id: string, text: string, level = "info") =>
|
||||||
d.hub.publish(id, "info", { type: "info", level, text });
|
d.hub.publish(id, "info", { type: "info", level, text });
|
||||||
|
|
||||||
const bindRuntime = (id: string, rt: ReturnType<PiProcessManager["createFor"]>) =>
|
const bindRuntime = (id: string, rt: ReturnType<PiProcessManager["createFor"]>) => {
|
||||||
rt.bridge.onClientEvent((e) => {
|
const previous = boundRuntimes.get(id);
|
||||||
if (e.type === "system_event" && e.event === "session_failed") {
|
boundRuntimes.set(id, rt);
|
||||||
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
|
try {
|
||||||
}
|
rt.bridge.onClientEvent((e) => {
|
||||||
d.hub.publish(id, e.type, e);
|
// Child termination is asynchronous. Ignore queued events from a runtime once a newer
|
||||||
});
|
// identity is bound or the session is explicitly closed/deleted. The active identity
|
||||||
|
// remains bound after an unexpected exit so its public failure events still reach SSE.
|
||||||
|
if (boundRuntimes.get(id) !== rt) return;
|
||||||
|
if (e.type === "system_event" && e.event === "session_failed") {
|
||||||
|
void d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
|
||||||
|
}
|
||||||
|
d.hub.publish(id, e.type, e);
|
||||||
|
if (
|
||||||
|
e.type === "system_event"
|
||||||
|
&& e.event === "agent_end"
|
||||||
|
&& d.mgr.get(id) !== rt
|
||||||
|
&& boundRuntimes.get(id) === rt
|
||||||
|
) {
|
||||||
|
boundRuntimes.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (previous) boundRuntimes.set(id, previous);
|
||||||
|
else if (boundRuntimes.get(id) === rt) boundRuntimes.delete(id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const bootstrap = (
|
const bootstrap = (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -114,42 +158,69 @@ export function sessionRoutes(
|
|||||||
});
|
});
|
||||||
app.post("/sessions/:id/resume", async (req, reply) => {
|
app.post("/sessions/:id/resume", async (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
const existing = d.mgr.get(id);
|
return withResumeLock(id, async () => {
|
||||||
if (existing) {
|
// This check belongs inside the per-session lock: a preceding cold Resume may have
|
||||||
const state = existing.bridge.turnState();
|
// installed a running runtime while this request was waiting.
|
||||||
if (state === "running" || state === "waiting") {
|
const existing = d.mgr.get(id);
|
||||||
return reply.code(200).send({ id, alreadyActive: true });
|
if (existing) {
|
||||||
|
const state = existing.bridge.turnState();
|
||||||
|
if (state === "running" || state === "waiting") {
|
||||||
|
return reply.code(200).send({ id, alreadyActive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null;
|
||||||
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null;
|
if (manifest?.status === "finalized" || manifest?.archived) {
|
||||||
if (manifest?.status === "finalized" || manifest?.archived) {
|
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
||||||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
}
|
||||||
}
|
const settings = d.getSettings();
|
||||||
const settings = d.getSettings();
|
const ensure = await d.readiness.ensure(settings.workspace ?? "");
|
||||||
const ensure = await d.readiness.ensure(settings.workspace ?? "");
|
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||||
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
|
||||||
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
|
const options = {
|
||||||
const options = {
|
provider: saved?.provider,
|
||||||
provider: saved?.provider,
|
model: saved?.model,
|
||||||
model: saved?.model,
|
thinking: saved?.thinking ?? settings.thinking,
|
||||||
thinking: saved?.thinking ?? settings.thinking,
|
author: getUser(req).id,
|
||||||
author: getUser(req).id,
|
mode: "resume" as const,
|
||||||
mode: "resume" as const,
|
};
|
||||||
};
|
|
||||||
if (existing) d.mgr.teardown(id);
|
// Reopening is validation, not the transport commit point. Keep the old hub intact if
|
||||||
d.hub.clear(id);
|
// persistence cannot be reopened.
|
||||||
await d.tht.reopenSession(id, settings.workspace);
|
try {
|
||||||
const rt = d.mgr.createFor(id, options);
|
await d.tht.reopenSession(id, settings.workspace);
|
||||||
bindRuntime(id, rt);
|
} catch {
|
||||||
info(id, "Resuming session");
|
return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE });
|
||||||
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
|
}
|
||||||
return reply.code(200).send({ id, alreadyActive: false });
|
|
||||||
|
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||||
|
try {
|
||||||
|
if (existing) {
|
||||||
|
boundRuntimes.delete(id);
|
||||||
|
d.mgr.teardown(id);
|
||||||
|
}
|
||||||
|
rt = d.mgr.createFor(id, options);
|
||||||
|
bindRuntime(id, rt);
|
||||||
|
} catch {
|
||||||
|
// A created-but-unbound runtime is not usable. The old hub remains attached because
|
||||||
|
// clear() has not happened yet.
|
||||||
|
if (rt) d.mgr.teardown(id);
|
||||||
|
return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the replacement only after reopen + runtime creation/binding succeeded, and
|
||||||
|
// immediately before the first event produced by the new Resume.
|
||||||
|
d.hub.clear(id);
|
||||||
|
info(id, "Resuming session");
|
||||||
|
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
|
||||||
|
return reply.code(200).send({ id, alreadyActive: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
app.post("/sessions/:id/close", async (req) => {
|
app.post("/sessions/:id/close", async (req) => {
|
||||||
const id = (req.params as { id: string }).id;
|
const id = (req.params as { id: string }).id;
|
||||||
try {
|
try {
|
||||||
await d.tht.closeSession(id, d.getSettings().workspace);
|
await d.tht.closeSession(id, d.getSettings().workspace);
|
||||||
} finally {
|
} finally {
|
||||||
|
boundRuntimes.delete(id);
|
||||||
d.mgr.teardown(id);
|
d.mgr.teardown(id);
|
||||||
d.hub.clear(id);
|
d.hub.clear(id);
|
||||||
}
|
}
|
||||||
@@ -202,8 +273,10 @@ export function sessionRoutes(
|
|||||||
});
|
});
|
||||||
app.delete("/sessions/:id", async (req, reply) => {
|
app.delete("/sessions/:id", async (req, reply) => {
|
||||||
const id = (req.params as any).id;
|
const id = (req.params as any).id;
|
||||||
|
boundRuntimes.delete(id);
|
||||||
d.mgr.teardown(id); // drop any live runtime before deleting on disk
|
d.mgr.teardown(id); // drop any live runtime before deleting on disk
|
||||||
await d.tht.deleteSession(id, d.getSettings().workspace);
|
await d.tht.deleteSession(id, d.getSettings().workspace);
|
||||||
|
d.hub.forget(id);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
|
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
|
||||||
|
|||||||
@@ -91,4 +91,10 @@ export class SseHub {
|
|||||||
this.buffers.delete(sessionId);
|
this.buffers.delete(sessionId);
|
||||||
this.subs.delete(sessionId);
|
this.subs.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanently discard transport state for a deleted session id. */
|
||||||
|
forget(sessionId: string): void {
|
||||||
|
this.clear(sessionId);
|
||||||
|
this.lastIds.delete(sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,36 @@ function recordingChild() {
|
|||||||
return ch;
|
return ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("createFor kills a spawned child when post-spawn initialization throws", () => {
|
||||||
|
const child = recordingChild();
|
||||||
|
child.kill = vi.fn();
|
||||||
|
child.stdout = {
|
||||||
|
on: () => { throw new Error("READER_INIT_SENTINEL"); },
|
||||||
|
};
|
||||||
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
||||||
|
|
||||||
|
expect(() => mgr.createFor("broken-init", {})).toThrow("READER_INIT_SENTINEL");
|
||||||
|
|
||||||
|
expect(child.kill).toHaveBeenCalledOnce();
|
||||||
|
expect(mgr.get("broken-init")).toBeUndefined();
|
||||||
|
expect(mgr.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createFor kills a spawned child when spawn boundary initialization throws", () => {
|
||||||
|
const child = recordingChild();
|
||||||
|
child.kill = vi.fn();
|
||||||
|
child.stderr = {
|
||||||
|
on: () => { throw new Error("STDERR_INIT_SENTINEL"); },
|
||||||
|
};
|
||||||
|
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
|
||||||
|
|
||||||
|
expect(() => mgr.createFor("broken-spawn-init", {})).toThrow("STDERR_INIT_SENTINEL");
|
||||||
|
|
||||||
|
expect(child.kill).toHaveBeenCalledOnce();
|
||||||
|
expect(mgr.get("broken-spawn-init")).toBeUndefined();
|
||||||
|
expect(mgr.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("un exit INATTESO del child notifica il client (info error + agent_end)", async () => {
|
test("un exit INATTESO del child notifica il client (info error + agent_end)", async () => {
|
||||||
const cfg = loadConfig({});
|
const cfg = loadConfig({});
|
||||||
const child = recordingChild();
|
const child = recordingChild();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os from "node:os";
|
|||||||
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { SseHub } from "../src/sse/sse-hub.js";
|
||||||
|
|
||||||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
||||||
@@ -186,7 +187,7 @@ test.each(["idle", "failed"])(
|
|||||||
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
||||||
expect(order).toEqual(["teardown:s1", "clear:s1", "reopen", "create", "start"]);
|
expect(order).toEqual(["reopen", "teardown:s1", "create", "clear:s1", "start"]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ test("POST resume without a runtime clears stale SSE state before cold start", a
|
|||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
expect(response.json()).toEqual({ id: "crashed", alreadyActive: false });
|
expect(response.json()).toEqual({ id: "crashed", alreadyActive: false });
|
||||||
expect(order).toEqual(["clear:crashed", "reopen", "create", "start"]);
|
expect(order).toEqual(["reopen", "create", "clear:crashed", "start"]);
|
||||||
expect(createOptions).toMatchObject({
|
expect(createOptions).toMatchObject({
|
||||||
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", mode: "resume",
|
provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low", mode: "resume",
|
||||||
});
|
});
|
||||||
@@ -275,6 +276,364 @@ test("POST resume keeps a failed runtime stream attached when readiness fails",
|
|||||||
expect(cleared).toBe(false);
|
expect(cleared).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POST resume sanitizes reopen failure and preserves the old hub attachment", async () => {
|
||||||
|
const delivered: string[] = [];
|
||||||
|
const actualHub = new SseHub();
|
||||||
|
let clearCalls = 0;
|
||||||
|
let tornDown = false;
|
||||||
|
const hub = {
|
||||||
|
subscribe: actualHub.subscribe.bind(actualHub),
|
||||||
|
publish: actualHub.publish.bind(actualHub),
|
||||||
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
||||||
|
} as any;
|
||||||
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
||||||
|
hub.publish("s1", "info", { text: "before" });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => ({ bridge: { turnState: () => "idle" } }),
|
||||||
|
teardown: () => { tornDown = true; },
|
||||||
|
} as any,
|
||||||
|
hub,
|
||||||
|
thtRunner: {
|
||||||
|
sessionShow: async () => ({ status: "open", archived: false }),
|
||||||
|
reopenSession: async () => { throw new Error("REOPEN_SENTINEL"); },
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "Session could not be resumed. Check configuration and connectivity, then try again.",
|
||||||
|
});
|
||||||
|
expect(response.body).not.toContain("REOPEN_SENTINEL");
|
||||||
|
expect(clearCalls).toBe(0);
|
||||||
|
expect(tornDown).toBe(false);
|
||||||
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST resume sanitizes runtime creation failure and preserves the old hub attachment", async () => {
|
||||||
|
const delivered: string[] = [];
|
||||||
|
const actualHub = new SseHub();
|
||||||
|
let clearCalls = 0;
|
||||||
|
let createCalls = 0;
|
||||||
|
const hub = {
|
||||||
|
subscribe: actualHub.subscribe.bind(actualHub),
|
||||||
|
publish: actualHub.publish.bind(actualHub),
|
||||||
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
||||||
|
} as any;
|
||||||
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
||||||
|
hub.publish("s1", "info", { text: "before" });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => ({ bridge: { turnState: () => "failed" } }),
|
||||||
|
teardown: () => {},
|
||||||
|
createFor: () => { createCalls += 1; throw new Error("CREATE_SENTINEL"); },
|
||||||
|
} as any,
|
||||||
|
hub,
|
||||||
|
thtRunner: {
|
||||||
|
sessionShow: async () => ({ status: "open", archived: false }),
|
||||||
|
reopenSession: async () => {},
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "Session could not be resumed. Check configuration and connectivity, then try again.",
|
||||||
|
});
|
||||||
|
expect(response.body).not.toContain("CREATE_SENTINEL");
|
||||||
|
expect(createCalls).toBe(1);
|
||||||
|
expect(clearCalls).toBe(0);
|
||||||
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent cold Resume requests serialize and create one runtime", async () => {
|
||||||
|
let runtime: any;
|
||||||
|
let showCalls = 0;
|
||||||
|
let readinessCalls = 0;
|
||||||
|
let reopenCalls = 0;
|
||||||
|
let createCalls = 0;
|
||||||
|
let clearCalls = 0;
|
||||||
|
let releaseReopen!: () => void;
|
||||||
|
let markReopenStarted!: () => void;
|
||||||
|
const reopenStarted = new Promise<void>((resolve) => { markReopenStarted = resolve; });
|
||||||
|
const reopenReleased = new Promise<void>((resolve) => { releaseReopen = resolve; });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => runtime,
|
||||||
|
createFor: () => {
|
||||||
|
createCalls += 1;
|
||||||
|
runtime = {
|
||||||
|
bridge: {
|
||||||
|
turnState: () => "running",
|
||||||
|
onClientEvent: () => {},
|
||||||
|
emitClientEvent: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
configure: async () => {},
|
||||||
|
start: () => {},
|
||||||
|
teardown: () => {},
|
||||||
|
} as any,
|
||||||
|
hub: {
|
||||||
|
clear: () => { clearCalls += 1; },
|
||||||
|
publish: () => {},
|
||||||
|
} as any,
|
||||||
|
thtRunner: {
|
||||||
|
sessionShow: async () => {
|
||||||
|
showCalls += 1;
|
||||||
|
return { status: "open", archived: false };
|
||||||
|
},
|
||||||
|
reopenSession: async () => {
|
||||||
|
reopenCalls += 1;
|
||||||
|
markReopenStarted();
|
||||||
|
await reopenReleased;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
readiness: {
|
||||||
|
ensure: async () => { readinessCalls += 1; return { ok: true }; },
|
||||||
|
} as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
await reopenStarted;
|
||||||
|
const second = app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
releaseReopen();
|
||||||
|
const [firstResponse, secondResponse] = await Promise.all([first, second]);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(firstResponse.json()).toEqual({ id: "s1", alreadyActive: false });
|
||||||
|
expect(secondResponse.json()).toEqual({ id: "s1", alreadyActive: true });
|
||||||
|
expect({ showCalls, readinessCalls, reopenCalls, createCalls, clearCalls }).toEqual({
|
||||||
|
showCalls: 1,
|
||||||
|
readinessCalls: 1,
|
||||||
|
reopenCalls: 1,
|
||||||
|
createCalls: 1,
|
||||||
|
clearCalls: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a replaced runtime cannot publish or fail the newly resumed session", async () => {
|
||||||
|
const controlledBridge = (initialState: string) => {
|
||||||
|
let state = initialState;
|
||||||
|
let listener: ((event: any) => void) | undefined;
|
||||||
|
return {
|
||||||
|
turnState: () => state,
|
||||||
|
setState: (next: string) => { state = next; },
|
||||||
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
||||||
|
emitClientEvent: (event: any) => listener?.(event),
|
||||||
|
pendingWidget: () => null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const oldBridge = controlledBridge("running");
|
||||||
|
const newBridge = controlledBridge("running");
|
||||||
|
const runtimes = [{ bridge: oldBridge }, { bridge: newBridge }];
|
||||||
|
let current: any;
|
||||||
|
let failed = 0;
|
||||||
|
const published: any[] = [];
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => current,
|
||||||
|
createFor: () => { current = runtimes.shift(); return current; },
|
||||||
|
configure: async () => {},
|
||||||
|
start: () => {},
|
||||||
|
teardown: () => { current = undefined; },
|
||||||
|
} as any,
|
||||||
|
hub: {
|
||||||
|
clear: () => {},
|
||||||
|
publish: (_id: string, event: string, data: object) => {
|
||||||
|
published.push({ event, data });
|
||||||
|
return published.length;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
thtRunner: {
|
||||||
|
sessionNew: async () => ({ id: "s1" }),
|
||||||
|
searchPack: async () => {},
|
||||||
|
sessionShow: async () => ({ status: "open", archived: false }),
|
||||||
|
reopenSession: async () => {},
|
||||||
|
failSession: async () => { failed += 1; },
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
oldBridge.setState("idle");
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
published.length = 0;
|
||||||
|
// The manager removes an unexpectedly exited active runtime before its bridge publishes the
|
||||||
|
// public failure sequence. Binding identity, rather than mgr.get(), must still admit it.
|
||||||
|
current = undefined;
|
||||||
|
oldBridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
||||||
|
newBridge.emitClientEvent({ type: "info", text: "new runtime" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(response.json()).toEqual({ id: "s1", alreadyActive: false });
|
||||||
|
expect(failed).toBe(0);
|
||||||
|
expect(published).toEqual([{
|
||||||
|
event: "info",
|
||||||
|
data: { type: "info", text: "new runtime" },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a deleted runtime cannot repopulate or fail the forgotten session", async () => {
|
||||||
|
let listener: ((event: any) => void) | undefined;
|
||||||
|
const bridge = {
|
||||||
|
turnState: () => "running",
|
||||||
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
||||||
|
emitClientEvent: (event: any) => listener?.(event),
|
||||||
|
};
|
||||||
|
const runtime = { bridge };
|
||||||
|
let current: any;
|
||||||
|
let failed = 0;
|
||||||
|
const published: any[] = [];
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => current,
|
||||||
|
createFor: () => { current = runtime; return runtime; },
|
||||||
|
configure: async () => {},
|
||||||
|
start: () => {},
|
||||||
|
teardown: () => { current = undefined; },
|
||||||
|
} as any,
|
||||||
|
hub: {
|
||||||
|
publish: (_id: string, event: string, data: object) => {
|
||||||
|
published.push({ event, data });
|
||||||
|
return published.length;
|
||||||
|
},
|
||||||
|
forget: () => {},
|
||||||
|
} as any,
|
||||||
|
thtRunner: {
|
||||||
|
sessionNew: async () => ({ id: "s1" }),
|
||||||
|
searchPack: async () => {},
|
||||||
|
deleteSession: async () => {},
|
||||||
|
failSession: async () => { failed += 1; },
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
||||||
|
published.length = 0;
|
||||||
|
bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(204);
|
||||||
|
expect(failed).toBe(0);
|
||||||
|
expect(published).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unexpectedly exited runtime publishes its terminal sequence then releases its binding", async () => {
|
||||||
|
let listener: ((event: any) => void) | undefined;
|
||||||
|
const bridge = {
|
||||||
|
onClientEvent: (next: (event: any) => void) => { listener = next; },
|
||||||
|
emitClientEvent: (event: any) => listener?.(event),
|
||||||
|
};
|
||||||
|
const runtime = { bridge };
|
||||||
|
let current: any;
|
||||||
|
let failed = 0;
|
||||||
|
const published: any[] = [];
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => current,
|
||||||
|
createFor: () => { current = runtime; return runtime; },
|
||||||
|
configure: async () => {},
|
||||||
|
start: () => {},
|
||||||
|
teardown: () => { current = undefined; },
|
||||||
|
} as any,
|
||||||
|
hub: {
|
||||||
|
publish: (_id: string, event: string, data: object) => {
|
||||||
|
published.push({ event, data });
|
||||||
|
return published.length;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
thtRunner: {
|
||||||
|
sessionNew: async () => ({ id: "s1" }),
|
||||||
|
searchPack: async () => {},
|
||||||
|
failSession: async () => { failed += 1; },
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
published.length = 0;
|
||||||
|
|
||||||
|
// PiProcessManager deletes an unexpectedly exited runtime before emitting this sequence.
|
||||||
|
current = undefined;
|
||||||
|
bridge.emitClientEvent({ type: "info", level: "error", text: "public failure" });
|
||||||
|
bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
|
||||||
|
bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
||||||
|
bridge.emitClientEvent({ type: "text_delta", text: "too late" });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(failed).toBe(1);
|
||||||
|
expect(published).toEqual([
|
||||||
|
{ event: "info", data: { type: "info", level: "error", text: "public failure" } },
|
||||||
|
{ event: "system_event", data: { type: "system_event", event: "session_failed" } },
|
||||||
|
{ event: "system_event", data: { type: "system_event", event: "agent_end" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST resume tears down a created runtime when bridge binding fails", async () => {
|
||||||
|
const delivered: string[] = [];
|
||||||
|
const actualHub = new SseHub();
|
||||||
|
let clearCalls = 0;
|
||||||
|
let teardownCalls = 0;
|
||||||
|
let current: any = { bridge: { turnState: () => "failed" } };
|
||||||
|
const hub = {
|
||||||
|
subscribe: actualHub.subscribe.bind(actualHub),
|
||||||
|
publish: actualHub.publish.bind(actualHub),
|
||||||
|
clear: (id: string) => { clearCalls += 1; actualHub.clear(id); },
|
||||||
|
} as any;
|
||||||
|
hub.subscribe("s1", (_event: string, data: any) => delivered.push(data.text));
|
||||||
|
hub.publish("s1", "info", { text: "before" });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
mgr: {
|
||||||
|
get: () => current,
|
||||||
|
teardown: () => { teardownCalls += 1; current = undefined; },
|
||||||
|
createFor: () => {
|
||||||
|
current = {
|
||||||
|
bridge: {
|
||||||
|
onClientEvent: () => { throw new Error("BIND_SENTINEL"); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
hub,
|
||||||
|
thtRunner: {
|
||||||
|
sessionShow: async () => ({ status: "open", archived: false }),
|
||||||
|
reopenSession: async () => {},
|
||||||
|
} as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
getSettings: () => ({ workspace: "local" }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
|
||||||
|
hub.publish("s1", "info", { text: "post-failure probe" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.body).not.toContain("BIND_SENTINEL");
|
||||||
|
expect(teardownCalls).toBe(2);
|
||||||
|
expect(current).toBeUndefined();
|
||||||
|
expect(clearCalls).toBe(0);
|
||||||
|
expect(delivered).toEqual(["before", "post-failure probe"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: {
|
thtRunner: {
|
||||||
@@ -332,12 +691,31 @@ test("DELETE /sessions/:id tears down the runtime before deleting on disk", asyn
|
|||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: { deleteSession: async (id: string) => { order.push(`del:${id}`); } } as any,
|
thtRunner: { deleteSession: async (id: string) => { order.push(`del:${id}`); } } as any,
|
||||||
mgr: { teardown: (id: string) => { order.push(`teardown:${id}`); } } as any,
|
mgr: { teardown: (id: string) => { order.push(`teardown:${id}`); } } as any,
|
||||||
|
hub: { forget: (id: string) => { order.push(`forget:${id}`); } } as any,
|
||||||
getSettings: () => ({ workspace: "w" }) as any,
|
getSettings: () => ({ workspace: "w" }) as any,
|
||||||
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
||||||
});
|
});
|
||||||
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
|
||||||
expect(res.statusCode).toBe(204);
|
expect(res.statusCode).toBe(204);
|
||||||
expect(order).toEqual(["teardown:s1", "del:s1"]);
|
expect(order).toEqual(["teardown:s1", "del:s1", "forget:s1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /sessions/:id/close clears transient hub state without forgetting its id", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: { closeSession: async () => {} } as any,
|
||||||
|
mgr: { teardown: () => {} } as any,
|
||||||
|
hub: {
|
||||||
|
clear: (id: string) => { calls.push(`clear:${id}`); },
|
||||||
|
forget: (id: string) => { calls.push(`forget:${id}`); },
|
||||||
|
} as any,
|
||||||
|
getSettings: () => ({ workspace: "w" }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/s1/close" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(calls).toEqual(["clear:s1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("GET /sessions/:id/documents returns the runner output", async () => {
|
test("GET /sessions/:id/documents returns the runner output", async () => {
|
||||||
@@ -495,6 +873,7 @@ test("POST /sessions bootstrap failure emits only a fixed recovery message", asy
|
|||||||
"connect https://secret.invalid/bootstrap?token=DO_NOT_LEAK using /srv/private/model-key";
|
"connect https://secret.invalid/bootstrap?token=DO_NOT_LEAK using /srv/private/model-key";
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
mgr: {
|
mgr: {
|
||||||
|
get: () => undefined,
|
||||||
createFor: () => runtime,
|
createFor: () => runtime,
|
||||||
configure: async () => { throw new Error(rawFailure); },
|
configure: async () => { throw new Error(rawFailure); },
|
||||||
start: () => {},
|
start: () => {},
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ test("clear drops stale subscribers and buffers but preserves the per-session id
|
|||||||
expect(resumed).toEqual([{ event: "info", data: { text: "after" }, id: 2 }]);
|
expect(resumed).toEqual([{ event: "info", data: { text: "after" }, id: 2 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("forget drops subscribers, buffers, and the per-session id sequence", () => {
|
||||||
|
const hub = new SseHub();
|
||||||
|
const stale: any[] = [];
|
||||||
|
hub.subscribe("s1", (event, data, id) => stale.push({ event, data, id }));
|
||||||
|
expect(hub.publish("s1", "info", { text: "before" })).toBe(1);
|
||||||
|
|
||||||
|
hub.forget("s1");
|
||||||
|
expect(hub.publish("s1", "info", { text: "after" })).toBe(1);
|
||||||
|
const fresh: any[] = [];
|
||||||
|
hub.subscribe("s1", (event, data, id) => fresh.push({ event, data, id }));
|
||||||
|
|
||||||
|
expect(stale).toEqual([{ event: "info", data: { text: "before" }, id: 1 }]);
|
||||||
|
expect(fresh).toEqual([{ event: "info", data: { text: "after" }, id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
test("a buffered pending gate is emitted exactly once and matched by descriptor id", () => {
|
test("a buffered pending gate is emitted exactly once and matched by descriptor id", () => {
|
||||||
const hub = new SseHub();
|
const hub = new SseHub();
|
||||||
const descriptor = { id: "gate-1", widget: "select", title: "Original" };
|
const descriptor = { id: "gate-1", widget: "select", title: "Original" };
|
||||||
|
|||||||
@@ -134,6 +134,60 @@ test("an already-active same-session Resume preserves its EventSource and store"
|
|||||||
expect(useSessionStore.getState().activityLog).toEqual(before);
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a false then true pair of concurrent Resume completions preserves the cold stream", async () => {
|
||||||
|
let resumeCalls = 0;
|
||||||
|
let releaseCold!: () => void;
|
||||||
|
let releaseAlreadyActive!: () => void;
|
||||||
|
let markColdStarted!: () => void;
|
||||||
|
let markAlreadyActiveStarted!: () => void;
|
||||||
|
const coldStarted = new Promise<void>((resolve) => { markColdStarted = resolve; });
|
||||||
|
const alreadyActiveStarted = new Promise<void>((resolve) => { markAlreadyActiveStarted = resolve; });
|
||||||
|
const coldReleased = new Promise<void>((resolve) => { releaseCold = resolve; });
|
||||||
|
const alreadyActiveReleased = new Promise<void>((resolve) => { releaseAlreadyActive = resolve; });
|
||||||
|
server.use(
|
||||||
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
||||||
|
resumeCalls += 1;
|
||||||
|
if (resumeCalls === 1) {
|
||||||
|
markColdStarted();
|
||||||
|
await coldReleased;
|
||||||
|
return resumeResult("s1", false);
|
||||||
|
}
|
||||||
|
markAlreadyActiveStarted();
|
||||||
|
await alreadyActiveReleased;
|
||||||
|
return resumeResult("s1", true);
|
||||||
|
}),
|
||||||
|
http.get("http://localhost:8787/sessions/:id", () =>
|
||||||
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
||||||
|
);
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
|
const resumeButton = await screen.findByRole("button", { name: /resume/i });
|
||||||
|
|
||||||
|
await userEvent.click(resumeButton);
|
||||||
|
await coldStarted;
|
||||||
|
await userEvent.click(resumeButton);
|
||||||
|
await alreadyActiveStarted;
|
||||||
|
expect(resumeCalls).toBe(2);
|
||||||
|
|
||||||
|
releaseCold();
|
||||||
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
|
const stream = FakeEventSource.instances[0];
|
||||||
|
act(() => stream.emitNamed(
|
||||||
|
"ui_request",
|
||||||
|
{ type: "ui_request", ui_request: { id: "cold-gate", widget: "select" } },
|
||||||
|
"1",
|
||||||
|
));
|
||||||
|
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
||||||
|
|
||||||
|
releaseAlreadyActive();
|
||||||
|
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(FakeEventSource.instances).toHaveLength(1);
|
||||||
|
expect(stream.closed).toBe(false);
|
||||||
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("cold-gate");
|
||||||
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
||||||
@@ -146,7 +200,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
const first = FakeEventSource.instances[0];
|
const first = FakeEventSource.instances[0];
|
||||||
act(() => first.emitNamed("info", { type: "info", text: "Before cold Resume" }, "7"));
|
act(() => first.emitNamed("info", { type: "info", text: "Before cold Resume" }, "900"));
|
||||||
|
|
||||||
let releaseResume!: () => void;
|
let releaseResume!: () => void;
|
||||||
let markStarted!: () => void;
|
let markStarted!: () => void;
|
||||||
@@ -168,7 +222,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
expect(useSessionStore.getState().activityLog).toContainEqual({
|
expect(useSessionStore.getState().activityLog).toContainEqual({
|
||||||
kind: "status", phase: "F1", text: "Before cold Resume", level: "info",
|
kind: "status", phase: "F1", text: "Before cold Resume", level: "info",
|
||||||
});
|
});
|
||||||
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "Still attached" }, "8"));
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "Still attached" }, "901"));
|
||||||
expect(useSessionStore.getState().transcript).toEqual([
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
{ role: "assistant", text: "Still attached" },
|
{ role: "assistant", text: "Still attached" },
|
||||||
]);
|
]);
|
||||||
@@ -177,7 +231,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
const replacement = FakeEventSource.instances[1];
|
const replacement = FakeEventSource.instances[1];
|
||||||
expect(replacement.url).toContain("/sessions/s1/events?lastEventId=8");
|
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
||||||
expect(useSessionStore.getState().transcript).toEqual([]);
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||||
expect(useSessionStore.getState().activityLog).toEqual([
|
expect(useSessionStore.getState().activityLog).toEqual([
|
||||||
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
||||||
@@ -186,7 +240,7 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
act(() => replacement.emitNamed(
|
act(() => replacement.emitNamed(
|
||||||
"text_delta",
|
"text_delta",
|
||||||
{ type: "text_delta", text: "Post-resume delivery" },
|
{ type: "text_delta", text: "Post-resume delivery" },
|
||||||
"9",
|
"1",
|
||||||
));
|
));
|
||||||
expect(useSessionStore.getState().transcript).toEqual([
|
expect(useSessionStore.getState().transcript).toEqual([
|
||||||
{ role: "assistant", text: "Post-resume delivery" },
|
{ role: "assistant", text: "Post-resume delivery" },
|
||||||
@@ -199,8 +253,8 @@ test("cold same-session Resume keeps the old stream until success then receives
|
|||||||
ui_request: { id: "post-resume-gate", widget: "select", title: "Review once" },
|
ui_request: { id: "post-resume-gate", widget: "select", title: "Review once" },
|
||||||
};
|
};
|
||||||
act(() => {
|
act(() => {
|
||||||
replacement.emitNamed("ui_request", gate, "10");
|
replacement.emitNamed("ui_request", gate, "2");
|
||||||
replacement.emitNamed("ui_request", gate, "11");
|
replacement.emitNamed("ui_request", gate, "3");
|
||||||
});
|
});
|
||||||
expect(useSessionStore.getState().pendingWidget?.id).toBe("post-resume-gate");
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("post-resume-gate");
|
||||||
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "gate"))
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "gate"))
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
const [streamGeneration, setStreamGeneration] = useState(0);
|
const activeSessionIdRef = useRef<string | null>(null);
|
||||||
|
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
|
||||||
const [creatingSession, setCreatingSession] = useState(false);
|
const [creatingSession, setCreatingSession] = useState(false);
|
||||||
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
|
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
|
||||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||||
@@ -65,6 +66,12 @@ export function AppShell() {
|
|||||||
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
|
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
|
||||||
const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
||||||
|
|
||||||
|
function selectActiveSession(id: string | null) {
|
||||||
|
// Keep async Resume completions synchronized before React commits the state update.
|
||||||
|
activeSessionIdRef.current = id;
|
||||||
|
setActiveSessionId(id);
|
||||||
|
}
|
||||||
|
|
||||||
// A background refresh can remove a session (for example from another browser).
|
// A background refresh can remove a session (for example from another browser).
|
||||||
// Keep the local selection aligned with the authoritative list.
|
// Keep the local selection aligned with the authoritative list.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,9 +107,9 @@ export function AppShell() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function doResume(id: string) {
|
async function doResume(id: string) {
|
||||||
const reconnectSameSession = activeSessionId === id;
|
|
||||||
try {
|
try {
|
||||||
const result = await resumeSession(id);
|
const result = await resumeSession(id);
|
||||||
|
const reconnectSameSession = activeSessionIdRef.current === id;
|
||||||
setPanelSession(null);
|
setPanelSession(null);
|
||||||
setAwaitingQuestion(false);
|
setAwaitingQuestion(false);
|
||||||
|
|
||||||
@@ -115,11 +122,11 @@ export function AppShell() {
|
|||||||
recordLifecycle("Resuming session");
|
recordLifecycle("Resuming session");
|
||||||
setAgentActive(true);
|
setAgentActive(true);
|
||||||
}
|
}
|
||||||
setActiveSessionId(id);
|
selectActiveSession(id);
|
||||||
// The backend has now completed clear/rebind. Recreate a same-id source with its cursor;
|
// A cold runtime starts a fresh SSE id sequence. Recreate a same-id source only after
|
||||||
// switching ids naturally creates a new source and resets the cursor in the stream hook.
|
// Resume succeeds, and explicitly discard the old runtime's cursor.
|
||||||
if (!result.alreadyActive && reconnectSameSession) {
|
if (!result.alreadyActive && reconnectSameSession) {
|
||||||
setStreamGeneration((value) => value + 1);
|
setStreamCursorResetEpoch((value) => value + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paint the persisted re-entry phase while the replacement stream starts replaying.
|
// Paint the persisted re-entry phase while the replacement stream starts replaying.
|
||||||
@@ -182,7 +189,7 @@ export function AppShell() {
|
|||||||
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
|
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
|
||||||
);
|
);
|
||||||
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
|
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
|
||||||
if (deletedIds.has(activeSessionId ?? "")) { resetSession(); setActiveSessionId(null); }
|
if (deletedIds.has(activeSessionId ?? "")) { resetSession(); selectActiveSession(null); }
|
||||||
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
|
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
|
||||||
refresh();
|
refresh();
|
||||||
if (deletedIds.size !== targets.length) {
|
if (deletedIds.size !== targets.length) {
|
||||||
@@ -226,7 +233,7 @@ export function AppShell() {
|
|||||||
// session sits idle or a gate awaits the reviewer (pendingWidget).
|
// session sits idle or a gate awaits the reviewer (pendingWidget).
|
||||||
const running = working && !finalized;
|
const running = working && !finalized;
|
||||||
|
|
||||||
useSessionStream(activeSessionId, streamGeneration);
|
useSessionStream(activeSessionId, 0, streamCursorResetEpoch);
|
||||||
|
|
||||||
// A backend "session_exit" system event (e.g. the replay server emitting it
|
// A backend "session_exit" system event (e.g. the replay server emitting it
|
||||||
// when the reviewer picks "Esci") asks us to leave the live session view and
|
// when the reviewer picks "Esci") asks us to leave the live session view and
|
||||||
@@ -240,7 +247,7 @@ export function AppShell() {
|
|||||||
// Never let a streamed event terminate the managed Pi child. Only the
|
// Never let a streamed event terminate the managed Pi child. Only the
|
||||||
// explicit “Stop & save” action is allowed to call /close.
|
// explicit “Stop & save” action is allowed to call /close.
|
||||||
resetSession();
|
resetSession();
|
||||||
setActiveSessionId(null);
|
selectActiveSession(null);
|
||||||
setAwaitingQuestion(false);
|
setAwaitingQuestion(false);
|
||||||
}
|
}
|
||||||
// The final workflow turn ends with the session already finalized on disk:
|
// The final workflow turn ends with the session already finalized on disk:
|
||||||
@@ -254,7 +261,7 @@ export function AppShell() {
|
|||||||
resetSession();
|
resetSession();
|
||||||
setAwaitingQuestion(true);
|
setAwaitingQuestion(true);
|
||||||
setCreatingSession(false);
|
setCreatingSession(false);
|
||||||
setActiveSessionId(null);
|
selectActiveSession(null);
|
||||||
// Best effort only: session creation keeps the authoritative readiness gate.
|
// Best effort only: session creation keeps the authoritative readiness gate.
|
||||||
// Composer focus is deliberately independent of this network request.
|
// Composer focus is deliberately independent of this network request.
|
||||||
void prewarmRuntime().catch(() => undefined);
|
void prewarmRuntime().catch(() => undefined);
|
||||||
@@ -269,7 +276,7 @@ export function AppShell() {
|
|||||||
function finishSessionCreation(id: string) {
|
function finishSessionCreation(id: string) {
|
||||||
// React batches these updates, preserving the provisional view and timer
|
// React batches these updates, preserving the provisional view and timer
|
||||||
// while useSessionStream opens the durable session's SSE channel.
|
// while useSessionStream opens the durable session's SSE channel.
|
||||||
setActiveSessionId(id);
|
selectActiveSession(id);
|
||||||
setCreatingSession(false);
|
setCreatingSession(false);
|
||||||
setAwaitingQuestion(false);
|
setAwaitingQuestion(false);
|
||||||
refresh();
|
refresh();
|
||||||
@@ -287,7 +294,7 @@ export function AppShell() {
|
|||||||
await closeSession(activeSessionId);
|
await closeSession(activeSessionId);
|
||||||
} finally {
|
} finally {
|
||||||
resetSession();
|
resetSession();
|
||||||
setActiveSessionId(null);
|
selectActiveSession(null);
|
||||||
setAwaitingQuestion(false);
|
setAwaitingQuestion(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
import { act } from "react";
|
import { act, useLayoutEffect } from "react";
|
||||||
import { FakeEventSource } from "../test/fakeEventSource";
|
import { FakeEventSource } from "../test/fakeEventSource";
|
||||||
import { useSessionStream } from "./useSessionStream";
|
import { useSessionStream } from "./useSessionStream";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
@@ -100,6 +100,58 @@ test("a same-session generation reconnect includes the last consumed SSE id", ()
|
|||||||
.toEqual([{ kind: "assistant", phase: null, text: "hello world" }]);
|
.toEqual([{ kind: "assistant", phase: null, text: "hello world" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a same-session reset epoch drops a high cursor and accepts fresh low-id events", () => {
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ resetEpoch }) => useSessionStream("s1", 0, resetEpoch),
|
||||||
|
{ initialProps: { resetEpoch: 0 } },
|
||||||
|
);
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "old runtime" }, "900"));
|
||||||
|
|
||||||
|
rerender({ resetEpoch: 1 });
|
||||||
|
|
||||||
|
expect(first.closed).toBe(true);
|
||||||
|
expect(FakeEventSource.instances).toHaveLength(2);
|
||||||
|
expect(FakeEventSource.instances[1].url).toBe(
|
||||||
|
"http://localhost:8787/sessions/s1/events",
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
FakeEventSource.instances[1].emitNamed(
|
||||||
|
"ui_request",
|
||||||
|
{ type: "ui_request", ui_request: { id: "fresh-gate", widget: "select" } },
|
||||||
|
"1",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("fresh-gate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reset epoch also accepts an in-process preserved high id and resumes from it", () => {
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ generation, resetEpoch }) => useSessionStream("s1", generation, resetEpoch),
|
||||||
|
{ initialProps: { generation: 0, resetEpoch: 0 } },
|
||||||
|
);
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "old runtime" }, "900"));
|
||||||
|
|
||||||
|
rerender({ generation: 0, resetEpoch: 1 });
|
||||||
|
const replacement = FakeEventSource.instances[1];
|
||||||
|
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
||||||
|
act(() =>
|
||||||
|
replacement.emitNamed(
|
||||||
|
"ui_request",
|
||||||
|
{ type: "ui_request", ui_request: { id: "preserved-high-gate", widget: "select" } },
|
||||||
|
"902",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("preserved-high-gate");
|
||||||
|
rerender({ generation: 1, resetEpoch: 1 });
|
||||||
|
expect(FakeEventSource.instances[2].url).toBe(
|
||||||
|
"http://localhost:8787/sessions/s1/events?lastEventId=902",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("changing the session resets the manual reconnect cursor", () => {
|
test("changing the session resets the manual reconnect cursor", () => {
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ sessionId }) => useSessionStream(sessionId),
|
({ sessionId }) => useSessionStream(sessionId),
|
||||||
@@ -113,3 +165,55 @@ test("changing the session resets the manual reconnect cursor", () => {
|
|||||||
expect(first.closed).toBe(true);
|
expect(first.closed).toBe(true);
|
||||||
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events");
|
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a queued event from a replaced source cannot mutate the new session or its cursor", () => {
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ sessionId, generation }) => useSessionStream(sessionId, generation, 0),
|
||||||
|
{ initialProps: { sessionId: "s1" as string | null, generation: 0 } },
|
||||||
|
);
|
||||||
|
const first = FakeEventSource.instances[0];
|
||||||
|
act(() => first.emitNamed("info", { type: "info", text: "session one" }, "12"));
|
||||||
|
|
||||||
|
rerender({ sessionId: "s2", generation: 0 });
|
||||||
|
expect(first.closed).toBe(true);
|
||||||
|
expect(FakeEventSource.instances[1].url).toBe("http://localhost:8787/sessions/s2/events");
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
|
|
||||||
|
act(() =>
|
||||||
|
first.emitQueuedNamed(
|
||||||
|
"text_delta",
|
||||||
|
{ type: "text_delta", text: "stale session one" },
|
||||||
|
"999",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||||
|
|
||||||
|
rerender({ sessionId: "s2", generation: 1 });
|
||||||
|
expect(FakeEventSource.instances[2].url).toBe("http://localhost:8787/sessions/s2/events");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the old source is invalid before later layout effects can deliver a queued event", () => {
|
||||||
|
let first: FakeEventSource;
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ sessionId, emitDuringLayout }) => {
|
||||||
|
useSessionStream(sessionId);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (emitDuringLayout) {
|
||||||
|
first.emitQueuedNamed(
|
||||||
|
"text_delta",
|
||||||
|
{ type: "text_delta", text: "commit-window stale text" },
|
||||||
|
"777",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [emitDuringLayout, sessionId]);
|
||||||
|
},
|
||||||
|
{ initialProps: { sessionId: "s1" as string | null, emitDuringLayout: false } },
|
||||||
|
);
|
||||||
|
first = FakeEventSource.instances[0];
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
|
|
||||||
|
rerender({ sessionId: "s2", emitDuringLayout: true });
|
||||||
|
|
||||||
|
expect(first.closed).toBe(true);
|
||||||
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { BASE } from "../api/client";
|
import { BASE } from "../api/client";
|
||||||
import { joinBackendPath } from "../api/runtime-config";
|
import { joinBackendPath } from "../api/runtime-config";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import type { StreamEvent } from "../api/types";
|
import type { StreamEvent } from "../api/types";
|
||||||
|
|
||||||
export function useSessionStream(sessionId: string | null, generation = 0) {
|
export function useSessionStream(
|
||||||
|
sessionId: string | null,
|
||||||
|
generation = 0,
|
||||||
|
cursorResetEpoch = 0,
|
||||||
|
) {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const applyEvent = useSessionStore((s) => s.applyEvent);
|
const applyEvent = useSessionStore((s) => s.applyEvent);
|
||||||
const cursor = useRef({ sessionId: null as string | null, lastEventId: "" });
|
const cursor = useRef({
|
||||||
|
sessionId: null as string | null,
|
||||||
|
cursorResetEpoch,
|
||||||
|
lastEventId: "",
|
||||||
|
});
|
||||||
|
const activeSource = useRef<object | null>(null);
|
||||||
|
|
||||||
if (cursor.current.sessionId !== sessionId) {
|
useLayoutEffect(() => {
|
||||||
cursor.current = { sessionId, lastEventId: "" };
|
if (
|
||||||
}
|
cursor.current.sessionId !== sessionId
|
||||||
|
|| cursor.current.cursorResetEpoch !== cursorResetEpoch
|
||||||
useEffect(() => {
|
) {
|
||||||
if (!sessionId) return;
|
cursor.current = { sessionId, cursorResetEpoch, lastEventId: "" };
|
||||||
|
}
|
||||||
|
if (!sessionId) {
|
||||||
|
activeSource.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const query = cursor.current.lastEventId
|
const query = cursor.current.lastEventId
|
||||||
? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}`
|
? `?lastEventId=${encodeURIComponent(cursor.current.lastEventId)}`
|
||||||
: "";
|
: "";
|
||||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||||
es.onopen = () => setConnected(true);
|
const identity = { source: es, sessionId, cursorResetEpoch };
|
||||||
es.onerror = () => setConnected(false);
|
activeSource.current = identity;
|
||||||
|
es.onopen = () => {
|
||||||
|
if (activeSource.current === identity) setConnected(true);
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
if (activeSource.current === identity) setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handle = (ev: MessageEvent) => {
|
const handle = (ev: MessageEvent) => {
|
||||||
|
if (activeSource.current !== identity) return;
|
||||||
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
if (ev.lastEventId) cursor.current.lastEventId = ev.lastEventId;
|
||||||
try {
|
try {
|
||||||
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
applyEvent(JSON.parse(ev.data) as StreamEvent);
|
||||||
@@ -47,10 +69,14 @@ export function useSessionStream(sessionId: string | null, generation = 0) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const name of namedEvents) es.removeEventListener(name, handle);
|
for (const name of namedEvents) es.removeEventListener(name, handle);
|
||||||
|
es.onmessage = null;
|
||||||
es.close();
|
es.close();
|
||||||
setConnected(false);
|
if (activeSource.current === identity) {
|
||||||
|
activeSource.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [sessionId, generation, applyEvent]);
|
}, [sessionId, generation, cursorResetEpoch, applyEvent]);
|
||||||
|
|
||||||
return { connected };
|
return { connected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export class FakeEventSource {
|
|||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
closed = false;
|
closed = false;
|
||||||
private listeners = new Map<string, Set<(e: { data: string; lastEventId: string }) => void>>();
|
private listeners = new Map<string, Set<(e: { data: string; lastEventId: string }) => void>>();
|
||||||
|
private historicalListeners = new Map<
|
||||||
|
string,
|
||||||
|
Set<(e: { data: string; lastEventId: string }) => void>
|
||||||
|
>();
|
||||||
constructor(public url: string) {
|
constructor(public url: string) {
|
||||||
FakeEventSource.instances.push(this);
|
FakeEventSource.instances.push(this);
|
||||||
}
|
}
|
||||||
@@ -17,9 +21,16 @@ export class FakeEventSource {
|
|||||||
const data = JSON.stringify(obj);
|
const data = JSON.stringify(obj);
|
||||||
for (const h of this.listeners.get(type) ?? []) h({ data, lastEventId });
|
for (const h of this.listeners.get(type) ?? []) h({ data, lastEventId });
|
||||||
}
|
}
|
||||||
|
/** Simulate an event already queued before removeEventListener/close completed. */
|
||||||
|
emitQueuedNamed(type: string, obj: unknown, lastEventId = "") {
|
||||||
|
const data = JSON.stringify(obj);
|
||||||
|
for (const h of this.historicalListeners.get(type) ?? []) h({ data, lastEventId });
|
||||||
|
}
|
||||||
addEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
addEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||||
|
if (!this.historicalListeners.has(type)) this.historicalListeners.set(type, new Set());
|
||||||
this.listeners.get(type)!.add(handler);
|
this.listeners.get(type)!.add(handler);
|
||||||
|
this.historicalListeners.get(type)!.add(handler);
|
||||||
}
|
}
|
||||||
removeEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
removeEventListener(type: string, handler: (e: { data: string; lastEventId: string }) => void) {
|
||||||
this.listeners.get(type)?.delete(handler);
|
this.listeners.get(type)?.delete(handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user