diff --git a/.changeset/binary-release-bidi-windows-channel.md b/.changeset/binary-release-bidi-windows-channel.md new file mode 100644 index 0000000000..44dda8f252 --- /dev/null +++ b/.changeset/binary-release-bidi-windows-channel.md @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 622d0994ce..7b2faf7f63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 965d120560..d302c2a788 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -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", diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 12b8b913cb..abdfda824d 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -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, ]); }); }); diff --git a/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts b/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts index 844c9f5201..812e2264b7 100644 --- a/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts +++ b/packages/core/src/__tests__/postgres/taskstore-remaining.test.ts @@ -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/); diff --git a/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts b/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts index b394b02f37..5b85e240e8 100644 --- a/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts +++ b/packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts @@ -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[] { diff --git a/packages/engine/src/__tests__/executor-outer-dispatch-dependency-gate.test.ts b/packages/engine/src/__tests__/executor-outer-dispatch-dependency-gate.test.ts index 6b31ce1c4c..31f19e34ad 100644 --- a/packages/engine/src/__tests__/executor-outer-dispatch-dependency-gate.test.ts +++ b/packages/engine/src/__tests__/executor-outer-dispatch-dependency-gate.test.ts @@ -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 }).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([ diff --git a/packages/engine/src/__tests__/executor-prompt.test.ts b/packages/engine/src/__tests__/executor-prompt.test.ts index ad90e69ce4..fcbffdde82 100644 --- a/packages/engine/src/__tests__/executor-prompt.test.ts +++ b/packages/engine/src/__tests__/executor-prompt.test.ts @@ -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 () => { diff --git a/packages/engine/src/__tests__/executor-step-numbering-zero-based.test.ts b/packages/engine/src/__tests__/executor-step-numbering-zero-based.test.ts index 8756f3b9c4..f37002aec7 100644 --- a/packages/engine/src/__tests__/executor-step-numbering-zero-based.test.ts +++ b/packages/engine/src/__tests__/executor-step-numbering-zero-based.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index be265111ae..6d2f31ab49 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -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 diff --git a/packages/engine/src/__tests__/executor-worktree-liveness.test.ts b/packages/engine/src/__tests__/executor-worktree-liveness.test.ts index f2dcb4fa4b..0f910b508a 100644 --- a/packages/engine/src/__tests__/executor-worktree-liveness.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-liveness.test.ts @@ -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 () => { diff --git a/packages/engine/src/__tests__/merger-merge-details.test.ts b/packages/engine/src/__tests__/merger-merge-details.test.ts index af9c960b41..f3609cb1bc 100644 --- a/packages/engine/src/__tests__/merger-merge-details.test.ts +++ b/packages/engine/src/__tests__/merger-merge-details.test.ts @@ -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:") 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(); }); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 3314787d4b..c527cf017e 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -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[]) { diff --git a/packages/engine/src/__tests__/plan-review-unavailable-recovery.test.ts b/packages/engine/src/__tests__/plan-review-unavailable-recovery.test.ts index 5122b159f4..962332e31a 100644 --- a/packages/engine/src/__tests__/plan-review-unavailable-recovery.test.ts +++ b/packages/engine/src/__tests__/plan-review-unavailable-recovery.test.ts @@ -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 | 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"; diff --git a/packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts b/packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts index 46be3606fa..fdaaeb2e58 100644 --- a/packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts +++ b/packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/project-runtime.test.ts b/packages/engine/src/__tests__/project-runtime.test.ts index ac766aecdb..efb6dde30b 100644 --- a/packages/engine/src/__tests__/project-runtime.test.ts +++ b/packages/engine/src/__tests__/project-runtime.test.ts @@ -5,6 +5,8 @@ vi.mock("@earendil-works/pi-ai", () => ({ Object: (props: Record) => ({ 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) ?? {}) }), diff --git a/packages/engine/src/__tests__/reliability-interactions/concurrent-execute-race.test.ts b/packages/engine/src/__tests__/reliability-interactions/concurrent-execute-race.test.ts index 7e9ad1d964..af5b9d2c17 100644 --- a/packages/engine/src/__tests__/reliability-interactions/concurrent-execute-race.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/concurrent-execute-race.test.ts @@ -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; diff --git a/packages/engine/src/__tests__/reliability-interactions/executor-pending-review-skip-retry.test.ts b/packages/engine/src/__tests__/reliability-interactions/executor-pending-review-skip-retry.test.ts index 056aaffec6..e95933255a 100644 --- a/packages/engine/src/__tests__/reliability-interactions/executor-pending-review-skip-retry.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/executor-pending-review-skip-retry.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts b/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts index 84a071364d..4489607c53 100644 --- a/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/owning-node-unavailable-interactions.test.ts @@ -44,6 +44,7 @@ function createMockTask(overrides: Partial = {}): Task { } function createMockStore(task: Task, settings: Record = {}): 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 = {}): 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, opts?: Record) => { + 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"), diff --git a/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts index e31d570524..61b2de7db3 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-done-continuation-no-wedge.test.ts @@ -86,7 +86,26 @@ function createStore(task: Task, settingsOverrides: Record = {} (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([]); diff --git a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts index 006ea3b2f2..0071138321 100644 --- a/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/starved-refinement-x-triage-poll.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts b/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts index fa1fb1a1e9..f1baa256d5 100644 --- a/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/todo-inprogress-flapping.test.ts @@ -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) => { + 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) => Object.assign(task, updates)), - moveTask: vi.fn(async (_id: string, column: Task["column"], opts?: Record) => { - 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, opts?: Record) => { + 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), diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index 59689b96b0..b06be2373b 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -256,6 +256,8 @@ vi.mock("@earendil-works/pi-ai", () => ({ Object: (props: Record) => ({ 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 = {}) { 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, + opts?: Record, + ) => { + 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), diff --git a/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts b/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts index f4dd897865..f7c92fadf9 100644 --- a/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts +++ b/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts @@ -42,6 +42,7 @@ function makeAgent(overrides: Partial & Pick): Agent { } function createStore(task: Task, settings: Record, 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, 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, opts?: Record) => { + 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"), diff --git a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts index c969238c4a..6b0ef455a8 100644 --- a/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts +++ b/packages/engine/src/__tests__/scheduler-overlap-starvation.test.ts @@ -46,6 +46,18 @@ function createStore(tasks: Task[], scopes: Record, 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, opts?: Record) => { + 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, 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"), diff --git a/packages/engine/src/__tests__/triage-refinement-routing.test.ts b/packages/engine/src/__tests__/triage-refinement-routing.test.ts index d28b85eef3..de2376d196 100644 --- a/packages/engine/src/__tests__/triage-refinement-routing.test.ts +++ b/packages/engine/src/__tests__/triage-refinement-routing.test.ts @@ -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", ]); }); }); diff --git a/packages/engine/src/__tests__/workflow-ir-pin-wiring.test.ts b/packages/engine/src/__tests__/workflow-ir-pin-wiring.test.ts index fe2664adca..e079a80fcb 100644 --- a/packages/engine/src/__tests__/workflow-ir-pin-wiring.test.ts +++ b/packages/engine/src/__tests__/workflow-ir-pin-wiring.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/workflow-task-runtime.test.ts b/packages/engine/src/__tests__/workflow-task-runtime.test.ts index 39864d6f25..4fd4b60c5a 100644 --- a/packages/engine/src/__tests__/workflow-task-runtime.test.ts +++ b/packages/engine/src/__tests__/workflow-task-runtime.test.ts @@ -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) => {