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:
@@ -1349,7 +1349,7 @@ test("POST resume tears down a created runtime when bridge binding fails", async
|
||||
expect(delivered).toEqual(["before", "post-failure probe"]);
|
||||
});
|
||||
|
||||
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||||
test("POST /sessions/:id/response senza gate pendente risponde 409 (risposta stantia)", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
@@ -1361,9 +1361,11 @@ test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
|
||||
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
|
||||
});
|
||||
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||
// The fake Pi never emitted a ui_request: the bridge has no pending descriptor, so a
|
||||
// response (stale UI, double submit) must be rejected instead of forwarded to Pi.
|
||||
const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
|
||||
payload: { ui_response: { id: "u1", choices: ["a"] } } });
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
test("POST /sessions/:id/rename calls setName", async () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -114,16 +114,14 @@ test("run with exit != 0 propagates error with stderr", async () => {
|
||||
await expect(r.sessionList()).rejects.toThrow(/boom/);
|
||||
});
|
||||
|
||||
test("sessionNew with missing workspace file falls back to default configPath argv", async () => {
|
||||
// harnessDir "/nope" has no workspaces/foo.yaml -> configArg falls back to default.
|
||||
test("sessionNew with a missing workspace file fails loud (no silent default fallback)", async () => {
|
||||
// harnessDir "/nope" has no workspaces/foo.yaml. Silently falling back to the default
|
||||
// config would target the WRONG workspace (wrong DB, wrong sessions dir): must throw.
|
||||
(spawn as any).mockClear();
|
||||
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" });
|
||||
await r.sessionNew({ question: "q", workspace: "foo" });
|
||||
const [bin, argv] = (spawn as any).mock.calls[0];
|
||||
expect(bin).toBe("tht");
|
||||
// `--config`/`-c` is a PER-COMMAND option in tht (no global -c): it MUST follow
|
||||
// the subcommand, never precede it. (Prepending it caused a live 500 "No such option: -c".)
|
||||
expect(argv).toEqual(["session", "new", "q", "--json", "-c", "config/tht.yaml"]);
|
||||
await expect(r.sessionNew({ question: "q", workspace: "foo" }))
|
||||
.rejects.toThrow(/workspace non trovato: workspaces\/foo\.yaml/);
|
||||
expect((spawn as any).mock.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("buildArgv appends -c AFTER the subcommand (never a global -c)", () => {
|
||||
|
||||
Reference in New Issue
Block a user