fix: finalize session after memory promotion

This commit is contained in:
2026-07-12 14:26:48 +02:00
parent e02e61ea35
commit 08f0029793
6 changed files with 232 additions and 39 deletions
@@ -0,0 +1,87 @@
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createFakePi } = require("./fake_pi_runtime.js");
// F8 auto-close: reviewer_memory_promote is the last human interaction of the
// workflow — after recording the promotion the gate must advance F8 and finalize
// the session itself (no trailing reviewer_confirm "approve" form). These tests
// drive the real tool execute against a fake `tht` CLI placed on PATH.
// tht-gate.js calls a bare `require` (Pi's runtime provides it); under node --test
// the ESM import has none, so expose the CJS require globally for the gate module.
globalThis.require = require;
const installGatePromise = import("../../tht-gate.js").then((m) => m.default);
function setupFakeTht(t, { phase, status = "open" }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tht-"));
const log = path.join(dir, "calls.log");
fs.writeFileSync(log, "");
const script = `#!/bin/bash
echo "$@" >> "${log}"
case "$1 $2" in
"phase show") echo "Fase corrente: ${phase}";;
"phase meta") echo '{"max_phase":8,"phases":[{"num":2,"id":"F2","emits":[]},{"num":8,"id":"F8","emits":[]}]}';;
"memory promote") echo "[]";;
"session show") echo '{"status":"${status}"}';;
*) echo "OK";;
esac
`;
const bin = path.join(dir, "tht");
fs.writeFileSync(bin, script, { mode: 0o755 });
const oldPath = process.env.PATH;
process.env.PATH = `${dir}:${oldPath}`;
t.after(() => {
process.env.PATH = oldPath;
fs.rmSync(dir, { recursive: true, force: true });
});
return { dir, calls: () => fs.readFileSync(log, "utf8") };
}
async function runPromote(t, { phase }) {
const fake = setupFakeTht(t, { phase });
const installGate = await installGatePromise;
const { pi, ctx, tools } = createFakePi();
ctx.cwd = fake.dir; // execFileSync needs an existing cwd; no .env here is fine
installGate(pi);
const { def } = tools.get("reviewer_memory_promote");
const res = await def.execute("t1", { session: "s1" }, null, null, ctx);
return { res, calls: fake.calls() };
}
test("F8 + zero candidati: il gate avanza la fase e finalizza da solo", async (t) => {
const { res, calls } = await runPromote(t, { phase: 8 });
const text = res.content[0].text;
assert.match(text, /sessione finalizzata \(s1\)/);
assert.match(text, /NON presentare altri gate/);
assert.match(calls, /^phase advance --session s1$/m);
assert.match(calls, /^session finalize s1$/m);
});
test("fuori dall'ultima fase non chiude nulla (comportamento precedente)", async (t) => {
const { res, calls } = await runPromote(t, { phase: 2 });
assert.match(res.content[0].text, /prosegui con la chiusura della sessione/);
assert.doesNotMatch(calls, /phase advance/);
assert.doesNotMatch(calls, /session finalize/);
});
test("phase-gate su sessione gia' finalizzata: nessun widget, il modello viene fermato", async (t) => {
setupFakeTht(t, { phase: 8, status: "finalized" });
const installGate = await installGatePromise;
const { pi, ctx, tools } = createFakePi();
ctx.cwd = os.tmpdir();
installGate(pi);
const { def } = tools.get("reviewer_confirm");
const res = await def.execute(
"t2",
{ session: "s1", kind: "phase", title: "F8", artifact: { kind: "phase", data: {} } },
null,
null,
ctx,
);
assert.match(res.content[0].text, /gia' finalizzata/);
assert.equal(ctx.uiCalls.length, 0); // la form "approve" NON viene ripresentata
});
+75 -25
View File
@@ -832,6 +832,24 @@ export default function (pi) {
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
// Sessione gia' finalizzata: non c'e' piu' nulla da approvare — mai
// ripresentare il phase-gate (la form "approve") a workflow chiuso.
if (kind === "phase" && curNum >= phaseMeta(ctx).max_phase) {
try {
const show = JSON.parse(tht(ctx, ["session", "show", session, "--json"]));
if (show?.status === "finalized") {
lockActive = false;
return textResult(
`La sessione ${session} e' gia' finalizzata: il workflow e' completo. ` +
"Comunica al reviewer il riepilogo finale e termina il turno; NON " +
"presentare altri gate reviewer_*.",
);
}
} catch {
// show non leggibile: prosegui col gate normale
}
}
// --- v2 payload construction (WS2) -------------------------------------
// The gate builds/validates the structured payload BEFORE showing the
// widget, so the reviewer approves a deterministic artifact (not the raw
@@ -948,26 +966,9 @@ export default function (pi) {
return textResult("Il reviewer vuole uscire.");
// outcome.kind === "approve" -> execute the privileged action via the CLI.
if (kind === "phase") {
// Explicit human approval: advance unconditionally except for unmet
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
// and exits 6 with the missing items, which relayIfThtFails surfaces.
const err = relayIfThtFails(
ctx,
["phase", "advance", "--session", session],
PHASE_RECOVERY,
);
if (err) return err;
// Auto-finalize after advancing the LAST phase: the model may not
// follow through (token budget, turn end) leaving the session open.
const meta = phaseMeta(ctx);
if (curNum >= meta.max_phase) {
const fErr = relayIfThtFails(
ctx,
["session", "finalize", session],
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
`\`tht session finalize ${session}\`.`,
);
if (fErr) return fErr;
const r = advancePhaseAndFinalize(ctx, session, curNum);
if (r.err) return r.err;
if (r.finalized) {
lockActive = false;
lastSteered = false;
return textResult(
@@ -1053,6 +1054,46 @@ export default function (pi) {
},
});
// Explicit human approval: advance unconditionally except for unmet
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
// and exits 6 with the missing items, which relayIfThtFails surfaces.
// Auto-finalize after advancing the LAST phase: the model may not follow
// through (token budget, turn end) leaving the session open.
function advancePhaseAndFinalize(ctx, session, curNum) {
const err = relayIfThtFails(
ctx,
["phase", "advance", "--session", session],
PHASE_RECOVERY,
);
if (err) return { err };
if (curNum < phaseMeta(ctx).max_phase) return { finalized: false };
const fErr = relayIfThtFails(
ctx,
["session", "finalize", session],
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
`\`tht session finalize ${session}\`.`,
);
if (fErr) return { err: fErr };
return { finalized: true };
}
// La promozione memorie e' l'ultima interazione umana di F8: chiudere qui
// (advance + finalize) evita il reviewer_confirm finale a workflow gia'
// deciso (la form "approve" ridondante a fine sessione). Fuori dall'ultima
// fase ritorna null e il chiamante mantiene il comportamento precedente.
function closeAfterPromotion(ctx, session, curNum, summary) {
if (curNum < phaseMeta(ctx).max_phase) return null;
const r = advancePhaseAndFinalize(ctx, session, curNum);
if (r.err) return r.err;
lockActive = false;
lastSteered = false;
return textResult(
`${summary} Fase ${curNum} approvata e sessione finalizzata (${session}): ` +
"il workflow e' completo. Comunica al reviewer il riepilogo finale " +
"della sessione e termina il turno; NON presentare altri gate reviewer_*.",
);
}
pi.registerTool({
name: "reviewer_memory_promote",
label: "Promozione memorie riusabili (reviewer)",
@@ -1062,14 +1103,17 @@ export default function (pi) {
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
"alla sessione: i candidati sono deterministici, NON li scrivi tu.",
"alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " +
"promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " +
"presentare un reviewer_confirm dopo.",
parameters: Type.Object({
session: Type.String(),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session } = params;
const phase = phaseId(ctx, currentPhase(ctx, session));
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
let candidates;
try {
candidates = JSON.parse(
@@ -1084,6 +1128,10 @@ export default function (pi) {
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
"info",
);
const closed = closeAfterPromotion(
ctx, session, curNum, "Nessun candidato di promozione.",
);
if (closed) return closed;
return textResult(
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
);
@@ -1128,10 +1176,12 @@ export default function (pi) {
}), "");
if (err) return err;
}
return textResult(
const summary =
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
`${decline.length} candidati scartati.`,
);
`${decline.length} candidati scartati.`;
const closed = closeAfterPromotion(ctx, session, curNum, summary);
if (closed) return closed;
return textResult(summary);
},
});