fix(backend): robustness pass — spawn leak, timeouts, workspace fail-loud, 409 order, respond guard

Audit findings 4.1-4.6.

- spawnFor: a rejected configure/start no longer leaks a registered runtime
  with a live Pi child (identity-checked teardown + rethrow); every later
  start used to hit "session runtime already active".
- ThtRunner.run: default 60s timeout on every tht child (SIGKILL backstop),
  120s for DWH-touching calls (sql preview/export, search pack); a dropped
  VPN mid-call no longer wedges the HTTP request forever.
- configArg: a NAMED workspace whose yaml is missing now throws instead of
  silently falling back to the default config (operations were silently
  targeting the wrong workspace).
- resume: the finalized/archived 409 is evaluated BEFORE the alreadyActive
  fast-path — the manifest is the truth even with a lingering runtime.
- ollamaEnsure: exit-0 with non-JSON stdout is a failed check, not ok:true.
- SessionBridge.respond: only the response matching the pending descriptor
  is forwarded to Pi; stale/duplicate submissions return 409 instead of
  being sent with the current gate's RPC id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 01:37:20 +02:00
co-authored by Claude Fable 5
parent 2958b32fd5
commit f772ef9dca
7 changed files with 92 additions and 34 deletions
+20 -1
View File
@@ -146,13 +146,32 @@ test("respond correla sull'id RPC di Pi (non sull'id del descriptor) e azzera il
// Pi emette la richiesta con il SUO id RPC ("pi-req-1"); il descriptor nel title ha id "u1".
fire({ type: "extension_ui_request", id: "pi-req-1", method: "input", title: JSON.stringify({ id: "u1", widget: "select" }) });
// Il frontend rimanda l'id del descriptor ("u1").
b.respond({ id: "u1", choices: ["a"] });
expect(b.respond({ id: "u1", choices: ["a"] })).toBe(true);
// Pi correla la risposta sul SUO id ("pi-req-1") per risolvere ctx.ui.input; il value
// continua a portare l'id del descriptor, cosi' il check interno del gate regge.
expect(sent.at(-1)).toEqual({ type: "extension_ui_response", id: "pi-req-1", value: JSON.stringify({ id: "u1", choices: ["a"] }) });
expect(b.pendingWidget()).toBeNull();
});
test("respond rifiuta risposte senza gate pendente o con id non corrispondente", () => {
const { rpc, sent, fire } = fakeRpc();
const b = new SessionBridge(rpc);
// Nessun gate pendente: la risposta non parte e lo stato non cambia.
expect(b.respond({ id: "u0", choices: ["a"] })).toBe(false);
expect(sent).toEqual([]);
fire({ type: "extension_ui_request", id: "pi-req-1", method: "input", title: JSON.stringify({ id: "u1", widget: "select" }) });
// Risposta stantia per un ALTRO gate: rifiutata, il gate vero resta pendente in waiting.
expect(b.respond({ id: "u0", choices: ["a"] })).toBe(false);
expect(sent).toEqual([]);
expect(b.turnState()).toBe("waiting");
expect(b.pendingWidget()).toEqual({ id: "u1", widget: "select" });
// Doppio submit: il primo passa, il secondo (pendente ormai nullo) viene rifiutato.
expect(b.respond({ id: "u1", choices: ["a"] })).toBe(true);
expect(b.respond({ id: "u1", choices: ["a"] })).toBe(false);
expect(sent).toHaveLength(1);
});
test("agent_end di Pi diventa un system_event agent_end per il FE", () => {
const { rpc, fire } = fakeRpc();
const b = new SessionBridge(rpc);