feat: gate decision-type validation, force-advance, and frontend fixes

Gate (tht-gate.js):
- Pre-validate decision types against workflow.yaml before showing reviewer widget
- Reject decisions emitted by later phases (min-phase check)
- Copy top-level `kind` into artifact when model forgets it (prevents loop)
- Force-advance on reviewer_decide/schema_linking when advance:true — skip
  redundant reviewer_confirm gate

Backend:
- Emit agent_end on clean Pi exit (code 0 + bridge idle) instead of marking failed

Frontend:
- Strip <think> tags from transcript and activity panel
- Fix mermaid render with offscreen container + cleanup
- Graceful mermaid error: show source code instead of red error, fall back to table

Workflow:
- F2 now emits table_promoted and table_excluded (early schema linking decisions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 14:14:44 +02:00
co-authored by Claude Opus 4.6
parent 9fbca06f7c
commit 6ee5bda7f0
9 changed files with 114 additions and 16 deletions
+8
View File
@@ -122,6 +122,14 @@ export class PiProcessManager {
child.on("exit", (code) => { child.on("exit", (code) => {
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === runtime}`); console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === runtime}`);
if (this.runtimes.get(sessionId) === runtime) { if (this.runtimes.get(sessionId) === runtime) {
// Clean exit (code 0) while the bridge is idle means the model completed its
// turn and Pi shut down normally (e.g. after session finalization). Just clean
// up — no failure events.
if (code === 0 && runtime.bridge.turnState() === "idle") {
this.runtimes.delete(sessionId);
runtime.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
return;
}
runtime.bridge.markFailed(); runtime.bridge.markFailed();
this.runtimes.delete(sessionId); this.runtimes.delete(sessionId);
runtime.bridge.emitClientEvent({ runtime.bridge.emitClientEvent({
+2
View File
@@ -5,6 +5,8 @@ import { isNearBottom } from "./activityScroll";
function transcriptLines(transcript: Array<{ text: string }>): string[] { function transcriptLines(transcript: Array<{ text: string }>): string[] {
return transcript.flatMap(({ text }) => return transcript.flatMap(({ text }) =>
text text
.replace(/<think>[\s\S]*?<\/think>/g, "")
.replace(/<\/?think>/g, "")
.split("\n") .split("\n")
.map((line) => line.trimEnd()) .map((line) => line.trimEnd())
.filter((line) => line.trim() !== ""), .filter((line) => line.trim() !== ""),
@@ -25,6 +25,8 @@ export function isVisibleModelActivity(entry: ActivityEntry): boolean {
export function formatModelActivity(text: string): string { export function formatModelActivity(text: string): string {
return text return text
.replace(/<think>[\s\S]*?<\/think>/g, "")
.replace(/<\/?think>/g, "")
.replace(/\r\n?/g, "\n") .replace(/\r\n?/g, "\n")
.replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n") .replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n")
.replace(/^[\t ]*[•‣–]\s+/gm, "- ") .replace(/^[\t ]*[•‣–]\s+/gm, "- ")
+1 -1
View File
@@ -16,7 +16,7 @@ function MermaidBlock({ code }: { code: string }) {
); );
}, [code]); }, [code]);
if (error) return <pre className="text-destructive text-xs">{error}</pre>; if (error) return <pre className="overflow-auto rounded-lg bg-muted p-3 text-xs opacity-60">{code}</pre>;
if (!svg) return <pre className="text-xs opacity-50">{code}</pre>; if (!svg) return <pre className="text-xs opacity-50">{code}</pre>;
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
return <div dangerouslySetInnerHTML={{ __html: svg }} />; return <div dangerouslySetInnerHTML={{ __html: svg }} />;
+3 -3
View File
@@ -116,9 +116,9 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
if (view !== "chart" || oversized) return; if (view !== "chart" || oversized) return;
let cancelled = false; let cancelled = false;
const def = buildErDiagram(promoted, linking.joins); const def = buildErDiagram(promoted, linking.joins);
renderMermaid(def).then((s) => { renderMermaid(def)
if (!cancelled) setSvg(s); .then((s) => { if (!cancelled) setSvg(s); })
}); .catch(() => { if (!cancelled) setView("table"); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
+11 -2
View File
@@ -7,6 +7,15 @@ export async function renderMermaid(def: string): Promise<string> {
_initialized = true; _initialized = true;
} }
const id = `mermaid-${Math.random().toString(36).slice(2)}`; const id = `mermaid-${Math.random().toString(36).slice(2)}`;
const { svg } = await mermaid.render(id, def); const container = document.createElement("div");
return svg; container.style.position = "absolute";
container.style.left = "-9999px";
document.body.appendChild(container);
try {
const { svg } = await mermaid.render(id, def, container);
return svg;
} finally {
container.remove();
document.getElementById(id)?.remove();
}
} }
+85 -8
View File
@@ -129,6 +129,11 @@ export function prepareReviewerArguments(input) {
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) { if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) }; args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) };
} }
// Models frequently forget artifact.kind while providing it at top level —
// copy it over so validation doesn't loop.
if (args.artifact && typeof args.artifact === "object" && !args.artifact.kind && args.kind) {
args.artifact = { ...args.artifact, kind: args.kind };
}
return args; return args;
} }
@@ -275,6 +280,52 @@ function phaseId(ctx, num) {
const p = meta.phases.find((x) => x.num === num); const p = meta.phases.find((x) => x.num === num);
return p ? p.id : "?"; return p ? p.id : "?";
} }
// Known decision types from workflow.yaml emits + meta types. Pre-validates decision
// payloads BEFORE showing the reviewer widget so a typo in the type name doesn't waste
// a human interaction (the CLI would reject it only after the reviewer has already picked).
let _knownTypes = null;
function knownDecisionTypes(ctx) {
if (_knownTypes) return _knownTypes;
const meta = phaseMeta(ctx);
const types = new Set([
"phase_approved", "phase_auto_approved", "phase_reopened",
"phase_skipped", "decision_retracted",
]);
for (const p of meta.phases) {
for (const t of (p.emits || [])) types.add(t);
}
_knownTypes = types;
return types;
}
function decisionMinPhaseMap(ctx) {
const meta = phaseMeta(ctx);
const mins = {};
for (const p of meta.phases) {
for (const t of (p.emits || [])) {
if (!(t in mins) || p.num < mins[t]) mins[t] = p.num;
}
}
return mins;
}
function validateDecisionTypes(ctx, options, session) {
const known = knownDecisionTypes(ctx);
for (const o of options) {
if (o.decision && !known.has(o.decision.type)) {
return `Tipo di decisione '${o.decision.type}' non valido. Tipi ammessi: ${[...known].join(", ")}. Correggi e riprova.`;
}
}
if (session) {
const cur = currentPhase(ctx, session);
const mins = decisionMinPhaseMap(ctx);
for (const o of options) {
if (o.decision && mins[o.decision.type] && mins[o.decision.type] > cur) {
return `Tipo '${o.decision.type}' ammesso dalla Fase ${mins[o.decision.type]}, sessione alla Fase ${cur}. Chiudi prima la fase corrente.`;
}
}
}
return null;
}
// Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`. // Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`.
function phaseMetaForNum(ctx, num) { function phaseMetaForNum(ctx, num) {
const meta = phaseMeta(ctx); const meta = phaseMeta(ctx);
@@ -316,6 +367,21 @@ function advanceIfReady(ctx, session) {
} }
} }
// Unconditional phase advance — the reviewer already approved via the widget interaction
// (reviewer_decide selection IS the human confirmation). Used when reviewer_decide persists
// substantive decisions with advance:true, so no separate reviewer_confirm gate is needed.
function forceAdvance(ctx, session) {
try {
tht(ctx, ["phase", "advance", "--session", session]);
return { advanced: true };
} catch (e) {
return {
advanced: false,
error: (e.stderr || e.message || String(e)).toString().trim(),
};
}
}
// --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) -------- // --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) --------
// //
// emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title` // emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title`
@@ -662,6 +728,8 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session, title, options: opts, intro, advance } = params; const { session, title, options: opts, intro, advance } = params;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session)); const phase = phaseId(ctx, currentPhase(ctx, session));
const recommended = opts.find((o) => o.recommended)?.id ?? null; const recommended = opts.find((o) => o.recommended)?.id ?? null;
@@ -734,6 +802,8 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) { async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true; lockActive = true;
const { session, title, options: opts, advance } = params; const { session, title, options: opts, advance } = params;
const typeErr = validateDecisionTypes(ctx, opts, session);
if (typeErr) return textResult(typeErr);
const phase = phaseId(ctx, currentPhase(ctx, session)); const phase = phaseId(ctx, currentPhase(ctx, session));
const toAdd = []; const toAdd = [];
const meritOptions = opts const meritOptions = opts
@@ -813,12 +883,17 @@ export default function (pi) {
toAdd.push(d); toAdd.push(d);
} }
} }
if (advance) advanceIfReady(ctx, session); if (!toAdd.length) {
return textResult( const adv = advance ? advanceIfReady(ctx, session) : { advanced: false };
toAdd.length const msg = "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
? `Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.` return textResult(adv.advanced ? msg + " Fase avanzata automaticamente." : msg);
: "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).", }
); // Decisions were recorded AND advance requested: the reviewer's selection IS the
// approval — force-advance without a separate reviewer_confirm gate.
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
const parts = [`Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`];
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
return textResult(parts.join(" "));
}, },
}); });
@@ -924,8 +999,10 @@ export default function (pi) {
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], ""); const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
if (eSync) return eSync; if (eSync) return eSync;
if (advance) advanceIfReady(ctx, session); const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
return textResult(`Schema linking registrato dal reviewer (${n} tabelle + colonne curate).`); const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`];
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
return textResult(parts.join(" "));
}, },
}); });
+1 -1
View File
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
("concept_clarified", 1), ("concept_clarified", 1),
("memory_rejected", 2), ("memory_rejected", 2),
("question_rewritten", 3), ("question_rewritten", 3),
("table_promoted", 4), ("table_promoted", 2),
("column_corrected", 4), ("column_corrected", 4),
("evidence_accepted", 4), ("evidence_accepted", 4),
("value_grounded", 4), ("value_grounded", 4),
+1 -1
View File
@@ -21,7 +21,7 @@ phases:
advance: auto_if_empty advance: auto_if_empty
prerequisites: [] prerequisites: []
artifacts_out: [] artifacts_out: []
emits: [memory_rejected] emits: [memory_rejected, table_promoted, table_excluded]
- id: F3 - id: F3
name: riscrittura name: riscrittura
advance: kind:phase advance: kind:phase