fix(gate): auto-finalize session after last phase approval
The model sometimes stops after receiving 'Fase approvata' without calling `tht session finalize`, leaving the session open. Now the gate itself calls finalize after advancing the max phase (F8), making session closure deterministic regardless of model behavior. - reviewer_confirm kind:phase: after phase advance at max_phase, gate calls `tht session finalize <session>` (best-effort with recovery message) - SKILL.md updated: model no longer needs to call finalize itself - Tests: 2 new JS tests (auto-finalize at max phase; no-finalize at non-max) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -345,9 +345,11 @@ test("reviewer_confirm kind:phase (v2): fills phase from phaseMeta and enriches
|
|||||||
const origExecFileSync = cp.execFileSync;
|
const origExecFileSync = cp.execFileSync;
|
||||||
cp.execFileSync = (file, args) => {
|
cp.execFileSync = (file, args) => {
|
||||||
if (args[0] === "phase" && args[1] === "meta")
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
return JSON.stringify({ phases: [{ num: 5, id: "F5", name: "sintesi" }] });
|
return JSON.stringify({ max_phase: 8, phases: [{ num: 5, id: "F5", name: "sintesi", emits: [] }] });
|
||||||
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n";
|
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n";
|
||||||
if (args[0] === "phase" && args[1] === "advance") return "";
|
if (args[0] === "phase" && args[1] === "advance") return "";
|
||||||
|
if (args[0] === "session" && args[1] === "show")
|
||||||
|
return JSON.stringify({ decisions: [] });
|
||||||
if (args[0] === "schema" && args[1] === "columns")
|
if (args[0] === "schema" && args[1] === "columns")
|
||||||
return JSON.stringify(CATALOG[args[2]] || {});
|
return JSON.stringify(CATALOG[args[2]] || {});
|
||||||
return "";
|
return "";
|
||||||
@@ -386,11 +388,83 @@ test("reviewer_confirm kind:phase (v2): fills phase from phaseMeta and enriches
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reviewer_confirm kind:phase (v2) at max_phase auto-finalizes the session", async () => {
|
||||||
|
const origExecFileSync = cp.execFileSync;
|
||||||
|
let finalizeCalled = false;
|
||||||
|
cp.execFileSync = (file, args) => {
|
||||||
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
|
return JSON.stringify({ max_phase: 8, phases: [{ num: 8, id: "F8", name: "datamart", emits: [] }] });
|
||||||
|
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n";
|
||||||
|
if (args[0] === "phase" && args[1] === "advance") return "";
|
||||||
|
if (args[0] === "session" && args[1] === "show")
|
||||||
|
return JSON.stringify({ decisions: [] });
|
||||||
|
if (args[0] === "session" && args[1] === "finalize") { finalizeCalled = true; return ""; }
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { ctx, tools } = await loadGate();
|
||||||
|
ctx.ui.input = async (title) => {
|
||||||
|
const d = JSON.parse(title);
|
||||||
|
return JSON.stringify({ id: d.id, choices: ["approve"] });
|
||||||
|
};
|
||||||
|
const tool = tools.get("reviewer_confirm");
|
||||||
|
const result = await tool.def.execute(
|
||||||
|
"call-1",
|
||||||
|
{
|
||||||
|
session: "s1", kind: "phase", title: "Chiusura F8",
|
||||||
|
artifact: { kind: "phase", data: { schema_version: 2, summary: "done" } },
|
||||||
|
},
|
||||||
|
null, null, ctx,
|
||||||
|
);
|
||||||
|
assert.ok(finalizeCalled, "gate must auto-finalize after last phase advance");
|
||||||
|
assert.match(result.content[0].text, /finalizzata/);
|
||||||
|
} finally {
|
||||||
|
cp.execFileSync = origExecFileSync;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviewer_confirm kind:phase at non-max phase does NOT finalize", async () => {
|
||||||
|
const origExecFileSync = cp.execFileSync;
|
||||||
|
let finalizeCalled = false;
|
||||||
|
cp.execFileSync = (file, args) => {
|
||||||
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
|
return JSON.stringify({ max_phase: 8, phases: [{ num: 5, id: "F5", name: "sintesi", emits: [] }] });
|
||||||
|
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n";
|
||||||
|
if (args[0] === "phase" && args[1] === "advance") return "";
|
||||||
|
if (args[0] === "session" && args[1] === "show")
|
||||||
|
return JSON.stringify({ decisions: [] });
|
||||||
|
if (args[0] === "session" && args[1] === "finalize") { finalizeCalled = true; return ""; }
|
||||||
|
if (args[0] === "schema" && args[1] === "columns") return "{}";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { ctx, tools } = await loadGate();
|
||||||
|
ctx.ui.input = async (title) => {
|
||||||
|
const d = JSON.parse(title);
|
||||||
|
return JSON.stringify({ id: d.id, choices: ["approve"] });
|
||||||
|
};
|
||||||
|
const tool = tools.get("reviewer_confirm");
|
||||||
|
const result = await tool.def.execute(
|
||||||
|
"call-1",
|
||||||
|
{
|
||||||
|
session: "s1", kind: "phase", title: "Chiusura F5",
|
||||||
|
artifact: { kind: "phase", data: { schema_version: 2, summary: "ok" } },
|
||||||
|
},
|
||||||
|
null, null, ctx,
|
||||||
|
);
|
||||||
|
assert.ok(!finalizeCalled, "must NOT finalize at non-max phase");
|
||||||
|
assert.match(result.content[0].text, /Fase approvata/);
|
||||||
|
assert.ok(!result.content[0].text.includes("finalizzata"));
|
||||||
|
} finally {
|
||||||
|
cp.execFileSync = origExecFileSync;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("reviewer_confirm kind:phase legacy (non-v2 data): unchanged path", async () => {
|
test("reviewer_confirm kind:phase legacy (non-v2 data): unchanged path", async () => {
|
||||||
const origExecFileSync = cp.execFileSync;
|
const origExecFileSync = cp.execFileSync;
|
||||||
cp.execFileSync = (file, args) => {
|
cp.execFileSync = (file, args) => {
|
||||||
if (args[0] === "phase" && args[1] === "meta")
|
if (args[0] === "phase" && args[1] === "meta")
|
||||||
return JSON.stringify({ phases: [{ num: 1, id: "F1", name: "chiarimento" }] });
|
return JSON.stringify({ max_phase: 8, phases: [{ num: 1, id: "F1", name: "chiarimento" }] });
|
||||||
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 1\n";
|
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 1\n";
|
||||||
if (args[0] === "phase" && args[1] === "advance") return "";
|
if (args[0] === "phase" && args[1] === "advance") return "";
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -957,6 +957,23 @@ export default function (pi) {
|
|||||||
PHASE_RECOVERY,
|
PHASE_RECOVERY,
|
||||||
);
|
);
|
||||||
if (err) return err;
|
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;
|
||||||
|
lockActive = false;
|
||||||
|
lastSteered = false;
|
||||||
|
return textResult(
|
||||||
|
`Fase ${curNum} approvata e sessione finalizzata (${session}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return textResult(`Fase approvata (sessione ${session}).`);
|
return textResult(`Fase approvata (sessione ${session}).`);
|
||||||
}
|
}
|
||||||
if (kind === "cte_plan") {
|
if (kind === "cte_plan") {
|
||||||
|
|||||||
@@ -393,12 +393,14 @@ Prerequisite: Phase 7 closed.
|
|||||||
pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`;
|
pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`;
|
||||||
deselected → `memory_promotion_declined` (never re-proposed). If the gate reports
|
deselected → `memory_promotion_declined` (never re-proposed). If the gate reports
|
||||||
zero candidates, move on — do not retry.
|
zero candidates, move on — do not retry.
|
||||||
4. Close with `reviewer_confirm kind:"phase"`. The session is finalizable.
|
4. Close with `reviewer_confirm kind:"phase"`. The gate auto-finalizes the session
|
||||||
|
after advancing the last phase — you do NOT need to call `tht session finalize`
|
||||||
|
yourself. If auto-finalize fails, the error message tells you the recovery command.
|
||||||
|
|
||||||
## Session end
|
## Session end
|
||||||
|
|
||||||
When the workflow is complete (Phase 8), `tht session finalize` closes the session
|
When Phase 8 is approved, the gate calls `tht session finalize` automatically.
|
||||||
and unlocks input. Finalize also indexes the question→SQL pair in the vectordb
|
Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`,
|
||||||
(kind `solved_question`, best-effort — on failure recover with `tht memory
|
best-effort — on failure recover with `tht memory solved-index <id>`). The persisted
|
||||||
solved-index <id>`). The persisted state (ledger `review_decisions.jsonl` +
|
state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not
|
||||||
artifacts) is the truth: what is not recorded did not happen.
|
recorded did not happen.
|
||||||
|
|||||||
Reference in New Issue
Block a user