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 # 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; # publish.channel=beta so electron-builder emits beta*.yml update manifests;
# beta-channel desktop installs read those, stable installs keep latest*.yml. # 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: env:
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 // cpu-features: native .node binding from ssh2 (transitive via dockerode); ssh2 falls back to pure JS when unavailable
"--external", "--external",
"cpu-features", "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, cwd: workspaceRoot,
stdout: "inherit", stdout: "inherit",

View File

@@ -76,6 +76,9 @@ import {
SQLITE_MIGRATION_RUNTIME_READ_VERSION, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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"; } from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.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; 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(); ctx = await setupFreshDb();
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only. // FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
// applySchemaBaseline now runs the plugin schema-init hooks by default, // 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 import_translation_cache (FNXC:GitHubImportTranslate 2026-07-15-09:30)
// + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00) // + 1 configuration_revisions (FNXC:ConfigVersioning 2026-07-18-14:00)
// + 2 ideation_sessions/ideation_candidates (FNXC:Ideation 2026-07-18-13:25 / FN-8295) // + 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. // 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.central).toBe(18);
expect(bySchema.archive).toBe(1); expect(bySchema.archive).toBe(1);
}); });
@@ -1502,6 +1506,35 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
CREATE TABLE project.missions (id text PRIMARY KEY); CREATE TABLE project.missions (id text PRIMARY KEY);
/* slice_id required before 0023 research provenance unique index can attach. */ /* slice_id required before 0023 research provenance unique index can attach. */
CREATE TABLE project.mission_features (id text PRIMARY KEY, slice_id text); 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 ( CREATE TABLE project.automations (
id text PRIMARY KEY, id text PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
@@ -1594,6 +1627,9 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SQLITE_MIGRATION_RUNTIME_READ_VERSION, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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); 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, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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, SQLITE_MIGRATION_RUNTIME_READ_VERSION,
WORKFLOW_TASK_CONTINUATIONS_VERSION, WORKFLOW_TASK_CONTINUATIONS_VERSION,
LEGACY_ADOPTION_DRAINED_MARKER_RUNTIME_GRANTS_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"); const running = await transitionWorkflowWorkItem(ctx.layer, item.id, "running");
expect(running.state).toBe("running"); expect(running.state).toBe("running");
// Transition to 'completed' (terminal). // Transition to 'succeeded' (terminal). #2378 renamed the terminal
const completed = await transitionWorkflowWorkItem(ctx.layer, item.id, "completed"); // completion state from 'completed' to 'succeeded' (WORKFLOW_WORK_ITEM_STATES).
expect(completed.state).toBe("completed"); 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( await expect(
transitionWorkflowWorkItem(ctx.layer, item.id, "runnable"), transitionWorkflowWorkItem(ctx.layer, item.id, "runnable"),
).rejects.toThrow(/terminal/); ).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: 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 }, { 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. // 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: 4215, 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: 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: 4241, primitive: "execSync", signature: "const branchesRaw = String(execSync(\"git branch --list 'fusion/*'\", {", 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: 12822, primitive: "execSync", signature: "execSync(`git branch -d ${shellQuote(branch)}`, {", 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: 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/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 }, { 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: 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: 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: 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: 7640, primitive: "execSync", signature: "execSync(`git rev-parse --verify \"${branch}\"`, {", 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: 8595, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", 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: 8608, 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: 8620, 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: 8958, 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: 8978, 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: 8987, 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: 9077, 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: 9652, primitive: "execSync", signature: "const postPushSha = execSync(\"git rev-parse HEAD\", {", 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: 10211, primitive: "execSync", signature: "const squashIsEmpty = execSync(", 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: 10245, 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: 10432, primitive: "execSync", signature: "execSync(\"git reset --merge\", { cwd: rootDir, stdio: \"pipe\" });", 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: 16296, primitive: "execSync", signature: "execSync(`git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, {", 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 },
]; ];
function scanSource(file: string, source: string): ShelloutSite[] { function scanSource(file: string, source: string): ShelloutSite[] {

View File

@@ -75,6 +75,12 @@ function prepareStore(child: TaskDetail, dependencies: TaskDetail[], shadowEnabl
return store; 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) { function spyOuterDispatch(executor: TaskExecutor) {
const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined); const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined);
return { graph }; return { graph };
@@ -82,6 +88,14 @@ function spyOuterDispatch(executor: TaskExecutor) {
afterEach(() => { afterEach(() => {
clearPreHeldExecutorSlotsForTests(); 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", () => { describe("executor outer dispatch dependency gate", () => {
@@ -154,7 +168,7 @@ describe("executor outer dispatch dependency gate", () => {
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalledWith(child.id, expect.objectContaining({ status: "queued" }), undefined); 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 () => { 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); await executor.execute(child);
expect(store.moveTask).not.toHaveBeenCalled(); 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 () => { 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.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith(parent.id);
expect(store.moveTask).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled();
expect(graph).toHaveBeenCalledWith(child); expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
}); });
it.each([ it.each([

View File

@@ -798,11 +798,17 @@ describe("TaskExecutor pause behavior", () => {
// Should move to todo, NOT mark as failed. // 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: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): FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A pause-abort bounce to todo must never discard progress the run already recorded. A pause-abort bounce to todo preserves resume state ONLY when the run recorded resumable
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. 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" }); 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: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): FNXC:EngineTests 2026-07-23-21:40 (FN-8464 / #2403):
A global-pause abort must park the task in todo without failing it AND without throwing away the progress the run already recorded. A global-pause abort must park the task in todo without failing it. Resume state is
Graph-owned execution always has a materialized in-progress workflow step by the time the pause lands, so the bounce carries `preserveResumeState`. 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" }); 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"); const watchdogSpy = vi.spyOn(executor as any, "scheduleCompletedTaskWatchdog");
await executor.execute(todoTask as any); await executor.execute(todoTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { /*
paused: false, FNXC:EngineTests 2026-07-23-21:40 (#2371):
pausedByAgentId: null, User-paused dispatch stops: a paused todo task is no longer dispatched at all —
status: null, execute() ends the graph run benignly with the row still parked and paused, so no
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion. agent session exists and `fn_task_done` is unreachable from this shape. The
bulkCompletionRefusalAt: null, 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
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); 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(store.moveTask).not.toHaveBeenCalledWith(
"FN-001",
"in-review",
expect.anything(),
);
expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done"); expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect( expect(
store.logEntry.mock.calls.some( store.logEntry.mock.calls.some(
([id, action]: [string, string]) => ([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); ).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)", () => { 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); await executor.execute(todoTask as any);
// FN-4145: explicit agent completion always clears task-level pause state. /*
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { FNXC:EngineTests 2026-07-23-21:40 (#2371):
paused: false, User-paused dispatch stops supersede the FN-3964/FN-4167 shape for ALREADY-paused
pausedByAgentId: null, todo rows: execute() no longer dispatches a paused task, so no agent session is
status: null, created and `fn_task_done` cannot fire from this shape. Explicit-completion pause
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion. clearing (FN-4145) still holds for a pause that lands MID-session — covered by
bulkCompletionRefusalAt: null, "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
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); pause clear, and the run narrates the benign paused park.
expect(store.moveTask).toHaveBeenCalledWith( */
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", "FN-001",
"in-review", "in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }), expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
); );
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done"); expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect( expect(
store.logEntry.mock.calls.some( store.logEntry.mock.calls.some(
([id, action]: [string, string]) => ([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); ).toBe(true);
expect(taskDoneResult.content[0].text).toBe( // globalPause:true refused-dispatch behavior is intentionally covered by the test above.
"Task marked complete with summary. All steps done. Moving to in-review.",
);
// globalPause:true deferred behavior is intentionally covered by the test above.
}); });
it("completes in-progress + paused tasks after clearing task-level pause state", async () => { 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); await executor.execute(todoTask as any);
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { /*
paused: false, FNXC:EngineTests 2026-07-23-21:40 (#2371):
pausedByAgentId: null, Same paused-dispatch-stop contract as the sibling describe: an already-paused todo
status: null, row is never dispatched, `fn_task_done` is unreachable, the pause is preserved, and
// FNXC:Lifecycle 2026-07-17-06:15: FN-8141 clears skip-bypass taint on accepted completion. the run parks benignly in todo.
bulkCompletionRefusalAt: null, */
}); expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.updateTask).not.toHaveBeenCalledWith(
expect(store.moveTask).toHaveBeenCalledWith( "FN-001",
expect.objectContaining({ paused: false }),
);
expect(store.moveTask).not.toHaveBeenCalledWith(
"FN-001", "FN-001",
"in-review", "in-review",
expect.objectContaining({ workflowMoveSource: "workflow-graph" }), expect.objectContaining({ workflowMoveSource: "workflow-graph" }),
); );
expect(watchdogSpy).toHaveBeenCalledWith("FN-001", "fn_task_done"); expect(watchdogSpy).not.toHaveBeenCalledWith("FN-001", "fn_task_done");
expect( expect(
store.logEntry.mock.calls.some( store.logEntry.mock.calls.some(
([id, action]: [string, string]) => ([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); ).toBe(true);
// globalPause:true deferred behavior is intentionally covered by the test above. // 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 () => { 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) => store.getTaskDocument.mockImplementation(async (_taskId: string, key: string) =>
key === "PROMPT.md" ? { content: task.prompt } : undefined, 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: { 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(), dispose: vi.fn(),
subscribe: vi.fn(), subscribe: vi.fn(),
on: vi.fn(), on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {}, state: {},
}, },
} as any); }) as any);
const executor = new TaskExecutor(store as any, "/tmp/test"); const executor = new TaskExecutor(store as any, "/tmp/test");
await executor.execute(task); await executor.execute(task);

View File

@@ -433,15 +433,26 @@ describe("Workflow Steps Execution", () => {
}; };
store.getTask.mockResolvedValue(baseTask as any); 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: { session: {
prompt: vi.fn().mockResolvedValue(undefined), prompt: vi.fn(async () => {
store._setRow("FN-5436-B", { steps: [{ name: "Implement", status: "in-progress" }] });
}),
dispose: vi.fn(), dispose: vi.fn(),
on: vi.fn(), on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {}, state: {},
}, },
} as any); }) as any);
const executor = new TaskExecutor(store, "/tmp/test", {}); const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(baseTask as any); 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 // (3) PROMPT.md injection was invoked with the failure context. The
// actual file write is covered by other tests; here we just need to // actual file write is covered by other tests; here we just need to
// confirm sendTaskBackForFix forwards the right step name and feedback. // confirm sendTaskBackForFix forwards the right step name and feedback.
// Last arg is MAX_WORKFLOW_STEP_RETRIES (private const, currently 3) so // FNXC:EngineTests 2026-07-23-21:40 (FN-8503): the retry arg is now a
// the injected PROMPT.md note shows "3/3 (0 remaining)". // 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( expect(injectSpy).toHaveBeenCalledWith(
mutableTask, mutableTask,
feedback, feedback,
stepName, stepName,
expect.any(Number), { attempt: 3, max: 3 },
); );
// The scheduleWorkflowRerun stub above never registers the 15 s // The scheduleWorkflowRerun stub above never registers the 15 s

View File

@@ -184,15 +184,25 @@ describe("FN-4114 worktree liveness assertion", () => {
mockedCreateFnAgent.mockReset(); mockedCreateFnAgent.mockReset();
mockCompletingAgent(); mockCompletingAgent();
store.moveTask.mockReset(); /*
store.moveTask.mockResolvedValue({}); FNXC:ExecutorTests 2026-07-23-21:20:
store.getTask.mockResolvedValue(task({ worktree: allowedWorktree })); 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); await acceptExecutor.execute(task({ worktree: allowedWorktree }) as any);
expect(mockedCreateFnAgent).toHaveBeenCalled(); 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 () => { 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", "rate_limit_error: Rate limit exceeded",
undefined, 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, { expect(store.pauseTask).toHaveBeenCalledWith("FN-050", true, undefined, {
pausedReason: "provider-rate-limit", pausedReason: "provider-rate-limit:unknown",
}); });
expect(store.updateSettings).not.toHaveBeenCalled(); 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. this until FN-8142's SDK bump (this PR); mock ModelRuntime so createFnAgent's registry path resolves.
*/ */
ModelRuntime: { 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 { ModelRegistry: class {
static create(...args: unknown[]) { static create(...args: unknown[]) {

View File

@@ -16,6 +16,17 @@ vi.mock("../agent-session-helpers.js", () => ({
provider: "mock-provider", provider: "mock-provider",
modelId: "mock-model", 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"; import { reviewStep } from "../reviewer.js";

View File

@@ -43,6 +43,14 @@ describe("ProjectEngine.stopOverseerTask session advisor cleanup", () => {
sessionAdvisorLogCursor: cursor, sessionAdvisorLogCursor: cursor,
plannerObservationEmitDedup: new Map(), plannerObservationEmitDedup: new Map(),
plannerEscalationEmitDedup: new Set(), 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); 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 }), Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }), String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
Number: (opts?: unknown) => ({ type: "number", ...((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) ?? {}) }), Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
Optional: (schema: unknown) => schema, Optional: (schema: unknown) => schema,
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }), 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"); const executor = new TaskExecutor(store as any, "/tmp/test");
await executor.execute(makeTask()); await executor.execute(makeTask());
const firstCount = mockedCreateFnAgent.mock.calls.length; 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()); await executor.execute(makeTask());
const secondCount = mockedCreateFnAgent.mock.calls.length; 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() }], log: [{ action: "code review requested for Step 0 (Step 1)", timestamp: new Date().toISOString() }],
}); });
store.getTask.mockResolvedValue(task); 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"); const executor = new TaskExecutor(store as any, "/repo");
await executor.execute(task); 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 { function createMockStore(task: Task, settings: Record<string, unknown> = {}): TaskStore {
const moveTask = vi.fn().mockResolvedValue(undefined);
return { return {
listTasks: vi.fn().mockResolvedValue([task]), listTasks: vi.fn().mockResolvedValue([task]),
getSettings: vi.fn().mockResolvedValue(settings), getSettings: vi.fn().mockResolvedValue(settings),
@@ -54,7 +55,18 @@ function createMockStore(task: Task, settings: Record<string, unknown> = {}): Ta
updateSettings: vi.fn().mockResolvedValue(settings), updateSettings: vi.fn().mockResolvedValue(settings),
getTask: vi.fn().mockResolvedValue(task), getTask: vi.fn().mockResolvedValue(task),
updateTask: vi.fn().mockResolvedValue(undefined), 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([]), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/test"), 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).listWorkflowSteps = vi.fn().mockResolvedValue([]);
(emitter as any).getWorkflowStep = vi.fn().mockResolvedValue(undefined); (emitter as any).getWorkflowStep = vi.fn().mockResolvedValue(undefined);
(emitter as any).setPluginWorkflowStepTemplates = 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).parseStepsFromPrompt = vi.fn().mockResolvedValue([]);
(emitter as any).parseFileScopeFromPrompt = vi.fn().mockResolvedValue([]); (emitter as any).parseFileScopeFromPrompt = vi.fn().mockResolvedValue([]);
(emitter as any).getAgentLogs = 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; (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(); 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); 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, ...settingsOverrides,
} as unknown as Settings; } 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, { return Object.assign(emitter, {
getSettings: vi.fn(async () => settings), getSettings: vi.fn(async () => settings),
/* /*
@@ -107,12 +115,17 @@ function makeSchedulerStore(rootDir: string, task: Task, settingsOverrides: Part
return task.column === column ? [task] : []; return task.column === column ? [task] : [];
}), }),
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => Object.assign(task, updates)), 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>) => { moveTask,
const from = task.column; /*
task.column = column; FNXC:EngineTests 2026-07-23-21:20:
task.columnMovedAt = new Date(Date.now()).toISOString(); Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
emitter.emit("task:moved", { task, from, to: column, source: (opts?.moveSource as "user" | "engine" | "scheduler" | undefined) ?? "engine" }); The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch/settle-window assertions on `store.moveTask` stay meaningful.
return task; */
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), logEntry: vi.fn(async () => undefined),
recordRunAuditEvent: 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 }), Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }), String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
Number: (opts?: unknown) => ({ type: "number", ...((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) ?? {}) }), Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
Optional: (schema: unknown) => schema, Optional: (schema: unknown) => schema,
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }), 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 }; return { ...(patches.get(id) ?? {}), id };
}), }),
moveTask: makeWriteThroughMoveTask(), 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({}), recordActivity: vi.fn().mockResolvedValue({}),
mergeTask: vi.fn().mockResolvedValue({}), mergeTask: vi.fn().mockResolvedValue({}),
getWorkflowStep: vi.fn().mockResolvedValue(undefined), 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 { function createStore(task: Task, settings: Record<string, unknown>, tasksForList?: Task[]): TaskStore {
const moveTask = vi.fn().mockResolvedValue(undefined);
return { return {
listTasks: vi.fn().mockImplementation(async () => tasksForList ?? [task]), listTasks: vi.fn().mockImplementation(async () => tasksForList ?? [task]),
getSettings: vi.fn().mockResolvedValue(settings), getSettings: vi.fn().mockResolvedValue(settings),
@@ -52,7 +53,19 @@ function createStore(task: Task, settings: Record<string, unknown>, tasksForList
updateSettings: vi.fn().mockResolvedValue(settings), updateSettings: vi.fn().mockResolvedValue(settings),
getTask: vi.fn().mockResolvedValue(task), getTask: vi.fn().mockResolvedValue(task),
updateTask: vi.fn().mockResolvedValue(undefined), 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([]), parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/tmp/project"), 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; if (task) task.column = column;
return task as Task; 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 { return {
listTasks: vi.fn(async () => tasks), 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] ?? []), parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
updateTask, updateTask,
moveTask, moveTask,
moveTaskIf,
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
logEntry: vi.fn(async () => undefined), logEntry: vi.fn(async () => undefined),
getRootDir: vi.fn(() => "/tmp/project"), getRootDir: vi.fn(() => "/tmp/project"),

View File

@@ -93,7 +93,15 @@ describe("refinement routing from triage", () => {
}); });
(processor as any).running = true; (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(); await (processor as any).poll();
if (tasks.find((t) => t.id === refinement.id)?.column === "todo") break; 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; (processor as any).running = true;
await (processor as any).poll(); 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([ expect(specifySpy.mock.calls.map(([task]) => task.id)).toEqual([
"FN-100",
"FN-101", "FN-101",
"FN-103", "FN-103",
"FN-102", "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(); await expect(persistence.loadPriorPin()).resolves.toBeUndefined();
// Node entries through the boundary never throw with the degraded seam. // 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 }); 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 // A row that predates the U9b fields (getTask works, fields absent) also
// yields no prior pin — the drift guard stays inert. // yields no prior pin — the drift guard stays inert.

View File

@@ -135,7 +135,7 @@ describe("WorkflowTaskRuntime", () => {
store: { store: {
getTaskWorkflowSelection: () => undefined, getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => 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" }), runCustomNode: async () => ({ outcome: "success" }),
}; };
@@ -304,7 +304,7 @@ describe("WorkflowTaskRuntime", () => {
store: { store: {
getTaskWorkflowSelection: () => undefined, getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => 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), primitives: recordingPrimitives(calls, undefined, observed),
runCustomNode: async (node) => { runCustomNode: async (node) => {
@@ -330,7 +330,7 @@ describe("WorkflowTaskRuntime", () => {
store: { store: {
getTaskWorkflowSelection: () => undefined, getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => 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), primitives: recordingPrimitives([], undefined, observed),
runCustomNode: async () => ({ outcome: "success" }), runCustomNode: async () => ({ outcome: "success" }),
@@ -374,7 +374,7 @@ describe("WorkflowTaskRuntime", () => {
store: { store: {
getTaskWorkflowSelection: () => undefined, getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => 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), primitives: recordingPrimitives(calls),
runCustomNode: async (node) => { runCustomNode: async (node) => {
@@ -408,7 +408,7 @@ describe("WorkflowTaskRuntime", () => {
store: { store: {
getTaskWorkflowSelection: () => undefined, getTaskWorkflowSelection: () => undefined,
getWorkflowDefinition: async () => 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), primitives: recordingPrimitives(calls),
runCustomNode: async (node) => { runCustomNode: async (node) => {