fix(executor): bypass summary-incomplete refusal for PREMISE STALE: summaries
Address the code-review finding on the prior commit: a natural premise-stale summary like "PREMISE STALE: the task has no remaining work — implementation is already done on HEAD" matches /\b(incomplete|not implemented|not done| not finished)\b/i with 'the task' inside the 40-char first-person window, refusing fn_task_done with summary-claims-incomplete and deadlocking the escape hatch. When summary starts (case-insensitive) with PREMISE STALE:, skip the dissent-pattern and scoped-incomplete summary checks. Pending-code-review and bulk-step-completion guards still apply unchanged. Add executor-task-done-premise-stale.test.ts covering: the deadlock case now passes; dissent phrasing in a sentinel summary is allowed; case- insensitive sentinel; sentinel must be at the start (mid-summary doesn't bypass); REVISE verdict still blocks even with the sentinel.
This commit is contained in:
9
.changeset/executor-premise-stale-bypass.md
Normal file
9
.changeset/executor-premise-stale-bypass.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
fix(executor): exempt `PREMISE STALE:` summaries from `summary-claims-incomplete` refusals
|
||||
|
||||
The preflight escape hatch added in the prior commit instructs the agent to call `fn_task_done` with a summary that begins `PREMISE STALE:` when reproduction shows HEAD already matches the desired state. Natural premise-stale wording such as *"PREMISE STALE: the task has no remaining work — implementation is already done on HEAD"* tripped `evaluateTaskDoneRefusal`'s scoped-incomplete regex (`/\b(incomplete|not implemented|not done|not finished)\b/i`) when the 40-char window contained `the task`/`this task`/first-person pronouns, refusing `fn_task_done` and deadlocking the executor — the exact failure the escape hatch was meant to prevent.
|
||||
|
||||
Add a sentinel bypass: when `summary` starts (case-insensitive) with `PREMISE STALE:`, skip the dissent-pattern and scoped-incomplete summary checks. The `pending-code-review-revise` and `bulk-step-completion-without-review` guards still run unchanged, so the bypass cannot dodge real review obligations or unfinished work — only the summary-phrasing checks are relaxed.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateTaskDoneRefusal } from "../executor.js";
|
||||
|
||||
function createTask(stepStatuses: Array<"done" | "skipped" | "pending" | "in-progress">) {
|
||||
return {
|
||||
id: "FN-PREMISE-STALE",
|
||||
title: "Premise stale",
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: stepStatuses.map((status, index) => ({ name: `Step ${index + 1}`, status })),
|
||||
currentStep: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("preflight PREMISE STALE: escape hatch", () => {
|
||||
it("allows fn_task_done when summary starts with PREMISE STALE: even if it contains 'done' near 'the task'", () => {
|
||||
// Without the bypass, the scoped-incomplete regex matches 'done' and the
|
||||
// 40-char window contains 'the task' → would refuse with
|
||||
// summary-claims-incomplete. The bypass must let this through.
|
||||
const task = createTask(["done", "skipped", "skipped", "skipped", "skipped"]);
|
||||
const result = evaluateTaskDoneRefusal(
|
||||
task,
|
||||
{ summary: "PREMISE STALE: the task has no remaining work — implementation is already done on HEAD" },
|
||||
new Map(),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("allows fn_task_done when summary starts with PREMISE STALE: and contains 'I'm blocked' style dissent phrasing", () => {
|
||||
// Natural premise-stale phrasing may accidentally include a dissent-pattern
|
||||
// word ("blocked from", "to unblock", "requires follow-up"). The bypass
|
||||
// must not refuse on the dissent regex when the sentinel is present.
|
||||
const task = createTask(["done", "skipped", "skipped"]);
|
||||
const result = evaluateTaskDoneRefusal(
|
||||
task,
|
||||
{ summary: "PREMISE STALE: targeted reproduction passes on HEAD; nothing to unblock and no further work required" },
|
||||
new Map(),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("is case-insensitive on the sentinel", () => {
|
||||
const task = createTask(["done", "skipped"]);
|
||||
const result = evaluateTaskDoneRefusal(
|
||||
task,
|
||||
{ summary: "premise stale: this task is not done because main already shipped it" },
|
||||
new Map(),
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("does NOT bypass when sentinel appears later in the summary (must be at start)", () => {
|
||||
// Defends against agents tacking the sentinel into the middle to dodge a
|
||||
// genuine incomplete-work refusal.
|
||||
const task = createTask(["done", "pending"]);
|
||||
const result = evaluateTaskDoneRefusal(
|
||||
task,
|
||||
{ summary: "The task is not done yet, but PREMISE STALE: I think it's stale anyway" },
|
||||
new Map(),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.refusalClass).toBe("summary-claims-incomplete");
|
||||
}
|
||||
});
|
||||
|
||||
it("still enforces pending-code-review-revise even with the sentinel", () => {
|
||||
// The bypass only relaxes the summary-text checks. A genuine REVISE verdict
|
||||
// on an in-progress step must still block fn_task_done.
|
||||
const task = createTask(["done", "in-progress"]);
|
||||
const verdicts = new Map<number, "REVISE">([[1, "REVISE"]]);
|
||||
const result = evaluateTaskDoneRefusal(
|
||||
task,
|
||||
{ summary: "PREMISE STALE: already done on HEAD" },
|
||||
verdicts as any,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.refusalClass).toBe("pending-code-review-revise");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -310,7 +310,15 @@ export function evaluateTaskDoneRefusal(
|
||||
}
|
||||
|
||||
const summary = params.summary?.trim();
|
||||
if (summary) {
|
||||
// Preflight escape hatch: when the agent's preflight finds PROMPT.md is out
|
||||
// of sync with HEAD (work already done on the base), it marks remaining
|
||||
// steps `skipped` and calls fn_task_done with a `PREMISE STALE:` summary.
|
||||
// Skip the summary-text refusals (dissent + scoped-incomplete) for this
|
||||
// sentinel so a natural premise-stale explanation like "...the work is
|
||||
// already done on HEAD" cannot deadlock the executor. The pending-review
|
||||
// and bulk-step-completion guards above/below still apply.
|
||||
const isPremiseStale = !!summary && /^premise stale:/i.test(summary);
|
||||
if (summary && !isPremiseStale) {
|
||||
const dissentMatch = DISSENT_PATTERNS.find((pattern) => pattern.test(summary));
|
||||
if (dissentMatch) {
|
||||
const matchText = summary.match(dissentMatch)?.[0] ?? dissentMatch.source;
|
||||
|
||||
Reference in New Issue
Block a user