feat: harden runtime readiness and session workflow
This commit is contained in:
@@ -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 () => [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user