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:
@@ -122,6 +122,14 @@ export class PiProcessManager {
|
||||
child.on("exit", (code) => {
|
||||
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${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();
|
||||
this.runtimes.delete(sessionId);
|
||||
runtime.bridge.emitClientEvent({
|
||||
|
||||
@@ -5,6 +5,8 @@ import { isNearBottom } from "./activityScroll";
|
||||
function transcriptLines(transcript: Array<{ text: string }>): string[] {
|
||||
return transcript.flatMap(({ text }) =>
|
||||
text
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
.replace(/<\/?think>/g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim() !== ""),
|
||||
|
||||
@@ -25,6 +25,8 @@ export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
||||
|
||||
export function formatModelActivity(text: string): string {
|
||||
return text
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
.replace(/<\/?think>/g, "")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n")
|
||||
.replace(/^[\t ]*[•‣–]\s+/gm, "- ")
|
||||
|
||||
@@ -16,7 +16,7 @@ function MermaidBlock({ code }: { code: string }) {
|
||||
);
|
||||
}, [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>;
|
||||
// eslint-disable-next-line react/no-danger
|
||||
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
|
||||
@@ -116,9 +116,9 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
if (view !== "chart" || oversized) return;
|
||||
let cancelled = false;
|
||||
const def = buildErDiagram(promoted, linking.joins);
|
||||
renderMermaid(def).then((s) => {
|
||||
if (!cancelled) setSvg(s);
|
||||
});
|
||||
renderMermaid(def)
|
||||
.then((s) => { if (!cancelled) setSvg(s); })
|
||||
.catch(() => { if (!cancelled) setView("table"); });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,15 @@ export async function renderMermaid(def: string): Promise<string> {
|
||||
_initialized = true;
|
||||
}
|
||||
const id = `mermaid-${Math.random().toString(36).slice(2)}`;
|
||||
const { svg } = await mermaid.render(id, def);
|
||||
return svg;
|
||||
const container = document.createElement("div");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ export function prepareReviewerArguments(input) {
|
||||
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -275,6 +280,52 @@ function phaseId(ctx, num) {
|
||||
const p = meta.phases.find((x) => x.num === num);
|
||||
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`.
|
||||
function phaseMetaForNum(ctx, num) {
|
||||
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) --------
|
||||
//
|
||||
// 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) {
|
||||
lockActive = true;
|
||||
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 recommended = opts.find((o) => o.recommended)?.id ?? null;
|
||||
|
||||
@@ -734,6 +802,8 @@ export default function (pi) {
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
lockActive = true;
|
||||
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 toAdd = [];
|
||||
const meritOptions = opts
|
||||
@@ -813,12 +883,17 @@ export default function (pi) {
|
||||
toAdd.push(d);
|
||||
}
|
||||
}
|
||||
if (advance) advanceIfReady(ctx, session);
|
||||
return textResult(
|
||||
toAdd.length
|
||||
? `Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`
|
||||
: "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).",
|
||||
);
|
||||
if (!toAdd.length) {
|
||||
const adv = advance ? advanceIfReady(ctx, session) : { advanced: false };
|
||||
const msg = "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
|
||||
return textResult(adv.advanced ? msg + " Fase avanzata automaticamente." : msg);
|
||||
}
|
||||
// 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], "");
|
||||
if (eSync) return eSync;
|
||||
|
||||
if (advance) advanceIfReady(ctx, session);
|
||||
return textResult(`Schema linking registrato dal reviewer (${n} tabelle + colonne curate).`);
|
||||
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
|
||||
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(" "));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
|
||||
("concept_clarified", 1),
|
||||
("memory_rejected", 2),
|
||||
("question_rewritten", 3),
|
||||
("table_promoted", 4),
|
||||
("table_promoted", 2),
|
||||
("column_corrected", 4),
|
||||
("evidence_accepted", 4),
|
||||
("value_grounded", 4),
|
||||
|
||||
@@ -21,7 +21,7 @@ phases:
|
||||
advance: auto_if_empty
|
||||
prerequisites: []
|
||||
artifacts_out: []
|
||||
emits: [memory_rejected]
|
||||
emits: [memory_rejected, table_promoted, table_excluded]
|
||||
- id: F3
|
||||
name: riscrittura
|
||||
advance: kind:phase
|
||||
|
||||
Reference in New Issue
Block a user