**U9's consolidation branch.** Supersedes nothing — #2637 and #2643 are green with zero threads and left for your sweep per rule 3. ## Contents | File | Change | Before → After | |---|---|---| | `__tests__/executor-step-numbering-zero-based.test.ts` | isolate the review-handoff `moveTask` call so the assertion is attributable | **1 failed / 3 passed → 4 passed** | | `__tests__/ce-workflow-step-executor.test.ts` | re-green against the block-first merge boundary | **3 failed / 48 passed → 51 passed** | | `__tests__/goal-anchoring-audit.test.ts` | swallow path reports at debug, not `console.warn` | **1 failed / 6 passed → 7 passed** | Triage-guard counts: **no change**. My lane has no remaining column receivers — the rest belong to the capacity/U7/U8/U11/U12 workers, or are deliberate compat retentions I verified individually (`spec-staleness.ts` carries its own "U11 proof" block; `live-agent-count.ts`'s literal fallback is reachable by flag-less callers). Commits kept small and separated: signature fix, then attribution fix, then the boundary re-green, then the debug-channel fix. **Census reconciliation:** `node scripts/lifecycle-column-census.mjs` reports **11** triage guards on main, and **none are in the review/merge lane** — they are the `moves.ts` flag-OFF branch plus the dashboard cluster. Nothing in this branch moves that number, and I am not chasing the 779 non-triage guards per your instruction. ## 1. The review-handoff assertion (and a lesson) The handoff gained a third argument (workflow move provenance), so a two-arg `toHaveBeenCalledWith` failed on the extra options object while the card moved correctly. My first fix used `expect.anything()` — and I *documented in the comment* that six mutations couldn't make it fail, then shipped it anyway. Greptile (P2) correctly called that out: this flow records two `moveTask` calls, so the assertion is satisfied by the boundary move even if the handoff regresses. **Documenting a weakness is not removing it.** Now the test selects the handoff call by its own marker (`workflowMoveMetadata.reason === "workflow-review-handoff"`), asserts exactly one such call, and asserts its target column: | Mutation | Before | After | |---|---|---| | change the seam's `reason` | green | **NEW=1**, this test only | | retarget the seam to `"done"` | green | **NEW=1**, this test only | ## 2. The merge boundary changed shape `ensureWorkflowMergeBoundaryTask` (`executor.ts:7808`) now **refuses** a foreach step-execute region with incomplete pre-merge node proof — logging `"Workflow merge boundary blocked: <reason>"` and returning **without moving**. The move-then-check sequence this file pinned is gone: `"Workflow merge boundary moved task to in-review before requesting merge"` no longer exists anywhere in production. Three fixes, one per failure: 1. **negative case** pinned the retired move-first log. Now pins the *stronger* property the new order gives: an unproven card is **not moved into review at all**. The old assertion could only say "it was moved, then blocked". Log text asserted by stable prefix — the reason clause enumerates missing instance ids, which is legitimately volatile. 2. **"moves direct-to-merge tasks into in-review"** got zero calls: its fixture recorded no node results, so the gate blocked it. Added one `steps#0:step-execute` pre-merge result. 3. **"completes graph-native checklist projection"** also got zero calls. Its existing `plan` result proves *some* pre-merge node ran but not the per-instance work; the gate additionally requires an instance per foreach step-execute. Added the two matching its two steps. (2) and (3) are the same class as the lifecycle E2E `seedTask` fix in #2634: a fixture that never modelled completed work, asking the engine to advance it, and reading the correct refusal as a failure. Proof shape matched to the evaluator (`source: "node"`, `phase: "pre-merge"`, terminal = `passed`/`skipped`) rather than guessed. Verified the gate is what these fixtures exercise: disabling the boundary proof check fails the negative case (`NEW=1`, that test only). `pnpm test:gate` green, `pnpm lint` clean. ## Where U9 actually stands The conversion (S06/S07/S08) is **not** done, and is now precisely characterised rather than "blocked on U8": `workflow-graph-executor.ts:310` short-circuits every `MERGE_REGION_KINDS` entry to the legacy merge seam, so `merge-gate`, `merge-attempt`, `manual-merge-hold`, `retry-backoff`, `recovery-router` and both `branch-group-*` handlers **never execute**. `createMergeGateHandler` does read `task.autoMerge` and emit auto-on/auto-off — and is never called. The builtin IR's `outcome:auto-*` edges are unreachable. **U9's conversion, concretely: stop short-circuiting `MERGE_REGION_KINDS` and let those nodes run.** S06/S07/S08 all hang off that one change. Safeguard 2 has no node-level representation today, so enabling the region without carrying the `autoMerge` contract into it would let an `autoMerge:false` card merge on PR-readiness alone. Full write-up in `docs/plans/workflow-owned-merge-stack/u9-safeguard-baseline.md` (#2634). ## 3. A recurring class worth a shared helper `goal-anchoring-audit`'s swallow path now reports via `log.debug` (a deliberate demotion of log noise), and `debug` is FUSION_DEBUG-gated so vitest emits nothing — the test asserted a channel that was both wrong *and* disabled. I kept both halves of the contract (swallowed **and** reported) by enabling the flag for that case, rather than deleting the awkward assertion. **This is the third instance this session** — `worktree-pool`, `self-healing`'s auto-archive line, and now this. If a fourth appears it deserves a shared test helper rather than three bespoke fixes. ## Two failing files I could NOT responsibly take — flagged, not touched **`executor-prompt.test.ts` (3 failures) — I ESCALATED THIS AND I WAS WRONG. Retracting.** I flagged these as a possible real pause-contract violation: an agent session spawning while an operator has globally paused the engine. I then finished the diagnosis, and the evidence goes the other way. Recording the retraction with the same detail as the alarm, because a false alarm aimed at another unit costs them a chase. **The discriminator I asked for, resolved.** Six tests in that file assert `expect(mockedCreateFnAgent).not.toHaveBeenCalled()` during global pause; 3 fail. Splitting them by what they drive: | Assertions | Drives | Result | |---|---|---| | `does not resume unpaused in-progress task while global pause is active` (+2 siblings) | no executor method — `task:updated` / resume paths | **pass** | | `parks todo tasks in in-progress when fn_task_done…` (+2 siblings) | `executor.execute(...)` **directly** | **fail** | So the guard holds on every event-driven path and is absent only from the direct `execute()` entry. **And `execute()` is not the guard site — the scheduler is.** `scheduler.ts:1491` is an explicit hard stop (*"Global pause (hard stop): halt all scheduling activity"*), with a second gate at `:1055`, and the scheduler never calls `.execute(` at all — dispatch routes through the runtime. In production a global pause halts scheduling before anything reaches the executor. **Conclusion: the pause contract is intact in production.** The 3 failing tests call `execute()` directly, bypassing the upstream gate, and assert a defence-in-depth check *inside* `execute()` that is not there. They are testing a path production does not take during a pause. What that leaves is a real but much smaller question, and a design one rather than a defect: should `execute()` carry its own pause check as defence-in-depth, given non-scheduler callers exist (self-healing, manual retry)? If yes, add the guard and all six assertions pass. If no, the 3 direct-`execute` assertions are asserting a guarantee the architecture places elsewhere and should be retired. **I have not changed either the code or the tests** — but nobody needs to hunt a pause-contract regression, because there isn't one. **`executor-fast-mode-workflows.test.ts` (1 failure) — mechanism not isolated.** `visitedNodeIds` is `['review']` where the test expects `['start','review']`. Three probes failed to explain it: giving the review node an explicit `column: "in-progress"` changed nothing (so it is not column-based entry resolution), and swapping `seam: "review"` for a plain prompt config did not isolate it either. Two structurally identical sibling tests in the same file still pass with `['start', ...]`, so something in graph traversal distinguishes them that I did not find. That is U8/graph-executor territory; I am not asserting a `visitedNodeIds` shape I cannot explain. ## Still open and green - **#2637** — `task-delete-notice` 21 failed → 34 passed. - **#2643** — shellout allowlist re-pin. **Merge early:** it re-drifts whenever `executor.ts`/`self-healing.ts` line counts shift, with no git conflict to warn you. It already drifted once while open (`executor.ts:17106 → 17198`) and I re-pinned it. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import "./executor-test-helpers.js";
|
|
import { TaskExecutor } from "../executor.js";
|
|
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
|
|
import {
|
|
createMockStore,
|
|
mockedCreateFnAgent,
|
|
mockedExecSync,
|
|
mockedExistsSync,
|
|
resetExecutorMocks,
|
|
} from "./executor-test-helpers.js";
|
|
|
|
const mockedReviewStep = vi.mocked(mockedReviewStepFn);
|
|
|
|
describe("executor tool step numbering is 0-based", () => {
|
|
beforeEach(() => {
|
|
resetExecutorMocks();
|
|
mockedExistsSync.mockReturnValue(true);
|
|
});
|
|
|
|
async function captureTools(stepStates = [
|
|
{ name: "Preflight", status: "pending" },
|
|
{ name: "First", status: "pending" },
|
|
{ name: "Second", status: "pending" },
|
|
]) {
|
|
const store = createMockStore();
|
|
store.getTask.mockImplementation(async () => ({
|
|
id: "FN-6607-T",
|
|
title: "Zero based steps",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: stepStates.map((step) => ({ ...step })),
|
|
currentStep: 0,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: First\n### Step 2: Second",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
}));
|
|
store.updateStep.mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
|
stepStates[stepIndex].status = status;
|
|
return { steps: stepStates.map((step) => ({ ...step })) };
|
|
});
|
|
|
|
let customTools: any[] = [];
|
|
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
|
customTools = opts.customTools || [];
|
|
return {
|
|
session: {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
navigateTree: vi.fn(),
|
|
sessionManager: {
|
|
getLeafId: vi.fn().mockReturnValue("leaf-step"),
|
|
branchWithSummary: vi.fn(),
|
|
},
|
|
state: {},
|
|
},
|
|
} as any;
|
|
});
|
|
|
|
const executor = new TaskExecutor(store, "/tmp/test");
|
|
await executor.execute({
|
|
id: "FN-6607-T",
|
|
title: "Zero based steps",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any);
|
|
|
|
const tools: Record<string, any> = {};
|
|
for (const tool of customTools) tools[tool.name] = tool.execute;
|
|
return { tools, store, stepStates };
|
|
}
|
|
|
|
it("resume recovery reads the same 0-based review log written by fn_review_step", async () => {
|
|
const store = createMockStore();
|
|
store.getTask.mockResolvedValue({
|
|
id: "FN-6607-R",
|
|
title: "Resume",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "First", status: "in-progress" },
|
|
{ name: "Second", status: "pending" },
|
|
],
|
|
currentStep: 1,
|
|
log: [
|
|
{ timestamp: "2026-06-17T00:00:00.000Z", action: "Step 1 (First) → in-progress" },
|
|
{ timestamp: "2026-06-17T00:00:01.000Z", action: "code review Step 1: APPROVE" },
|
|
],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any);
|
|
|
|
const executor = new TaskExecutor(store as any, "/tmp/test");
|
|
await (executor as any).recoverApprovedStepsOnResume("FN-6607-R");
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-6607-R", 1, "done");
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-6607-R",
|
|
expect.stringContaining("Step 1 (First) recovered as done on resume"),
|
|
);
|
|
});
|
|
|
|
it("does not reconcile reopened steps from older complete-step commits", async () => {
|
|
const store = createMockStore();
|
|
const detail = {
|
|
id: "FN-7273",
|
|
title: "Reopened suffix",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
baseCommitSha: "base",
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Implementation", status: "done" },
|
|
{ name: "Testing", status: "pending" },
|
|
],
|
|
currentStep: 2,
|
|
log: [
|
|
{ timestamp: "2026-06-30T14:59:30.110Z", action: "Step 2 (Testing) → pending" },
|
|
],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: Implementation\n### Step 2: Testing",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any;
|
|
store.getTask.mockResolvedValue(detail);
|
|
mockedExecSync.mockImplementation((cmd: string) => {
|
|
if (cmd.includes("git log")) {
|
|
return "1782831500\tfeat(FN-7273): complete Step 2 — old verification\n";
|
|
}
|
|
return "";
|
|
});
|
|
|
|
const executor = new TaskExecutor(store as any, "/tmp/test");
|
|
await (executor as any).reconcileStepsFromGitHistory("FN-7273", detail, "/tmp/wt");
|
|
|
|
expect(store.updateStep).not.toHaveBeenCalled();
|
|
expect(store.logEntry).not.toHaveBeenCalledWith(
|
|
"FN-7273",
|
|
expect.stringContaining("Reconciled Step 2 as done from git history"),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("does not log git-history reconciliation when TaskStore rejects the done write", async () => {
|
|
const store = createMockStore();
|
|
const detail = {
|
|
id: "FN-7273",
|
|
title: "Out of order reconciliation",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
baseCommitSha: "base",
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Fix", status: "in-progress" },
|
|
{ name: "Delivery", status: "pending" },
|
|
],
|
|
currentStep: 1,
|
|
log: [],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: Fix\n### Step 2: Delivery",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any;
|
|
store.getTask.mockResolvedValue(detail);
|
|
store.updateStep.mockResolvedValue({
|
|
...detail,
|
|
steps: [
|
|
{ name: "Preflight", status: "done" },
|
|
{ name: "Fix", status: "in-progress" },
|
|
{ name: "Delivery", status: "pending" },
|
|
],
|
|
} as any);
|
|
mockedExecSync.mockImplementation((cmd: string) => {
|
|
if (cmd.includes("git log")) {
|
|
return "1782832000\tfeat(FN-7273): complete Step 2 — old delivery\n";
|
|
}
|
|
return "";
|
|
});
|
|
|
|
const executor = new TaskExecutor(store as any, "/tmp/test");
|
|
await (executor as any).reconcileStepsFromGitHistory("FN-7273", detail, "/tmp/wt");
|
|
|
|
expect(store.updateStep).toHaveBeenCalledWith("FN-7273", 2, "done");
|
|
expect(store.logEntry).not.toHaveBeenCalledWith(
|
|
"FN-7273",
|
|
expect.stringContaining("Reconciled Step 2 as done from git history"),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("pending-review loop detection matches 0-based writer strings", async () => {
|
|
const store = createMockStore();
|
|
const task = {
|
|
id: "FN-6607-P",
|
|
title: "Pending review",
|
|
description: "",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
taskDoneRetryCount: 2,
|
|
/*
|
|
FNXC:EngineTests 2026-07-19-16:50 (U10b):
|
|
The invariant under test lives in the IMPLEMENTATION session's no-fn_task_done retry loop:
|
|
a step blocked on a pending review must skip the retry and park in review. Declaring no
|
|
pre-merge gates keeps the graph's optional review nodes out of the fixture so the pending
|
|
review being detected is the one this test seeded in `log`.
|
|
*/
|
|
enabledWorkflowSteps: [],
|
|
/*
|
|
FNXC:EngineTests 2026-07-19-17:05 (U10b):
|
|
The pending review is seeded against the step the graph is actually executing — its first
|
|
step — because the graph re-parses PROMPT.md into the step list on entry and starts at the
|
|
first non-terminal step. "Step 0" remains the discriminator this test exists for: only a
|
|
0-based writer ever emits it, so a 1-based regression breaks the match.
|
|
*/
|
|
steps: [
|
|
{ name: "Preflight", status: "in-progress" },
|
|
{ name: "First", status: "pending" },
|
|
],
|
|
currentStep: 0,
|
|
log: [{ timestamp: new Date().toISOString(), action: "code review requested for Step 0 (Preflight)" }],
|
|
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: First",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as any;
|
|
store.getTask.mockResolvedValue(task);
|
|
/*
|
|
FNXC:EngineTests 2026-07-19-16:55 (U10b):
|
|
PROMPT.md is the step source of record: the graph parses it into the task's step list before
|
|
the implementation session, so the artifact must describe the SAME two steps the fixture
|
|
seeded. With the harness's default single-step artifact the parse collapses the list to one
|
|
step and the pending-review step this test is about ceases to exist.
|
|
*/
|
|
store.getTaskDocument.mockImplementation(async (_taskId: string, key: string) =>
|
|
key === "PROMPT.md" ? { content: task.prompt } : undefined,
|
|
);
|
|
/*
|
|
FNXC:EngineTests 2026-07-23-21:40:
|
|
The graph's `parse` node writes every re-derived step back as `pending`, so the fixture's
|
|
seeded `in-progress` step no longer survives to `detectPendingReviewBlock`. The
|
|
pending-review shape can only arise from the implementation session itself: the agent
|
|
starts Step 0, requests review, and exits without fn_task_done. Simulate that by having
|
|
the session mark Step 0 `in-progress` (the 0-based review-request log line stays the
|
|
discriminator this test exists for).
|
|
*/
|
|
mockedCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
prompt: vi.fn(async () => {
|
|
store._setRow("FN-6607-P", {
|
|
steps: [
|
|
{ name: "Preflight", status: "in-progress" },
|
|
{ name: "First", status: "pending" },
|
|
],
|
|
});
|
|
}),
|
|
dispose: vi.fn(),
|
|
subscribe: vi.fn(),
|
|
on: vi.fn(),
|
|
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
|
state: {},
|
|
},
|
|
}) as any);
|
|
|
|
const executor = new TaskExecutor(store as any, "/tmp/test");
|
|
await executor.execute(task);
|
|
|
|
expect(store.logEntry).toHaveBeenCalledWith(
|
|
"FN-6607-P",
|
|
expect.stringContaining("Step 0 is blocked on pending review"),
|
|
undefined,
|
|
expect.objectContaining({ agentId: "executor" }),
|
|
);
|
|
/*
|
|
FNXC:ReviewHandoff 2026-07-30-11:00 (#2646 review — greptile P2):
|
|
ISOLATE the handoff call instead of matching any of them. This flow records TWO
|
|
moveTask calls, so `toHaveBeenCalledWith(id, "in-review", expect.anything())` is
|
|
satisfied by the workflow-boundary move even if the review handoff itself regresses —
|
|
the review was right, and it is the same objection I had already raised against my own
|
|
first attempt without then fixing it properly.
|
|
|
|
The handoff call is identifiable by its own provenance marker
|
|
(`workflowMoveMetadata.reason === "workflow-review-handoff"`, set at
|
|
workflow-node-handlers.ts's `review-handoff` seam), so select THAT call and assert its
|
|
target column. Now a regression has nowhere to hide: drop the handoff and no such call
|
|
exists; retarget it and the column assertion fails.
|
|
|
|
Attribution verified by mutation, which the previous version could not manage —
|
|
changing the seam's `reason` and changing its target column each fail this test.
|
|
*/
|
|
const handoffCalls = (store.moveTask as ReturnType<typeof vi.fn>).mock.calls.filter(
|
|
(call: unknown[]) =>
|
|
(call[2] as { workflowMoveMetadata?: { reason?: string } } | undefined)
|
|
?.workflowMoveMetadata?.reason === "workflow-review-handoff",
|
|
);
|
|
expect(handoffCalls).toHaveLength(1);
|
|
expect(handoffCalls[0]?.[0]).toBe("FN-6607-P");
|
|
expect(handoffCalls[0]?.[1]).toBe("in-review");
|
|
});
|
|
});
|