fix(ci,tests): repair binary release pipeline and re-green the full suite

Binary Release (v0.73.0-beta.5 was fully red):
- bun compile: mark chromium-bidi external — playwright-core@1.60 (feature-video)
  optionally requires it and bun fails closed on unresolvable requires.
- Windows desktop EXE: quote -c.publish.channel=beta in release.yml; PowerShell
  tokenizes the bare flag into `-c` + a path and electron-builder ENOENTs on it.

Full suite (all 4 shards red from stale-test drift, no product bugs found):
- engine: align mock stores/assertions with atomic store.moveTaskIf dispatch
  (#2371), the fail-closed non-empty PROMPT.md artifact gate (#2390), oldest-
  first admission (FN-8453), alreadyClaimed graph routing (#2393), startStep
  step projection (#2403/FN-8464), structured retry presentation (FN-8503),
  provider-lane pause reasons (#2339), typed column-boundary entry (#2378),
  Type.Integer in CAS document schemas (#2375), bounded model-registry refresh.
- engine-no-blocking-shellout: re-pin 17 drifted allowlist line numbers and drop
  the stale REBASE_HEAD entry whose execSync was removed.
- core: schema-applier expectations track migrations 0033-0035 (96 tables) and
  the synthetic 0000 fixture gains workflow_work_items/mission_contract_assertions;
  work-item terminal state is "succeeded" post-#2378.

Known follow-up (not addressed here): self-healing starved-refinement escalation
bumps task.priority, which FN-8453 oldest-first admission no longer consults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 21:43:00 -07:00
parent 336b0cf95d
commit b007de5f94
28 changed files with 443 additions and 119 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix broken beta binary builds — bun executables and the Windows desktop EXE package again.
category: fix
dev: bun compile marks `chromium-bidi` external (optional playwright-core BiDi require); release.yml quotes `-c.publish.channel=beta` so PowerShell stops splitting it into a config-file path.

View File

@@ -197,7 +197,10 @@ jobs:
# FNXC:UpdateChannels 2026-07-19-13:30: beta tags (v*-beta.N) build with
# publish.channel=beta so electron-builder emits beta*.yml update manifests;
# beta-channel desktop installs read those, stable installs keep latest*.yml.
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --publish never ${{ contains(github.ref_name, '-beta') && '-c.publish.channel=beta' || '' }}
# FNXC:UpdateChannels 2026-07-23-21:35: the flag must be quoted on Windows —
# pwsh tokenizes bare `-c.publish.channel=beta` into `-c` + `.publish.channel=beta`,
# which electron-builder then reads as a config FILE path (ENOENT, v0.73.0-beta.5).
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --win --publish never ${{ contains(github.ref_name, '-beta') && '"-c.publish.channel=beta"' || '' }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -519,6 +519,18 @@ function compileBinary(outFile: string, target: string, isCrossCompile: boolean)
// cpu-features: native .node binding from ssh2 (transitive via dockerode); ssh2 falls back to pure JS when unavailable
"--external",
"cpu-features",
/*
FNXC:StandaloneExeBuild 2026-07-23-21:30:
playwright-core (feature-video review artifacts) optionally requires chromium-bidi
inside its coreBundle for BiDi transport. chromium-bidi is not a dependency of this
workspace, so Bun's compile-time resolution fails on those requires. Mark the whole
package external — playwright-core only reaches that require when a BiDi browser
channel is requested, which the feature-video pipeline never does (it uses CDP).
*/
"--external",
"chromium-bidi",
"--external",
"chromium-bidi/*",
],
cwd: workspaceRoot,
stdout: "inherit",

View File

@@ -76,6 +76,9 @@ import {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -640,7 +643,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
ctx = null;
});
it("creates all 93 project tables, 18 central tables, 1 archive table", async () => {
it("creates all 96 project tables, 18 central tables, 1 archive table", async () => {
ctx = await setupFreshDb();
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
// applySchemaBaseline now runs the plugin schema-init hooks by default,
@@ -659,9 +662,10 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
// + 1 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30)
// + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00)
// + 2 ideation_sessions/ideation_candidates (FNXC:Ideation 2026-07-18-13:25 / FN-8295)
// + 1 task_verification_requests + 1 durable symbol_locks table (FN-8305).
// + 1 task_verification_requests + 1 durable symbol_locks table (FN-8305)
// + 1 mission_lineage_stops (FNXC:MissionLineageBudget FN-8543 / migration 0035).
// Plugin tables are added separately by the hook.
expect(bySchema.project).toBe(95);
expect(bySchema.project).toBe(96);
expect(bySchema.central).toBe(18);
expect(bySchema.archive).toBe(1);
});
@@ -1502,6 +1506,35 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
CREATE TABLE project.missions (id text PRIMARY KEY);
/* slice_id required before 0023 research provenance unique index can attach. */
CREATE TABLE project.mission_features (id text PRIMARY KEY, slice_id text);
/*
FNXC:MissionValidation 2026-07-23-21:30:
Migration 0034 (FN-8542) ALTERs project.mission_contract_assertions and builds
the derived-milestone partial unique index on (project_id, milestone_id).
Real 0000 databases have the table (baseline since the PG cutover), so this
historical fixture must retain milestone_id; project_id arrives via 0006.
*/
CREATE TABLE project.mission_contract_assertions (id text PRIMARY KEY, milestone_id text);
/*
FNXC:WorkflowContinuations 2026-07-23-21:30:
Migration 0031 (#2378) ALTERs project.workflow_work_items and rebuilds its
single-active-continuation index. Real 0000 databases have the table (it has
been in 0000_initial.sql since the PG cutover), so this historical fixture
must retain the column surface 0031 reads: task_id/kind/state for the ranked
retirement UPDATE plus lease and updated_at bookkeeping. project_id is added
by the 0006 ownership migration before 0031 runs.
*/
CREATE TABLE project.workflow_work_items (
id text PRIMARY KEY,
task_id text,
run_id text,
node_id text,
kind text,
state text,
attempt integer,
lease_owner text,
lease_expires_at text,
updated_at text
);
CREATE TABLE project.automations (
id text PRIMARY KEY,
name text NOT NULL,
@@ -1594,6 +1627,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1652,6 +1688,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
]);
});
@@ -1843,6 +1882,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
]);
});
@@ -1915,6 +1957,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
]);
});
@@ -1987,6 +2032,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_VERSION,
TASK_WEDGE_NOTIFICATION_VERSION,
MILESTONE_ASSERTION_PROVENANCE_VERSION,
MISSION_LINEAGE_STOP_VERSION,
]);
});
});

View File

@@ -778,11 +778,12 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
const running = await transitionWorkflowWorkItem(ctx.layer, item.id, "running");
expect(running.state).toBe("running");
// Transition to 'completed' (terminal).
const completed = await transitionWorkflowWorkItem(ctx.layer, item.id, "completed");
expect(completed.state).toBe("completed");
// Transition to 'succeeded' (terminal). #2378 renamed the terminal
// completion state from 'completed' to 'succeeded' (WORKFLOW_WORK_ITEM_STATES).
const completed = await transitionWorkflowWorkItem(ctx.layer, item.id, "succeeded");
expect(completed.state).toBe("succeeded");
// Terminal guard: cannot requeue a completed item.
// Terminal guard: cannot requeue a succeeded item.
await expect(
transitionWorkflowWorkItem(ctx.layer, item.id, "runnable"),
).rejects.toThrow(/terminal/);

View File

@@ -38,10 +38,10 @@ const allowlist: AllowlistEntry[] = [
{ file: "src/already-merged-detector.ts", line: 270, primitive: "execSync", signature: "branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/already-merged-detector.ts", line: 345, primitive: "execSync", signature: "execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {", reason: SHORT_GIT_PLUMBING },
// FNXC:EngineProcessRules 2026-07-22-17:30: refreshed drifted line numbers for these unchanged git-plumbing call-sites (self-healing.ts 4187/4193/4230/12705, executor.ts 15808) after FN-8490 + graph-owned-cutover follow-ups shifted lines above them. The call-site-level allowlist keys on file:line:signature, so a legitimate call moving lines reads as unmatched-site + stale-entry until the line is re-pinned here.
{ file: "src/self-healing.ts", line: 4198, primitive: "execSync", signature: "const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 4204, primitive: "execSync", signature: "const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote(\"main\")}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 4241, primitive: "execSync", signature: "const branchesRaw = String(execSync(\"git branch --list 'fusion/*'\", {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 12822, primitive: "execSync", signature: "execSync(`git branch -d ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 4215, primitive: "execSync", signature: "const tipSha = String(execSync(`git rev-parse --verify ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 4221, primitive: "execSync", signature: "const uniqueCommitCount = Number.parseInt(String(execSync(`git rev-list --count ${shellQuote(branch)} --not ${shellQuote(\"main\")}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 4258, primitive: "execSync", signature: "const branchesRaw = String(execSync(\"git branch --list 'fusion/*'\", {", reason: SHORT_GIT_PLUMBING },
{ file: "src/self-healing.ts", line: 12839, primitive: "execSync", signature: "execSync(`git branch -d ${shellQuote(branch)}`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger-workspace-test-commands.ts", line: 204, primitive: "execSync", signature: "changedFilesOutput = execSync(", reason: BOUNDED_GIT_DIFF },
{ file: "src/merger-workspace-test-commands.ts", line: 301, primitive: "execSync", signature: "changedFilesOutput = execSync(", reason: BOUNDED_GIT_DIFF },
{ file: "src/integration-branch.ts", line: 71, primitive: "execSync", signature: "const stdout = execSync(\"git symbolic-ref --short refs/remotes/origin/HEAD\", {", reason: SHORT_GIT_PLUMBING },
@@ -51,20 +51,19 @@ const allowlist: AllowlistEntry[] = [
{ file: "src/merger.ts", line: 1388, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 1600, primitive: "execSync", signature: "beforeRaw = execSync(\"git status -z --porcelain\", { cwd: rootDir, stdio: [\"ignore\", \"pipe\", \"ignore\"] }).toString(\"utf-8\");", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 1612, primitive: "execSync", signature: "afterRaw = execSync(\"git status -z --porcelain\", { cwd: rootDir, stdio: [\"ignore\", \"pipe\", \"ignore\"] }).toString(\"utf-8\");", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 5754, primitive: "execSync", signature: "execSync(\"git rev-parse --verify REBASE_HEAD\", {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 7618, primitive: "execSync", signature: "execSync(`git rev-parse --verify \"${branch}\"`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8573, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8586, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8598, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8936, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8956, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8965, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 9055, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 9630, primitive: "execSync", signature: "const postPushSha = execSync(\"git rev-parse HEAD\", {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10189, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10223, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10410, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/executor.ts", line: 16262, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 7640, primitive: "execSync", signature: "execSync(`git rev-parse --verify \"${branch}\"`, {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8595, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8608, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8620, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8958, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8978, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 8987, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 9077, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 9652, primitive: "execSync", signature: "const postPushSha = execSync(\"git rev-parse HEAD\", {", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10211, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10245, primitive: "execSync", signature: "const squashIsEmpty = execSync(", reason: SHORT_GIT_PLUMBING },
{ file: "src/merger.ts", line: 10432, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", reason: SHORT_GIT_PLUMBING },
{ file: "src/executor.ts", line: 16296, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {", reason: SHORT_GIT_PLUMBING },
];
function scanSource(file: string, source: string): ShelloutSite[] {

View File

@@ -75,6 +75,12 @@ function prepareStore(child: TaskDetail, dependencies: TaskDetail[], shadowEnabl
return store;
}
/*
FNXC:EngineTests 2026-07-23-21:25:
executeCore now claims graphRouting before any await and passes `{ alreadyClaimed: true }`
into `executeWorkflowGraph` (FN-8471 overseer-thrash fix, commit 6422cb93a). The "allows"
assertions below match that second positional argument; the gated contract is unchanged.
*/
function spyOuterDispatch(executor: TaskExecutor) {
const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined);
return { graph };
@@ -82,6 +88,14 @@ function spyOuterDispatch(executor: TaskExecutor) {
afterEach(() => {
clearPreHeldExecutorSlotsForTests();
/*
FNXC:EngineTests 2026-07-23-21:25:
executeCore claims the process-wide graphRouting set before calling executeWorkflowGraph
(FN-8471 fix, commit 6422cb93a). With executeWorkflowGraph mocked, its real `finally` never
releases the claim, so the shared FN-CHILD id would leak across tests and later dispatches
would drop as duplicates. Clear the static set between tests (precedent: executor-prompt.test.ts).
*/
(TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting.clear();
});
describe("executor outer dispatch dependency gate", () => {
@@ -154,7 +168,7 @@ describe("executor outer dispatch dependency gate", () => {
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(child.id, expect.objectContaining({ status: "queued" }), undefined);
expect(graph).toHaveBeenCalledWith(child);
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
});
it("allows missing or soft-deleted dependency residue past the outer gate", async () => {
@@ -167,7 +181,7 @@ describe("executor outer dispatch dependency gate", () => {
await executor.execute(child);
expect(store.moveTask).not.toHaveBeenCalled();
expect(graph).toHaveBeenCalledWith(child);
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
});
it("observes an accepted marker in shadow mode without letting it unblock a live dependency", async () => {
@@ -203,7 +217,7 @@ describe("executor outer dispatch dependency gate", () => {
expect(store.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith(parent.id);
expect(store.moveTask).not.toHaveBeenCalled();
expect(graph).toHaveBeenCalledWith(child);
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
});
it.each([

View File

@@ -798,11 +798,17 @@ describe("TaskExecutor pause behavior", () => {
// Should move to todo, NOT mark as failed.
// FNXC:ExecutorMoveTaskOptions 2026-07-12: executor.ts:11622-11625 now always passes a moveTask options object built from conditional spreads.
/*
FNXC:EngineTests 2026-07-19-03:12 (U10b):
A pause-abort bounce to todo must never discard progress the run already recorded.
Under graph-owned execution the workflow materializes its steps and marks the running one `in-progress` before the implementation session, so every aborted run has resumable progress and the bounce must carry `preserveResumeState` — the pre-graph "empty steps => discard worktree+branch" shape is unreachable.
FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A pause-abort bounce to todo preserves resume state ONLY when the run recorded resumable
progress (currentStep > 0 or a step marked done/in-progress). A FRESH task's first
implementation pass now OWNS the step projection: `runProjectedGraphTaskStep` defers the
atomic `startStep` in-progress write until the task has a real worktree (FN-8464 baseline
cwd gating) and #2403 routed step starts through the dependency-gated `store.startStep`.
A pause landing during that first session therefore finds every step still `pending`,
so the bounce carries no `preserveResumeState` — the conditional spreads collapse to `{}`.
The protective intent is unchanged: pause parks in todo and never marks the task failed.
*/
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
@@ -2296,11 +2302,14 @@ describe("TaskExecutor global pause behavior", () => {
// FNXC:ExecutorMoveTaskOptions 2026-07-12: executor.ts:11622-11625 now always passes a moveTask options object (conditional spreads collapse to {} when nothing to preserve); previously undefined. Intent (not marked failed) unchanged.
/*
FNXC:EngineTests 2026-07-19-03:14 (U10b):
A global-pause abort must park the task in todo without failing it AND without throwing away the progress the run already recorded.
Graph-owned execution always has a materialized in-progress workflow step by the time the pause lands, so the bounce carries `preserveResumeState`.
FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A global-pause abort must park the task in todo without failing it. Resume state is
preserved only when the run recorded resumable progress; a fresh task's first
implementation pass owns the step projection (startStep is deferred until a real
worktree exists), so a pause during that first session leaves all steps `pending`
and the bounce options collapse to `{}`.
*/
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", {});
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
@@ -2408,31 +2417,34 @@ describe("TaskExecutor global pause behavior", () => {
const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(todoTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion.
bulkCompletionRefusalAt: null,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
User-paused dispatch stops: a paused todo task is no longer dispatched at all —
execute() ends the graph run benignly with the row still parked and paused, so no
agent session exists and `fn_task_done` is unreachable from this shape. The
protective intent survives on the surfaces that remain: the card is never handed to
`in-review` under global pause, no completion watchdog is armed, the pause is never
cleared by the refused dispatch, and the run narrates the benign paused park.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(taskDoneResult).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.anything(),
);
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("fn_task_done called while task was in todo during pause"),
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(true);
/*
FNXC:EngineTests 2026-07-19-05:12 (U10b):
Deleted assertion: the executor's own "Completion handoff deferred — global pause active" log line.
That line belongs to the pre-graph completion path, which the graph short-circuits at the implementation-complete boundary (`graphCompletion`) before any executor-side defer check runs — the executor no longer owns the handoff, so it no longer narrates deferring it.
The REQUIREMENT it stood for is still asserted here, on the surfaces that survive: the card is never handed to `in-review` under global pause, no completion watchdog is armed, and `fn_task_done` tells the agent the handoff is deferred until the pause clears.
*/
expect(taskDoneResult.content[0].text).toBe(
"Task marked complete. Completion handoff deferred until pause is cleared.",
);
});
describe("fn_task_done with paused state (FN-3964 / FN-4167 regression)", () => {
@@ -2487,31 +2499,35 @@ describe("TaskExecutor global pause behavior", () => {
await executor.execute(todoTask as any);
// FN-4145: explicit agent completion always clears task-level pause state.
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion.
bulkCompletionRefusalAt: null,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith(
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
User-paused dispatch stops supersede the FN-3964/FN-4167 shape for ALREADY-paused
todo rows: execute() no longer dispatches a paused task, so no agent session is
created and `fn_task_done` cannot fire from this shape. Explicit-completion pause
clearing (FN-4145) still holds for a pause that lands MID-session — covered by
"completes in-progress + paused tasks after clearing task-level pause state".
Here the row must stay parked and paused: no in-review handoff, no watchdog, no
pause clear, and the run narrates the benign paused park.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(taskDoneResult).toBeUndefined();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(false);
expect(taskDoneResult.content[0].text).toBe(
"Task marked complete with summary. All steps done. Moving to in-review.",
);
// globalPause:true deferred behavior is intentionally covered by the test above.
).toBe(true);
// globalPause:true refused-dispatch behavior is intentionally covered by the test above.
});
it("completes in-progress + paused tasks after clearing task-level pause state", async () => {
@@ -2644,27 +2660,30 @@ describe("TaskExecutor global pause behavior", () => {
await executor.execute(todoTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion.
bulkCompletionRefusalAt: null,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
expect(store.moveTask).toHaveBeenCalledWith(
/*
FNXC:EngineTests 2026-07-23-21:40 (#2371):
Same paused-dispatch-stop contract as the sibling describe: an already-paused todo
row is never dispatched, `fn_task_done` is unreachable, the pause is preserved, and
the run parks benignly in todo.
*/
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
);
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect(
store.logEntry.mock.calls.some(
([id, action]: [string, string]) =>
id === "FN-001" && action.includes("Completion handoff deferred — global pause active"),
id === "FN-001" && action.includes("parked in todo — benign, paused awaiting explicit unpause"),
),
).toBe(false);
// globalPause:true deferred behavior is intentionally covered by the test above.
).toBe(true);
// globalPause:true refused-dispatch behavior is intentionally covered by the test above.
});
it("completes in-progress + paused tasks after clearing task-level pause state", async () => {

View File

@@ -247,16 +247,32 @@ describe("executor tool step numbering is 0-based", () => {
store.getTaskDocument.mockImplementation(async (_taskId: string, key: string) =>
key === "PROMPT.md" ? { content: task.prompt } : undefined,
);
mockedCreateFnAgent.mockResolvedValue({
/*
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().mockResolvedValue(undefined),
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);
}) as any);
const executor = new TaskExecutor(store as any, "/tmp/test");
await executor.execute(task);

View File

@@ -433,15 +433,26 @@ describe("Workflow Steps Execution", () => {
};
store.getTask.mockResolvedValue(baseTask as any);
mockedCreateFnAgent.mockResolvedValue({
/*
FNXC:EngineTests 2026-07-23-21:40:
The graph's `parse` node re-derives the step list from PROMPT.md and writes every step
back as `pending`, so an `in-progress` step on the fixture literal no longer survives to
`detectPendingReviewBlock`. The pending-review shape can only arise from the
implementation session itself: the agent starts the step, requests review, and exits
without fn_task_done. Simulate that by having the session mark the parsed step
`in-progress` (the review-request log line is already on the row).
*/
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn(async () => {
store._setRow("FN-5436-B", { steps: [{ name: "Implement", status: "in-progress" }] });
}),
dispose: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
} as any);
}) as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(baseTask as any);
@@ -774,13 +785,16 @@ describe("Workflow Steps Execution", () => {
// (3) PROMPT.md injection was invoked with the failure context. The
// actual file write is covered by other tests; here we just need to
// confirm sendTaskBackForFix forwards the right step name and feedback.
// Last arg is MAX_WORKFLOW_STEP_RETRIES (private const, currently 3) so
// the injected PROMPT.md note shows "3/3 (0 remaining)".
// FNXC:EngineTests 2026-07-23-21:40 (FN-8503): the retry arg is now a
// structured `{ attempt, max? }` presentation (max omitted = unbounded
// Code Review budget). A hard-failure exhaustion passes the bounded
// MAX_WORKFLOW_STEP_RETRIES budget (currently 3), so the injected
// PROMPT.md note shows "3/3 (0 remaining)".
expect(injectSpy).toHaveBeenCalledWith(
mutableTask,
feedback,
stepName,
expect.any(Number),
{ attempt: 3, max: 3 },
);
// The scheduleWorkflowRerun stub above never registers the 15 s

View File

@@ -184,15 +184,25 @@ describe("FN-4114 worktree liveness assertion", () => {
mockedCreateFnAgent.mockReset();
mockCompletingAgent();
store.moveTask.mockReset();
store.moveTask.mockResolvedValue({});
store.getTask.mockResolvedValue(task({ worktree: allowedWorktree }));
/*
FNXC:ExecutorTests 2026-07-23-21:20:
The accept phase must model a task that is genuinely live in-progress with the allowed
worktree. The helper store is write-through (updateTask/moveTask patches are readable back),
so reusing the reject phase's store leaks its rebound residue — column "todo", worktree
null — into the graph's live-row re-reads, and the graph now ends the run benignly when the
live row has already left in-progress instead of dispatching the agent (graph-owned
lifecycle cutover, #2342 line). A fresh store per phase keeps the ONLY variable under test
the worktree path itself.
*/
const acceptStore = createMockStore();
acceptStore.getSettings.mockResolvedValue(mergedSettings);
acceptStore.getTask.mockResolvedValue(task({ worktree: allowedWorktree }));
const acceptExecutor = new TaskExecutor(store as any, "/repo");
const acceptExecutor = new TaskExecutor(acceptStore as any, "/repo");
await acceptExecutor.execute(task({ worktree: allowedWorktree }) as any);
expect(mockedCreateFnAgent).toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
expect(acceptStore.moveTask).not.toHaveBeenCalledWith("FN-4114", "todo", { preserveProgress: true });
});
it("FN-4114 accepts usable pool-acquired worktrees", async () => {

View File

@@ -598,8 +598,11 @@ describe("aiMergeTask — usage limit detection", () => {
"rate_limit_error: Rate limit exceeded",
undefined,
);
// FNXC:EngineTests 2026-07-23-21:40 (#2339): rate-limit pauses are provider-lane
// scoped ("provider-rate-limit:<providerId>") so one saturated provider does not
// park other lanes; no runtime provider is resolvable here, hence ":unknown".
expect(store.pauseTask).toHaveBeenCalledWith("FN-050", true, undefined, {
pausedReason: "provider-rate-limit",
pausedReason: "provider-rate-limit:unknown",
});
expect(store.updateSettings).not.toHaveBeenCalled();
});

View File

@@ -158,7 +158,15 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
this until FN-8142's SDK bump (this PR); mock ModelRuntime so createFnAgent's registry path resolves.
*/
ModelRuntime: {
create: async () => ({ getAuth: modelRuntimeGetAuthMock }),
/*
FNXC:ModelRegistry 2026-07-23-21:20:
396090fc0 bounded post-registration registry refreshes via refreshFusionModelRegistry, which
PREFERS `modelRegistry.modelRuntime.refresh({ allowNetwork, signal })` over the legacy
`registry.refresh()` whenever a runtime is attached. The mocked runtime must expose `refresh`
(delegating to the same refreshMock) or the preferred path throws "runtime.refresh is not a
function" and the registration-order test can no longer observe the refresh.
*/
create: async () => ({ getAuth: modelRuntimeGetAuthMock, refresh: async () => refreshMock() }),
},
ModelRegistry: class {
static create(...args: unknown[]) {

View File

@@ -16,6 +16,17 @@ vi.mock("../agent-session-helpers.js", () => ({
provider: "mock-provider",
modelId: "mock-model",
}),
// FN-7794 fallback-swap resolver called unconditionally on the validator hot path; mirror
// production's validatorFallback -> fallback -> validator -> task precedence (see executor-test-helpers.ts).
resolveValidatorFallbackThinkingLevel: vi.fn(
(taskThinkingLevel: string | undefined, settings: Record<string, unknown> | undefined) =>
(typeof settings?.validatorFallbackThinkingLevel === "string" ? settings.validatorFallbackThinkingLevel : undefined)
?? (typeof settings?.fallbackThinkingLevel === "string" ? settings.fallbackThinkingLevel : undefined)
?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined)
?? taskThinkingLevel
?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined)
?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined),
),
}));
import { reviewStep } from "../reviewer.js";

View File

@@ -43,6 +43,14 @@ describe("ProjectEngine.stopOverseerTask session advisor cleanup", () => {
sessionAdvisorLogCursor: cursor,
plannerObservationEmitDedup: new Map(),
plannerEscalationEmitDedup: new Set(),
/*
FNXC:PlannerOversight 2026-07-23-21:20:
6422cb93a (#2393) made stopOverseerTask also release the live-retry skip-log dedup keys
via clearPlannerLiveRetrySkipLogDedup. This harness builds the engine with
Object.create(prototype), which skips class-field initializers, so the Set must be
supplied or the stop path throws and degrades to { applied:false, reason:"error" }.
*/
plannerLiveRetrySkipLogDedup: new Set(),
});
const result = await engine.stopOverseerTask(task.id);

View File

@@ -5,6 +5,8 @@ vi.mock("@earendil-works/pi-ai", () => ({
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
Number: (opts?: unknown) => ({ type: "number", ...((opts as object) ?? {}) }),
// FNXC:EngineTests 2026-07-23-21:20: f21d3ce13 (#2375) added Type.Integer to agent-tools document CAS schemas (expected_revision); partial Type mocks must cover it or these files fail at collect time.
Integer: (opts?: unknown) => ({ type: "integer", ...((opts as object) ?? {}) }),
Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
Optional: (schema: unknown) => schema,
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),

View File

@@ -131,6 +131,16 @@ describe("FN-4811 follow-up (FN-4814): concurrent execute() must not produce par
const executor = new TaskExecutor(store as any, "/tmp/test");
await executor.execute(makeTask());
const firstCount = mockedCreateFnAgent.mock.calls.length;
/*
FNXC:EngineTests 2026-07-23-21:40:
The first graph run ends in the no-fn_task_done requeue: the ROW is now parked in
`todo` awaiting the scheduler. A direct second execute() against a todo row is no
longer a valid dispatch shape — the graph re-requeues it without opening a session
(routeGraphFailureToExecutionResume). Emulate the scheduler's re-dispatch move
(todo → in-progress) before the second execute so this test keeps measuring what it
exists for: the in-memory executing slot was RELEASED and a sequential dispatch runs.
*/
store._setRow("FN-4814", { column: "in-progress" });
await executor.execute(makeTask());
const secondCount = mockedCreateFnAgent.mock.calls.length;

View File

@@ -114,6 +114,27 @@ describe("reliability interactions: FN-5436 executor pending-review skip", () =>
log: [{ action: "code review requested for Step 0 (Step 1)", timestamp: new Date().toISOString() }],
});
store.getTask.mockResolvedValue(task);
/*
FNXC:EngineTests 2026-07-23-21:40:
The graph's `parse` node re-derives the step list from PROMPT.md and writes every step
back as `pending` on each run, so an `in-progress` step on the fixture literal no longer
survives to `detectPendingReviewBlock`. The pending-review shape this test pins can only
arise from the implementation session itself: the agent starts the step, requests review,
and exits without fn_task_done. Simulate exactly that by having each session mark the
parsed step `in-progress` (the review-request log line is already on the row).
*/
mockedCreateFnAgent.mockImplementation(async () => ({
session: {
prompt: vi.fn(async () => {
store._setRow("FN-5436-RI-C", { steps: [{ name: "Preflight", status: "in-progress" }] });
}),
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, "/repo");
await executor.execute(task);

View File

@@ -44,6 +44,7 @@ function createMockTask(overrides: Partial<Task> = {}): Task {
}
function createMockStore(task: Task, settings: Record<string, unknown> = {}): TaskStore {
const moveTask = vi.fn().mockResolvedValue(undefined);
return {
listTasks: vi.fn().mockResolvedValue([task]),
getSettings: vi.fn().mockResolvedValue(settings),
@@ -54,7 +55,18 @@ function createMockStore(task: Task, settings: Record<string, unknown> = {}): Ta
updateSettings: vi.fn().mockResolvedValue(settings),
getTask: vi.fn().mockResolvedValue(task),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
moveTask,
/*
FNXC:EngineTests 2026-07-23-21:20:
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
*/
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
await moveTask(id, column, opts);
task.column = column;
return { task, moved: true };
}),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/test"),

View File

@@ -86,7 +86,26 @@ function createStore(task: Task, settingsOverrides: Record<string, unknown> = {}
(emitter as any).listWorkflowSteps = vi.fn().mockResolvedValue([]);
(emitter as any).getWorkflowStep = vi.fn().mockResolvedValue(undefined);
(emitter as any).setPluginWorkflowStepTemplates = vi.fn().mockResolvedValue(undefined);
(emitter as any).updateStep = vi.fn().mockResolvedValue(undefined);
(emitter as any).updateStep = vi.fn().mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
const steps = task.steps ?? [];
if (steps[stepIndex]) steps[stepIndex] = { ...steps[stepIndex], status } as any;
return task;
});
/*
FNXC:EngineTests 2026-07-23-21:40 (#2403):
Step starts now go through the atomic, dependency-gated `store.startStep` before any
step-session work (`runTaskStep`, step-runner.ts). A store without it throws at the
projection seam and the graph fails `steps#0:step-execute` before the session under
test ever runs. Mirror the production accept shape so these fixtures reach the
post-done continuation behavior they pin.
*/
(emitter as any).startStep = vi.fn().mockImplementation(async (_taskId: string, stepIndex: number) => {
const steps = task.steps ?? [];
if (steps[stepIndex] && steps[stepIndex].status === "pending") {
steps[stepIndex] = { ...steps[stepIndex], status: "in-progress" } as any;
}
return { task, accepted: true, disposition: "started" as const };
});
(emitter as any).parseStepsFromPrompt = vi.fn().mockResolvedValue([]);
(emitter as any).parseFileScopeFromPrompt = vi.fn().mockResolvedValue([]);
(emitter as any).getAgentLogs = vi.fn().mockResolvedValue([]);

View File

@@ -65,8 +65,17 @@ describe("reliability interaction: starved refinement x triage poll", () => {
});
(triage as any).running = true;
for (let i = 0; i < 2; i++) {
/*
FNXC:EngineTests 2026-07-23-21:30:
FN-8453 (commit eef5eb751) replaced priority-based triage ordering with the unified
oldest-createdAt-first admission coordinator, so the self-healing priority bump no
longer reorders admission. The surviving reliability invariant is FIFO fairness:
with maxConcurrent=1 and 6 older backlog tasks, the starved refinement must be
admitted within 7 bounded polls (one admission per poll).
*/
for (let i = 0; i < 7; i++) {
await (triage as any).poll();
if (tasks.find((t) => t.id === "FN-R1")?.column === "todo") break;
}
expect(specifySpy.mock.calls.some(([t]) => t.id === "FN-R1")).toBe(true);

View File

@@ -94,6 +94,14 @@ function makeSchedulerStore(rootDir: string, task: Task, settingsOverrides: Part
...settingsOverrides,
} as unknown as Settings;
const moveTask = vi.fn(async (_id: string, column: Task["column"], opts?: Record<string, unknown>) => {
const from = task.column;
task.column = column;
task.columnMovedAt = new Date(Date.now()).toISOString();
emitter.emit("task:moved", { task, from, to: column, source: (opts?.moveSource as "user" | "engine" | "scheduler" | undefined) ?? "engine" });
return task;
});
return Object.assign(emitter, {
getSettings: vi.fn(async () => settings),
/*
@@ -107,12 +115,17 @@ function makeSchedulerStore(rootDir: string, task: Task, settingsOverrides: Part
return task.column === column ? [task] : [];
}),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => Object.assign(task, updates)),
moveTask: vi.fn(async (_id: string, column: Task["column"], opts?: Record<string, unknown>) => {
const from = task.column;
task.column = column;
task.columnMovedAt = new Date(Date.now()).toISOString();
emitter.emit("task:moved", { task, from, to: column, source: (opts?.moveSource as "user" | "engine" | "scheduler" | undefined) ?? "engine" });
return task;
moveTask,
/*
FNXC:EngineTests 2026-07-23-21:20:
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch/settle-window assertions on `store.moveTask` stay meaningful.
*/
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
if (id !== task.id) return { task, moved: false };
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
const movedTask = await moveTask(id, column, opts);
return { task: movedTask ?? task, moved: true };
}),
logEntry: vi.fn(async () => undefined),
recordRunAuditEvent: vi.fn(async () => undefined),

View File

@@ -256,6 +256,8 @@ vi.mock("@earendil-works/pi-ai", () => ({
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
Number: (opts?: unknown) => ({ type: "number", ...((opts as object) ?? {}) }),
// FNXC:EngineTests 2026-07-23-21:20: f21d3ce13 (#2375) added Type.Integer to agent-tools document CAS schemas (expected_revision); partial Type mocks must cover it or these files fail at collect time.
Integer: (opts?: unknown) => ({ type: "integer", ...((opts as object) ?? {}) }),
Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
Optional: (schema: unknown) => schema,
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),
@@ -384,6 +386,26 @@ function createMockStore(overrides: Record<string, any> = {}) {
return { ...(patches.get(id) ?? {}), id };
}),
moveTask: makeWriteThroughMoveTask(),
/*
FNXC:EngineTests 2026-07-23-21:20:
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da #2371).
The fake evaluates the live-row predicate against the write-through `getTask` view and delegates to the mock
`moveTask` (forwarding the options bag) so existing dispatch assertions on `store.moveTask` — including the
`allocateWorktree` option — stay meaningful.
*/
moveTaskIf: vi.fn(
async (
id: string,
column: string,
predicate: (live: Task) => boolean | Promise<boolean>,
opts?: Record<string, unknown>,
) => {
const live = await store.getTask(id);
if (!live || !(await predicate(live))) return { task: live, moved: false };
const moved = await store.moveTask(id, column, opts);
return { task: moved ?? live, moved: true };
},
),
recordActivity: vi.fn().mockResolvedValue({}),
mergeTask: vi.fn().mockResolvedValue({}),
getWorkflowStep: vi.fn().mockResolvedValue(undefined),

View File

@@ -42,6 +42,7 @@ function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id">): Agent {
}
function createStore(task: Task, settings: Record<string, unknown>, tasksForList?: Task[]): TaskStore {
const moveTask = vi.fn().mockResolvedValue(undefined);
return {
listTasks: vi.fn().mockImplementation(async () => tasksForList ?? [task]),
getSettings: vi.fn().mockResolvedValue(settings),
@@ -52,7 +53,19 @@ function createStore(task: Task, settings: Record<string, unknown>, tasksForList
updateSettings: vi.fn().mockResolvedValue(settings),
getTask: vi.fn().mockResolvedValue(task),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
moveTask,
/*
FNXC:EngineTests 2026-07-23-21:20:
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
*/
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
const cur = (tasksForList ?? [task]).find((t) => t.id === id) ?? task;
if (!(await predicate(cur)) || cur.column === column) return { task: cur, moved: false };
await moveTask(id, column, opts);
cur.column = column;
return { task: cur, moved: true };
}),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"),

View File

@@ -46,6 +46,18 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>, settings:
if (task) task.column = column;
return task as Task;
});
/*
FNXC:EngineTests 2026-07-23-21:20:
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
*/
const moveTaskIf = vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
const task = tasks.find((candidate) => candidate.id === id);
if (!task) return { task: task as unknown as Task, moved: false };
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
const movedTask = await moveTask(id, column, opts);
return { task: movedTask ?? task, moved: true };
});
return {
listTasks: vi.fn(async () => tasks),
@@ -58,6 +70,7 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>, settings:
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
updateTask,
moveTask,
moveTaskIf,
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
logEntry: vi.fn(async () => undefined),
getRootDir: vi.fn(() => "/tmp/project"),

View File

@@ -93,7 +93,15 @@ describe("refinement routing from triage", () => {
});
(processor as any).running = true;
for (let i = 0; i < 3; i++) {
/*
FNXC:EngineTests 2026-07-23-21:30:
FN-8453 (commit eef5eb751) replaced priority-then-refinement triage ordering with the
unified oldest-createdAt-first admission coordinator. Refinements no longer jump the
same-priority backlog; the no-starvation invariant is now FIFO fairness — the newest
refinement behind an 8-task backlog at maxConcurrent=2 must be admitted within
ceil(9/2)=5 bounded polls.
*/
for (let i = 0; i < 5; i++) {
await (processor as any).poll();
if (tasks.find((t) => t.id === refinement.id)?.column === "todo") break;
}
@@ -281,11 +289,17 @@ describe("refinement routing from triage", () => {
(processor as any).running = true;
await (processor as any).poll();
/*
FNXC:EngineTests 2026-07-23-21:30:
FN-8453 (commit eef5eb751) removed priority ranking from triage admission: the baseline
ordering contract is now strictly oldest-createdAt-first (compareAdmissionCandidates),
so the oldest normal-priority task dispatches before newer urgent/high tasks.
*/
expect(specifySpy.mock.calls.map(([task]) => task.id)).toEqual([
"FN-100",
"FN-101",
"FN-103",
"FN-102",
"FN-100",
]);
});
});

View File

@@ -227,8 +227,11 @@ describe("KTD-3 IR pin wiring (U9b task-row persistence)", () => {
await expect(persistence.loadPriorPin()).resolves.toBeUndefined();
// Node entries through the boundary never throw with the degraded seam.
// FNXC:WorkflowIrPin 2026-07-23-21:20: 83209e64d (#2378) changed onNodeEntry to return a typed
// entry result ({ kind: "entered" } | { kind: "suspended", ... }); with the degraded pin seam a
// no-column-change entry still resolves "entered" rather than undefined.
const { boundary } = boundaryFor({ ir, store: bare });
await expect(boundary.onNodeEntry(nodeOf(ir, "execute"))).resolves.toBeUndefined();
await expect(boundary.onNodeEntry(nodeOf(ir, "execute"))).resolves.toEqual({ kind: "entered" });
// A row that predates the U9b fields (getTask works, fields absent) also
// yields no prior pin — the drift guard stays inert.

View File

@@ -135,7 +135,7 @@ describe("WorkflowTaskRuntime", () => {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null,
},
runCustomNode: async () => ({ outcome: "success" }),
};
@@ -304,7 +304,7 @@ describe("WorkflowTaskRuntime", () => {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null,
},
primitives: recordingPrimitives(calls, undefined, observed),
runCustomNode: async (node) => {
@@ -330,7 +330,7 @@ describe("WorkflowTaskRuntime", () => {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null,
},
primitives: recordingPrimitives([], undefined, observed),
runCustomNode: async () => ({ outcome: "success" }),
@@ -374,7 +374,7 @@ describe("WorkflowTaskRuntime", () => {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null,
},
primitives: recordingPrimitives(calls),
runCustomNode: async (node) => {
@@ -408,7 +408,7 @@ describe("WorkflowTaskRuntime", () => {
store: {
getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => undefined,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key } : null,
getTaskDocument: async (_taskId, key) => key === "PROMPT.md" ? { key, content: promptWithOneStep } : null,
},
primitives: recordingPrimitives(calls),
runCustomNode: async (node) => {