fix: make join approval atomic
This commit is contained in:
@@ -33,9 +33,10 @@ const JOIN_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
function shellStub(calls) {
|
||||
return (_file, args) => {
|
||||
function shellStub(calls, inputs = []) {
|
||||
return (_file, args, options = {}) => {
|
||||
calls.push(args.join(" "));
|
||||
if (options.input) inputs.push(options.input);
|
||||
if (args[0] === "phase" && args[1] === "meta") {
|
||||
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
|
||||
}
|
||||
@@ -46,8 +47,9 @@ function shellStub(calls) {
|
||||
|
||||
test("join-only reviewer_decide is read-only and Continue persists every join", async () => {
|
||||
const calls = [];
|
||||
const inputs = [];
|
||||
const original = cp.execFileSync;
|
||||
cp.execFileSync = shellStub(calls);
|
||||
cp.execFileSync = shellStub(calls, inputs);
|
||||
try {
|
||||
const gate = require(GATE);
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
@@ -77,9 +79,48 @@ test("join-only reviewer_decide is read-only and Continue persists every join",
|
||||
assert.equal(descriptor.widget, "join-review");
|
||||
assert.equal(descriptor.options[0].detail, JOIN_OPTIONS[0].decision.detail);
|
||||
assert.equal(descriptor.options[0].rationale, JOIN_OPTIONS[0].decision.rationale);
|
||||
const decisions = calls.filter((call) => call.startsWith("decision add"));
|
||||
assert.equal(decisions.length, 2);
|
||||
assert.ok(decisions.every((call) => call.includes("--type join_modified")));
|
||||
const decisions = calls.filter((call) => call.startsWith("decision add-join-set"));
|
||||
assert.equal(decisions.length, 1);
|
||||
assert.deepEqual(JSON.parse(inputs[0]), JOIN_OPTIONS.map((option) => option.decision));
|
||||
} finally {
|
||||
cp.execFileSync = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("partial join-review responses are rejected and the widget is presented again", async () => {
|
||||
const calls = [];
|
||||
const original = cp.execFileSync;
|
||||
cp.execFileSync = shellStub(calls);
|
||||
try {
|
||||
const gate = require(GATE);
|
||||
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||
const { pi, ctx, tools } = createFakePi();
|
||||
ctx.cwd = "/nonexistent-thothii-test-cwd";
|
||||
gate.default(pi);
|
||||
|
||||
let attempts = 0;
|
||||
ctx.ui.input = async (title) => {
|
||||
const descriptor = JSON.parse(title);
|
||||
attempts += 1;
|
||||
return JSON.stringify({
|
||||
id: descriptor.id,
|
||||
kind: attempts === 1 ? "multiselect" : "join-review",
|
||||
choices: attempts === 2
|
||||
? [descriptor.options[0].id]
|
||||
: descriptor.options.map((option) => option.id),
|
||||
});
|
||||
};
|
||||
|
||||
const tool = tools.get("reviewer_decide");
|
||||
await tool.def.execute(
|
||||
"call-partial",
|
||||
{ session: "s1", title: "Review joins", options: JOIN_OPTIONS, advance: false },
|
||||
null,
|
||||
null,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(attempts, 3);
|
||||
} finally {
|
||||
cp.execFileSync = original;
|
||||
}
|
||||
|
||||
@@ -246,9 +246,9 @@ function tht(ctx, args, input) {
|
||||
|
||||
// Runs a privileged tht call; converts any failure into an actionable textResult
|
||||
// (never propagates a raw "Command failed" to the model).
|
||||
function relayIfThtFails(ctx, args, recovery) {
|
||||
function relayIfThtFails(ctx, args, recovery, input) {
|
||||
try {
|
||||
tht(ctx, args);
|
||||
tht(ctx, args, input);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const cliMsg = (e.stderr || e.message || String(e)).toString().trim();
|
||||
@@ -380,6 +380,16 @@ export function resolveConfirmOutcome(resp) {
|
||||
return { kind: "unknown", choice };
|
||||
}
|
||||
|
||||
export function isJoinReviewApproval(resp, optionIds) {
|
||||
if (resp?.kind !== "join-review" || !Array.isArray(resp.choices)) return false;
|
||||
const expected = new Set(optionIds);
|
||||
const chosen = new Set(resp.choices);
|
||||
return expected.size === optionIds.length &&
|
||||
chosen.size === resp.choices.length &&
|
||||
chosen.size === expected.size &&
|
||||
[...chosen].every((id) => expected.has(id));
|
||||
}
|
||||
|
||||
// True when a reviewer_decide has no merito options but is allowed to close empty
|
||||
// and advance (the empty memory phase F2). The gate then shows an info notice and
|
||||
// auto-advances instead of presenting an empty checklist.
|
||||
@@ -764,7 +774,15 @@ export default function (pi) {
|
||||
options: meritOptions,
|
||||
...(phase === "F2" ? memorySelectionWidgetProps(opts) : {}),
|
||||
});
|
||||
const resp = await emitAndWait(ctx, widget);
|
||||
let resp;
|
||||
for (;;) {
|
||||
resp = await emitAndWait(ctx, widget);
|
||||
if (resp.control === "freetext" || resp.control === "back" || resp.control === "exit")
|
||||
break;
|
||||
if (!joinOnly || isJoinReviewApproval(resp, meritOptions.map((option) => option.id)))
|
||||
break;
|
||||
await reLoop(ctx);
|
||||
}
|
||||
if (resp.control === "freetext") {
|
||||
return textResult(
|
||||
`Altro (reviewer): ${resp.text}. Riformula la proposta tenendo conto.`,
|
||||
@@ -777,11 +795,23 @@ export default function (pi) {
|
||||
const chosen = joinOnly
|
||||
? opts.filter((o) => !isReserved(o.label))
|
||||
: opts.filter((o) => (resp.choices ?? []).includes(o.id));
|
||||
for (const c of chosen) {
|
||||
const d = c.decision;
|
||||
const err = relayIfThtFails(ctx, decisionAddArgs(session, d), "");
|
||||
if (joinOnly) {
|
||||
const decisions = chosen.map((choice) => choice.decision);
|
||||
const err = relayIfThtFails(
|
||||
ctx,
|
||||
["decision", "add-join-set", "--session", session, "--doc", "-"],
|
||||
"Il set di join non è stato registrato; correggi l'errore e riprova.",
|
||||
JSON.stringify(decisions),
|
||||
);
|
||||
if (err) return err;
|
||||
toAdd.push(d);
|
||||
toAdd.push(...decisions);
|
||||
} else {
|
||||
for (const c of chosen) {
|
||||
const d = c.decision;
|
||||
const err = relayIfThtFails(ctx, decisionAddArgs(session, d), "");
|
||||
if (err) return err;
|
||||
toAdd.push(d);
|
||||
}
|
||||
}
|
||||
if (advance) advanceIfReady(ctx, session);
|
||||
return textResult(
|
||||
|
||||
Reference in New Issue
Block a user