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:
7
.changeset/binary-release-bidi-windows-channel.md
Normal file
7
.changeset/binary-release-bidi-windows-channel.md
Normal 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.
|
||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -75,6 +75,12 @@ function prepareStore(child: TaskDetail, dependencies: TaskDetail[], shadowEnabl
|
||||
return store;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:25:
|
||||
executeCore now claims graphRouting before any await and passes `{ alreadyClaimed: true }`
|
||||
into `executeWorkflowGraph` (FN-8471 overseer-thrash fix, commit 6422cb93a). The "allows"
|
||||
assertions below match that second positional argument; the gated contract is unchanged.
|
||||
*/
|
||||
function spyOuterDispatch(executor: TaskExecutor) {
|
||||
const graph = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined);
|
||||
return { graph };
|
||||
@@ -82,6 +88,14 @@ function spyOuterDispatch(executor: TaskExecutor) {
|
||||
|
||||
afterEach(() => {
|
||||
clearPreHeldExecutorSlotsForTests();
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:25:
|
||||
executeCore claims the process-wide graphRouting set before calling executeWorkflowGraph
|
||||
(FN-8471 fix, commit 6422cb93a). With executeWorkflowGraph mocked, its real `finally` never
|
||||
releases the claim, so the shared FN-CHILD id would leak across tests and later dispatches
|
||||
would drop as duplicates. Clear the static set between tests (precedent: executor-prompt.test.ts).
|
||||
*/
|
||||
(TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting.clear();
|
||||
});
|
||||
|
||||
describe("executor outer dispatch dependency gate", () => {
|
||||
@@ -154,7 +168,7 @@ describe("executor outer dispatch dependency gate", () => {
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(store.updateTask).not.toHaveBeenCalledWith(child.id, expect.objectContaining({ status: "queued" }), undefined);
|
||||
expect(graph).toHaveBeenCalledWith(child);
|
||||
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
|
||||
});
|
||||
|
||||
it("allows missing or soft-deleted dependency residue past the outer gate", async () => {
|
||||
@@ -167,7 +181,7 @@ describe("executor outer dispatch dependency gate", () => {
|
||||
await executor.execute(child);
|
||||
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(graph).toHaveBeenCalledWith(child);
|
||||
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
|
||||
});
|
||||
|
||||
it("observes an accepted marker in shadow mode without letting it unblock a live dependency", async () => {
|
||||
@@ -203,7 +217,7 @@ describe("executor outer dispatch dependency gate", () => {
|
||||
|
||||
expect(store.getCompletionHandoffAcceptedMarker).toHaveBeenCalledWith(parent.id);
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(graph).toHaveBeenCalledWith(child);
|
||||
expect(graph).toHaveBeenCalledWith(child, { alreadyClaimed: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -598,8 +598,11 @@ describe("aiMergeTask — usage limit detection", () => {
|
||||
"rate_limit_error: Rate limit exceeded",
|
||||
undefined,
|
||||
);
|
||||
// FNXC:EngineTests 2026-07-23-21:40 (#2339): rate-limit pauses are provider-lane
|
||||
// scoped ("provider-rate-limit:<providerId>") so one saturated provider does not
|
||||
// park other lanes; no runtime provider is resolvable here, hence ":unknown".
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("FN-050", true, undefined, {
|
||||
pausedReason: "provider-rate-limit",
|
||||
pausedReason: "provider-rate-limit:unknown",
|
||||
});
|
||||
expect(store.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -16,6 +16,17 @@ vi.mock("../agent-session-helpers.js", () => ({
|
||||
provider: "mock-provider",
|
||||
modelId: "mock-model",
|
||||
}),
|
||||
// FN-7794 fallback-swap resolver called unconditionally on the validator hot path; mirror
|
||||
// production's validatorFallback -> fallback -> validator -> task precedence (see executor-test-helpers.ts).
|
||||
resolveValidatorFallbackThinkingLevel: vi.fn(
|
||||
(taskThinkingLevel: string | undefined, settings: Record<string, unknown> | undefined) =>
|
||||
(typeof settings?.validatorFallbackThinkingLevel === "string" ? settings.validatorFallbackThinkingLevel : undefined)
|
||||
?? (typeof settings?.fallbackThinkingLevel === "string" ? settings.fallbackThinkingLevel : undefined)
|
||||
?? (typeof settings?.validatorThinkingLevel === "string" ? settings.validatorThinkingLevel : undefined)
|
||||
?? taskThinkingLevel
|
||||
?? (typeof settings?.defaultThinkingLevelOverride === "string" ? settings.defaultThinkingLevelOverride : undefined)
|
||||
?? (typeof settings?.defaultThinkingLevel === "string" ? settings.defaultThinkingLevel : undefined),
|
||||
),
|
||||
}));
|
||||
|
||||
import { reviewStep } from "../reviewer.js";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,8 @@ vi.mock("@earendil-works/pi-ai", () => ({
|
||||
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
||||
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
|
||||
Number: (opts?: unknown) => ({ type: "number", ...((opts as object) ?? {}) }),
|
||||
// FNXC:EngineTests 2026-07-23-21:20: f21d3ce13 (#2375) added Type.Integer to agent-tools document CAS schemas (expected_revision); partial Type mocks must cover it or these files fail at collect time.
|
||||
Integer: (opts?: unknown) => ({ type: "integer", ...((opts as object) ?? {}) }),
|
||||
Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
|
||||
Optional: (schema: unknown) => schema,
|
||||
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,6 +44,7 @@ function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
}
|
||||
|
||||
function createMockStore(task: Task, settings: Record<string, unknown> = {}): TaskStore {
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
getSettings: vi.fn().mockResolvedValue(settings),
|
||||
@@ -54,7 +55,18 @@ function createMockStore(task: Task, settings: Record<string, unknown> = {}): Ta
|
||||
updateSettings: vi.fn().mockResolvedValue(settings),
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask,
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:20:
|
||||
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
|
||||
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
|
||||
*/
|
||||
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
|
||||
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
|
||||
await moveTask(id, column, opts);
|
||||
task.column = column;
|
||||
return { task, moved: true };
|
||||
}),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
|
||||
@@ -86,7 +86,26 @@ function createStore(task: Task, settingsOverrides: Record<string, unknown> = {}
|
||||
(emitter as any).listWorkflowSteps = vi.fn().mockResolvedValue([]);
|
||||
(emitter as any).getWorkflowStep = vi.fn().mockResolvedValue(undefined);
|
||||
(emitter as any).setPluginWorkflowStepTemplates = vi.fn().mockResolvedValue(undefined);
|
||||
(emitter as any).updateStep = vi.fn().mockResolvedValue(undefined);
|
||||
(emitter as any).updateStep = vi.fn().mockImplementation(async (_taskId: string, stepIndex: number, status: string) => {
|
||||
const steps = task.steps ?? [];
|
||||
if (steps[stepIndex]) steps[stepIndex] = { ...steps[stepIndex], status } as any;
|
||||
return task;
|
||||
});
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:40 (#2403):
|
||||
Step starts now go through the atomic, dependency-gated `store.startStep` before any
|
||||
step-session work (`runTaskStep`, step-runner.ts). A store without it throws at the
|
||||
projection seam and the graph fails `steps#0:step-execute` before the session under
|
||||
test ever runs. Mirror the production accept shape so these fixtures reach the
|
||||
post-done continuation behavior they pin.
|
||||
*/
|
||||
(emitter as any).startStep = vi.fn().mockImplementation(async (_taskId: string, stepIndex: number) => {
|
||||
const steps = task.steps ?? [];
|
||||
if (steps[stepIndex] && steps[stepIndex].status === "pending") {
|
||||
steps[stepIndex] = { ...steps[stepIndex], status: "in-progress" } as any;
|
||||
}
|
||||
return { task, accepted: true, disposition: "started" as const };
|
||||
});
|
||||
(emitter as any).parseStepsFromPrompt = vi.fn().mockResolvedValue([]);
|
||||
(emitter as any).parseFileScopeFromPrompt = vi.fn().mockResolvedValue([]);
|
||||
(emitter as any).getAgentLogs = vi.fn().mockResolvedValue([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -94,6 +94,14 @@ function makeSchedulerStore(rootDir: string, task: Task, settingsOverrides: Part
|
||||
...settingsOverrides,
|
||||
} as unknown as Settings;
|
||||
|
||||
const moveTask = vi.fn(async (_id: string, column: Task["column"], opts?: Record<string, unknown>) => {
|
||||
const from = task.column;
|
||||
task.column = column;
|
||||
task.columnMovedAt = new Date(Date.now()).toISOString();
|
||||
emitter.emit("task:moved", { task, from, to: column, source: (opts?.moveSource as "user" | "engine" | "scheduler" | undefined) ?? "engine" });
|
||||
return task;
|
||||
});
|
||||
|
||||
return Object.assign(emitter, {
|
||||
getSettings: vi.fn(async () => settings),
|
||||
/*
|
||||
@@ -107,12 +115,17 @@ function makeSchedulerStore(rootDir: string, task: Task, settingsOverrides: Part
|
||||
return task.column === column ? [task] : [];
|
||||
}),
|
||||
updateTask: vi.fn(async (_id: string, updates: Partial<Task>) => Object.assign(task, updates)),
|
||||
moveTask: vi.fn(async (_id: string, column: Task["column"], opts?: Record<string, unknown>) => {
|
||||
const from = task.column;
|
||||
task.column = column;
|
||||
task.columnMovedAt = new Date(Date.now()).toISOString();
|
||||
emitter.emit("task:moved", { task, from, to: column, source: (opts?.moveSource as "user" | "engine" | "scheduler" | undefined) ?? "engine" });
|
||||
return task;
|
||||
moveTask,
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:20:
|
||||
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
|
||||
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch/settle-window assertions on `store.moveTask` stay meaningful.
|
||||
*/
|
||||
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
|
||||
if (id !== task.id) return { task, moved: false };
|
||||
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
|
||||
const movedTask = await moveTask(id, column, opts);
|
||||
return { task: movedTask ?? task, moved: true };
|
||||
}),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
|
||||
@@ -256,6 +256,8 @@ vi.mock("@earendil-works/pi-ai", () => ({
|
||||
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
||||
String: (opts?: unknown) => ({ type: "string", ...((opts as object) ?? {}) }),
|
||||
Number: (opts?: unknown) => ({ type: "number", ...((opts as object) ?? {}) }),
|
||||
// FNXC:EngineTests 2026-07-23-21:20: f21d3ce13 (#2375) added Type.Integer to agent-tools document CAS schemas (expected_revision); partial Type mocks must cover it or these files fail at collect time.
|
||||
Integer: (opts?: unknown) => ({ type: "integer", ...((opts as object) ?? {}) }),
|
||||
Boolean: (opts?: unknown) => ({ type: "boolean", ...((opts as object) ?? {}) }),
|
||||
Optional: (schema: unknown) => schema,
|
||||
Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }),
|
||||
@@ -384,6 +386,26 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
return { ...(patches.get(id) ?? {}), id };
|
||||
}),
|
||||
moveTask: makeWriteThroughMoveTask(),
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:20:
|
||||
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da #2371).
|
||||
The fake evaluates the live-row predicate against the write-through `getTask` view and delegates to the mock
|
||||
`moveTask` (forwarding the options bag) so existing dispatch assertions on `store.moveTask` — including the
|
||||
`allocateWorktree` option — stay meaningful.
|
||||
*/
|
||||
moveTaskIf: vi.fn(
|
||||
async (
|
||||
id: string,
|
||||
column: string,
|
||||
predicate: (live: Task) => boolean | Promise<boolean>,
|
||||
opts?: Record<string, unknown>,
|
||||
) => {
|
||||
const live = await store.getTask(id);
|
||||
if (!live || !(await predicate(live))) return { task: live, moved: false };
|
||||
const moved = await store.moveTask(id, column, opts);
|
||||
return { task: moved ?? live, moved: true };
|
||||
},
|
||||
),
|
||||
recordActivity: vi.fn().mockResolvedValue({}),
|
||||
mergeTask: vi.fn().mockResolvedValue({}),
|
||||
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||
}
|
||||
|
||||
function createStore(task: Task, settings: Record<string, unknown>, tasksForList?: Task[]): TaskStore {
|
||||
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
listTasks: vi.fn().mockImplementation(async () => tasksForList ?? [task]),
|
||||
getSettings: vi.fn().mockResolvedValue(settings),
|
||||
@@ -52,7 +53,19 @@ function createStore(task: Task, settings: Record<string, unknown>, tasksForList
|
||||
updateSettings: vi.fn().mockResolvedValue(settings),
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask,
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:20:
|
||||
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
|
||||
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
|
||||
*/
|
||||
moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
|
||||
const cur = (tasksForList ?? [task]).find((t) => t.id === id) ?? task;
|
||||
if (!(await predicate(cur)) || cur.column === column) return { task: cur, moved: false };
|
||||
await moveTask(id, column, opts);
|
||||
cur.column = column;
|
||||
return { task: cur, moved: true };
|
||||
}),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
|
||||
|
||||
@@ -46,6 +46,18 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>, settings:
|
||||
if (task) task.column = column;
|
||||
return task as Task;
|
||||
});
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-23-21:20:
|
||||
Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da).
|
||||
The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful.
|
||||
*/
|
||||
const moveTaskIf = vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise<boolean>, opts?: Record<string, unknown>) => {
|
||||
const task = tasks.find((candidate) => candidate.id === id);
|
||||
if (!task) return { task: task as unknown as Task, moved: false };
|
||||
if (!(await predicate(task)) || task.column === column) return { task, moved: false };
|
||||
const movedTask = await moveTask(id, column, opts);
|
||||
return { task: movedTask ?? task, moved: true };
|
||||
});
|
||||
|
||||
return {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
@@ -58,6 +70,7 @@ function createStore(tasks: Task[], scopes: Record<string, string[]>, settings:
|
||||
parseFileScopeFromPrompt: vi.fn(async (id: string) => scopes[id] ?? []),
|
||||
updateTask,
|
||||
moveTask,
|
||||
moveTaskIf,
|
||||
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
getRootDir: vi.fn(() => "/tmp/project"),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user