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:
@@ -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