feat: harden runtime readiness and session workflow

This commit is contained in:
User
2026-07-14 10:27:25 +02:00
parent 6d7738d538
commit 6dbf93fff9
52 changed files with 1464 additions and 169 deletions
+1
View File
@@ -32,6 +32,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
searchPack: async () => {},
sessionNew: async () => ({ id: "s1" }),
sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }),
sessionList: async () => [],
+1
View File
@@ -38,6 +38,7 @@ test("SSE response headers are flushed before the first event", async () => {
]);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-cache");
expect(response.headers.get("x-accel-buffering")).toBe("no");
} finally {
controller.abort();
await app.close();
+27 -10
View File
@@ -36,27 +36,30 @@ test("l'exit del child rimuove il runtime dalla mappa (exit handler)", async ()
expect(mgr.get("exit-test")).toBeUndefined();
});
test("spawnFor sullo STESSO id uccide il vecchio child e sostituisce il runtime (count resta 1)", async () => {
test("spawnFor sullo STESSO id rifiuta il duplicato senza interrompere il runtime attivo", async () => {
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
const first = await mgr.spawnFor("dup-id", {});
expect(mgr.count()).toBe(1);
const firstExited = new Promise<void>((res) => first.child.on("exit", () => res()));
const second = await mgr.spawnFor("dup-id", {});
await firstExited; // the old child was killed by the idempotent respawn
await expect(mgr.spawnFor("dup-id", {})).rejects.toThrow(
"session runtime already active: dup-id",
);
expect(mgr.count()).toBe(1);
expect(mgr.get("dup-id")).toBe(second);
expect(second).not.toBe(first);
expect(mgr.get("dup-id")).toBe(first);
mgr.teardown("dup-id");
});
test("l'exit del VECCHIO child non elimina il nuovo runtime (exit identity-checked)", async () => {
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
const first = await mgr.spawnFor("respawn-id", {});
const second = await mgr.spawnFor("respawn-id", {});
const firstChild = recordingChild();
const secondChild = recordingChild();
const children = [firstChild, secondChild];
const mgr = new PiProcessManager(cfg, { spawnFn: () => children.shift() as any });
const first = mgr.createFor("respawn-id", {});
mgr.teardown("respawn-id");
const second = mgr.createFor("respawn-id", {});
// The old child's exit handler fires after the respawn; it must NOT evict `second`.
await new Promise((res) => setImmediate(res));
firstChild.emit("exit", 0);
expect(mgr.get("respawn-id")).toBe(second);
expect(mgr.count()).toBe(1);
void first;
@@ -91,6 +94,7 @@ test("un exit INATTESO del child notifica il client (info error + agent_end)", a
child.emit("exit", 137);
expect(seen).toEqual([
{ type: "info", level: "error", text: expect.stringContaining("137") },
{ type: "system_event", event: "session_failed" },
{ type: "system_event", event: "agent_end" },
]);
expect(mgr.count()).toBe(0);
@@ -138,6 +142,19 @@ test("spawnFor new mode forwards the real question instead of kickoff", async ()
mgr.teardown("sid-question");
});
test("createFor does not prompt until start is called", async () => {
const cfg = loadConfig({});
const child = recordingChild();
const mgr = new PiProcessManager(cfg, { spawnFn: () => child as any });
const rt = mgr.createFor("sid-deferred", { question: "q" });
expect(child._writes).toEqual([]);
await mgr.configure(rt, {});
expect(child._writes).toEqual([]);
mgr.start("sid-deferred", rt, { question: "q" });
expect(JSON.parse(child._writes.at(-1)!).message).toBe('/nuova-domanda "q"');
mgr.teardown("sid-deferred");
});
test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => {
vi.stubEnv("PATH", "/usr/local/bin:/usr/bin");
vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret");
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "vitest";
import { ReadinessManager } from "../src/runtime/readiness-manager.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
test("readiness is single-flight per workspace and caches a short-lived success", async () => {
let now = 1_000;
let calls = 0;
const first = deferred<any>();
const tht = {
ollamaEnsure: async () => {
calls += 1;
return first.promise;
},
} as any;
const readiness = new ReadinessManager(tht, 60, 500, () => now);
const a = readiness.ensure("psd");
const b = readiness.ensure("psd");
expect(calls).toBe(1);
first.resolve({ ok: true, model_name: "embed" });
await expect(a).resolves.toMatchObject({ ok: true });
await expect(b).resolves.toMatchObject({ ok: true });
await readiness.ensure("psd");
expect(calls).toBe(1);
now += 501;
tht.ollamaEnsure = async () => { calls += 1; return { ok: true }; };
await readiness.ensure("psd");
expect(calls).toBe(2);
});
test("readiness keys workspaces independently", async () => {
const seen: string[] = [];
const tht = {
ollamaEnsure: async (workspace: string) => {
seen.push(workspace);
return { ok: true };
},
} as any;
const readiness = new ReadinessManager(tht, 60);
await Promise.all([readiness.ensure("a"), readiness.ensure("b")]);
expect(seen.sort()).toEqual(["a", "b"]);
});
test("readiness does not cache failed results", async () => {
let calls = 0;
const tht = {
ollamaEnsure: async () => {
calls += 1;
return calls === 1 ? { ok: false, error: "down" } : { ok: true };
},
} as any;
const readiness = new ReadinessManager(tht, 60);
await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: false });
await expect(readiness.ensure("psd")).resolves.toMatchObject({ ok: true });
expect(calls).toBe(2);
});
+60
View File
@@ -28,6 +28,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
}), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
searchPack: async () => {},
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
sessionList: async () => [{ id: "s1" }],
} as any,
@@ -50,6 +51,7 @@ test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
searchPack: async () => {},
sessionNew: async () => ({ id: "s1" }),
sessionList: async () => [],
} as any,
@@ -134,6 +136,7 @@ test("POST /sessions refuses with 503 when ollamaEnsure fails (no session create
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
ollamaEnsure: async () => ({ ok: false, stage: "model", error: "modello non installato" }),
searchPack: async () => {},
sessionNew: async () => { createdCalled = true; return { id: "s1" }; },
} as any,
getSettings: () => ({ workspace: "psd" }) as any,
@@ -150,6 +153,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
ollamaEnsure: async (ws: string) => { ensureWs = ws; return { ok: true }; },
searchPack: async () => {},
sessionNew: async () => ({ id: "s1" }),
} as any,
getSettings: () => ({ workspace: "psd" }) as any,
@@ -183,3 +187,59 @@ test("POST /sessions/:id/resume refuses with 503 when ollamaEnsure fails", async
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
expect(res.statusCode).toBe(503);
});
test("POST /runtime/prewarm returns 202 without awaiting readiness", async () => {
let workspace: string | undefined;
let finish!: (value: any) => void;
const pending = new Promise((resolve) => { finish = resolve; });
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {} as any,
readiness: {
ensure: (ws: string) => { workspace = ws; return pending; },
} as any,
getSettings: () => ({ workspace: "psd" }) as any,
});
const res = await app.inject({ method: "POST", url: "/runtime/prewarm" });
expect(res.statusCode).toBe(202);
expect(res.json()).toEqual({ status: "warming" });
expect(workspace).toBe("psd");
finish({ ok: true });
});
test("POST /sessions returns after bridge attachment but starts only after retrieval", async () => {
let finishPack!: () => void;
const pack = new Promise<void>((resolve) => { finishPack = resolve; });
let bridgeAttached = false;
let started = false;
const bridge = {
onClientEvent: () => { bridgeAttached = true; },
emitClientEvent: () => {},
};
const runtime = { bridge } as any;
const mgr = {
createFor: () => runtime,
configure: async () => {},
start: () => {
expect(bridgeAttached).toBe(true);
started = true;
},
teardown: () => {},
} as any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
mgr,
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
sessionNew: async () => ({ id: "s-early" }),
searchPack: async () => pack,
} as any,
getSettings: () => ({ workspace: "psd" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(res.json()).toEqual({ id: "s-early" });
expect(bridgeAttached).toBe(true);
expect(started).toBe(false);
finishPack();
await new Promise((resolve) => setImmediate(resolve));
expect(started).toBe(true);
});
+11
View File
@@ -24,6 +24,17 @@ test("sessionNew parses id from JSON", async () => {
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});
test("searchPack persists retrieval context with session and workspace", async () => {
const calls: any[] = [];
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async (args, workspace) => {
calls.push({ args, workspace });
return { code: 0, stdout: "", stderr: "" };
};
await r.searchPack("domanda", "sid", "psd");
expect(calls).toEqual([{ args: ["search", "pack", "domanda", "--session", "sid"], workspace: "psd" }]);
});
test("run passes configured THT_DATA_ROOT and preserves the remaining environment", async () => {
const previousDataRoot = process.env.THT_DATA_ROOT;
const previousCa = process.env.NODE_EXTRA_CA_CERTS;