From 286dd0a7ae8cd6cbf619fe787ef2933b58361fda Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Thu, 27 Aug 2026 08:49:10 +0000 Subject: [PATCH] fix(workspace): block completion only on main-checkout commits Uncommitted edits in a sub-repo main checkout no longer refuse `fn_task_done`. They emit `worktree:workspace-main-checkout-edit` with `outcome:"warned"`, `reason:"uncommitted-only"`, and their evidence enum instead. Two measured reasons. The workspace land path is the same `landOneRepo` / `landSquash` mechanic as single-repo, with `projectRootDir` set to the sub-repo main checkout, so a dirty tree there is already stashed (untracked included) -> fast-forwarded -> restored under `merger.allowDirtyLocalCheckoutSync`; refusing completion for a state the very next stage is built to absorb stops the board for nothing. And an in-scope status entry carried no timing evidence at all, so an operator editing the same feature was indistinguishable from an agent that skipped `fn_acquire_repo_worktree` -- while the refusal named an operator-only remedy in a message addressed to the agent, so the card could only loop. The dangerous cases keep their refusals: a task-attributed commit still returns `main_checkout_edit` (it would reach the shared branch unreviewed), and work that exists only in a main checkout still fails the acquired-worktree `no_commits` invariant that actually proves delivery. --- .../main-checkout-guard-commits-only.md | 7 ++++ AGENTS.md | 1 + docs/architecture.md | 2 +- .../suite-only-flakes-observed-register.md | 29 +++++++++++++ ...utor-workspace-main-checkout-guard.test.ts | 41 ++++++++++++------- .../engine/src/executor/execution-prompt.ts | 4 +- .../executor/workspace-main-checkout-guard.ts | 33 +++++++++++++-- .../executor/worktree-verify-invariants.ts | 11 ++++- 8 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 .changeset/main-checkout-guard-commits-only.md diff --git a/.changeset/main-checkout-guard-commits-only.md b/.changeset/main-checkout-guard-commits-only.md new file mode 100644 index 0000000000..0aeb95bcbc --- /dev/null +++ b/.changeset/main-checkout-guard-commits-only.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Workspace tasks no longer stall on uncommitted edits sitting in a shared repo checkout. +category: fix +dev: The main-checkout completion guard blocks only task-attributed commits; uncommitted status entries emit `worktree:workspace-main-checkout-edit` with `outcome:"warned"`, `reason:"uncommitted-only"`, and their evidence enum. Delivery stays proven by the acquired-worktree `no_commits` invariant, and the land path already stashes/restores a dirty sub-repo checkout via `merger.allowDirtyLocalCheckoutSync`. diff --git a/AGENTS.md b/AGENTS.md index ec19214836..51deb93985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -365,6 +365,7 @@ canonical emitters remain explicit exclusions until their separately scoped hard - FN-9164: `worktree:workspace-repo-base-branch` records per-repo base resolution with exactly `taskId`, `repoRelPath`, `stage`, `source`, `outcome`, and optional `fallbackReason`; branch/ref names are deliberately excluded from metadata and `target`, living only in the durable entry and task log. - FN-9168: `task:merge-boundary-unproven-parked` is emitted once per terminal merge-boundary-unproven park at the bounded-retry router and reachable graph terminal-merge park. Metadata is ids/counts/fixed outcomes only (`taskId`, `nodeId`, `failureValue`, `source`, optional `reasonCode`/`missingInstanceCount`, `priorColumn`, `priorStatus`, `outcome`) and never boundary reason prose, foreach instance IDs, or error text. `emitMergeBoundaryUnprovenParked` swallows absent/throwing/rejecting sinks and time-bounds a hung one with `MERGE_BOUNDARY_UNPROVEN_AUDIT_EMIT_TIMEOUT_MS`; late settlement is swallowed and the unref'd timer is cleared, so best-effort telemetry never alters, delays, aborts, or wedges the terminal park. - FN-9058: `worktree:workspace-main-checkout-edit` records workspace completion guard evidence with ids/counts/fixed outcomes only: task/repo IDs, file/commit counts, evidence or warning reason enum, `taskDoneRetryCount`, and `blocked`/`warned`/`skipped`; never paths, file content, or commit prose. +- Main-checkout guard narrowing (2026-08-27): the workspace main-checkout guard blocks completion **only** on a task-attributed commit. Uncommitted status entries emit `outcome:"warned"` with `reason:"uncommitted-only"` plus their `evidence` enum and never refuse `fn_task_done`: workspace lands run the same `landOneRepo`/`landSquash` path as single-repo against the sub-repo main checkout, so a dirty tree there is already stashed → fast-forwarded → restored under `merger.allowDirtyLocalCheckoutSync`. Delivery is proven by the acquired-worktree `no_commits` invariant, not by main-checkout cleanliness. Do not re-add a status-only refusal: it named an operator-only remedy in a message addressed to the agent, so the card could only loop. - FN-9059: workspace coordination emits `workspace-lease:*` events for lease acquisition, renewal, release, `fence-published`, `fence-superseded`, `reclaimed`, and `reclaim-refused`, plus `workspace-land-intent:*` events for write-ahead intent lifecycle and `resolve-refused`. Metadata is ids, SHAs, counts, and fixed outcomes only; it never includes a credential-bearing remote URL. - FN-9056: self-healing emits `task:reconcile-orphaned-workspace-worktree` when it reclaims a complete-lane or conservatively-idle failed/soft-deleted workspace entry. It vetoes raw/canonical active paths, task-session/executor/merge liveness, pauses and scheduled recovery; archived rows remain archive-lifecycle-owned. It runs `git worktree prune` even for already-gone paths and deletes only safely-discardable canonical `fusion/` branches. Duplicate, foreign, unowned, or outside-root claims are skipped without git work; one entry-scoped `MAX_STARVATION_DROPS` budget plus settlement bounds retries. Metadata is ids/counts/fixed outcomes: task/repo/path, success/reason/lane, worktree/prune/branch outcomes, and attempt. - FN-8144: archive emits `archive-workspace-worktree-disposer-missing` when a workspace archive has no store-scoped backend disposer; per-repository archive removal is awaited under canonical-path reservations, with failed paths quarantined for successor reconciliation. diff --git a/docs/architecture.md b/docs/architecture.md index 05b62e0673..1082a8a7eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -637,7 +637,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - **Workspace integration targets (FN-122):** after the dispatch lease and before status, fence, intent, or ref writes, landing plans each modified confirmed-scope repository independently. Repositories with no remote are local-only: they retain durable task/repository leases and local ref CAS, but create no remote fence or land intent and execute no remote Git command. Remote targets use `worktreeRebaseRemote`, then the integration branch remote, then a sole remote or actual `origin`; missing configured or ambiguous remotes are actionable environment failures. The lock order remains merge queue → dispatch lease → one repository lease → optional remote fence/push. Persisted per-repository `landedSha` allows recovery to skip proven lands and finalize once only after every planned target is proven. - **Workspace merge and land crash boundaries (FN-9059):** merge dispatch claims at body dispatch rather than enqueue; a losing claimant benignly drops. Each live per-repository land lease renews before its five-minute TTL throughout dependency sync, AI merge/review, retry, push, and intent resolution. A refused or failed renewal aborts the land body before its next durable or remote commit point; renewal only maintains liveness, while the current owner/fence handle remains the write authority. The body re-proves its fence at admission, the target-plus-fence atomic push, subsequent PR/branch/status effects, and terminal outcome persistence. Non-CASable effects are idempotent and follow the fenced push, so mid-merge expiry stops a superseded body at its next commit point; a pushed result whose outcome cannot persist is `merge-completed-unrecorded`, never a re-push. Before workspace land pushes, `project.workspace_land_intents` records the expected tip, intended SHA, remote/integration identity, and fence pin. The ordered protocol is intent → atomic push → lease-validated `landedSha` persist plus intent resolution. The node-independent reconciler lists pending intents project-wide, fetches the recorded remote, and resolves reachability on its integration ref. Only a live holder (its own/equal fence or a lower-fence predecessor) or recovery with no held unexpired lease can resolve an intent; stale fence matches, local tip equality, and local object availability are not authority. - **Workspace File Scope interpretation (FN-078):** squash gates, completion scope-leak checks, and main-checkout guards use one repo-local resolver. A matching repo-prefixed declaration has priority; only a declaration with no configured repository prefix can fall back as repo-local scope. If any declaration belongs to another configured repository, a repo with no matching prefix receives no fallback authority. This preserves whole-repo `repo` → `**`, normalized segment-aware nested keys, empty-scope behavior, and strict foreign-repository isolation. - - **Main-checkout completion guard (FN-9058):** `fn_task_done` probes every configured sub-repo main checkout before any workspace worktree invariant, so `main_checkout_edit` takes precedence over `no_commits` and cannot be skipped by zero-acquire or no-commit eligibility. It uses the immutable first-execution anchor (never only the re-stamped per-attempt timestamp), blocks task-era status entries and bounded recent-HEAD evidence without `--since` or ancestry filtering, and emits `worktree:workspace-main-checkout-edit`. Unattributable pre-existing dirt, unavailable probes, and unresolved timing only warn: refusal has a bounded requeue budget and the guard is read-only. + - **Main-checkout completion guard (FN-9058, narrowed 2026-08-27):** `fn_task_done` probes every configured sub-repo main checkout before any workspace worktree invariant, so `main_checkout_edit` takes precedence over `no_commits` and cannot be skipped by zero-acquire or no-commit eligibility. It uses the immutable first-execution anchor (never only the re-stamped per-attempt timestamp), scans bounded recent HEAD without `--since` or ancestry filtering, and emits `worktree:workspace-main-checkout-edit`. **Only a task-attributed commit blocks.** Uncommitted status entries — including declared-scope and task-era ones — are reported as `uncommitted-only` warnings carrying their `evidence`, never a refusal, for two reasons: the workspace land path is the same `landOneRepo`/`landSquash` mechanic as single-repo with `projectRootDir` set to the sub-repo main checkout, so it already stashes (untracked included) → fast-forwards → restores that checkout under `merger.allowDirtyLocalCheckoutSync`; and an in-scope status entry carried no timing evidence, making ordinary operator edits on the same feature indistinguishable from an agent that skipped acquisition. Work that exists only in a main checkout is still refused, by the acquired-worktree `no_commits` invariant that proves delivery. Unattributable pre-existing dirt, unavailable probes, and unresolved timing also only warn: refusal has a bounded requeue budget and the guard is read-only. - **Task-pinned orphan recovery:** task-ID-pinned acquisition holds one path reservation across classification, preservation, quarantine reconciliation, and recreation. Inactive incomplete or unregistered directories are atomically moved to `/.fusion/recovery/worktrees`, or to `/.fusion-recovery/worktrees` after an `EXDEV` cross-filesystem refusal. Each actual recovery root retains the newest 10 recognized Fusion-generated entries; pruning is fail-soft and preserves unknown, symlinked, unreadable, or active paths. Worktree pool and self-healing scans exclude both `.ai-merge` and `.fusion-recovery` as internal container boundaries. - **Execution-only reused-base refresh (FN-8693):** planning creates isolated worktrees but does not refresh them; immediately before a graph `code` node, normal executor dispatch, or durable-agent heartbeat session, refresh-enabled reuse resolves the current integration target C1 and compares it with durable `task.baseCommitSha`. A clean no-own-commit checkout resets to C1; a clean own-commit checkout rebases and retains its resulting C2 `HEAD`, while storing C1—not C2—as the baseline. A durable C0/C1 mismatch is rechecked from git and durable metadata on every acquisition, so restart reconciliation needs no in-memory marker. Dirty, unresolved, unsupported worktrunk, git, conflict, persistence, and unprovable-reconciliation cases are typed non-execution outcomes that park before session start. If baseline persistence fails after git moves `HEAD`, the engine compensates to the original clean checkout and emits `worktree:base-refresh-persistence-failed-compensated`; otherwise it requires later proof-based reconciliation. Audit events are `worktree:base-refreshed`, `worktree:base-refresh-blocked`, `worktree:base-refresh-conflict`, `worktree:base-refresh-persistence-failed-compensated`, and `worktree:base-refresh-reconciled`. Plan/review/gate acquisition and merger acquisition remain excluded; production merger behavior is the unified clean-room `runAiMerge` path. diff --git a/docs/solutions/test-failures/suite-only-flakes-observed-register.md b/docs/solutions/test-failures/suite-only-flakes-observed-register.md index 296d907d1d..76158a1ebb 100644 --- a/docs/solutions/test-failures/suite-only-flakes-observed-register.md +++ b/docs/solutions/test-failures/suite-only-flakes-observed-register.md @@ -625,3 +625,32 @@ this investigation. One genuinely structural failure WAS found and fixed rather than recorded here: the lane runner's own self-tests spawned `pnpm --filter @fusion/dashboard test`, re-entering the suite from inside it. See `cc19584cc4`. + +--- + +## Entry: `executor-prompt` pause-resume agent-creation count (first sighting) + +- **File:** `packages/engine/src/__tests__/executor-prompt.test.ts` +- **Exact test:** `TaskExecutor pause behavior > resumes unpaused in-progress task with no active session` +- **Owner:** unowned — first sighting, recorded rather than quarantined because the file's remaining 113 tests are substantial coverage and quarantine is file-level. +- **Observed tree/SHA:** `5769d5cd6` plus the then-uncommitted main-checkout-guard narrowing (guard classification, its audit metadata, and the workspace prompt string) — none of which this test exercises. +- **Observed frequency:** once, and only when the file ran in the same vitest command as five other executor/workspace files. Passes deterministically alone (114/114). + +Verbatim observed failure: + +``` +FAIL |engine-default| src/__tests__/executor-prompt.test.ts > TaskExecutor pause behavior > resumes unpaused in-progress task with no active session +AssertionError: expected 0 to be greater than or equal to 2 + ❯ src/__tests__/executor-prompt.test.ts:1047:51 +``` + +| run | result | +|---|---| +| six files in one command (`task-done-refusal-x-invariant`, `executor-workspace`, `executor-prompt`, `verify-worktree-invariants-missing`, `executor-workspace-config-propagation`, `executor-workspace-capture`) | **failed** — 1 failed / 147 passed | +| `executor-prompt.test.ts` alone, same tree | **passed** (114/114) | + +The assertion counts `createFnAgent` calls after a resume and observed ZERO, so the resume path never +reached agent creation at all — reads as module-mock ownership racing across files that share the +`@fusion/core` agent-factory mock, not a wait that needs lengthening. No timeout was widened, no retry +added, and no assertion relaxed. A SECOND sighting is an ordinary on-sight quarantine with no further +discretion, per the standing rule in AGENTS.md. diff --git a/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts index acff99d0ec..364372dc50 100644 --- a/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts @@ -2,6 +2,11 @@ * FNXC:Workspace 2026-08-15-07:05: * Real git fixtures prove the guard sees configured main checkouts, including repos with no * acquired worktree; mocking status would not exercise the bypass completion previously missed. + * + * FNXC:WorkspaceFinalization 2026-08-27-08:42: + * The guard's blocking subject is now COMMITS only. These cases pin both halves: uncommitted main + * checkout entries surface as `uncommitted-only` warnings and complete, while task-attributed + * commits and undelivered work still refuse (`main_checkout_edit` / `no_commits`). */ import { afterEach, describe, expect, it, vi } from "vitest"; import { execSync } from "node:child_process"; @@ -44,7 +49,7 @@ describeIfGit("workspace main-checkout guard", () => { let fixture: WorkspaceFixture; afterEach(() => fixture?.cleanup()); - it("blocks staged, untracked, out-of-scope, and zero-acquire main-checkout edits", async () => { + it("reports staged, untracked, out-of-scope, and zero-acquire main-checkout edits without blocking", async () => { fixture = await createWorkspaceFixture(); mkdirSync(path.join(fixture.repoPath("repo-a"), "src"), { recursive: true }); writeFileSync(path.join(fixture.repoPath("repo-a"), "src", "outside.ts"), "export {};\n"); @@ -58,8 +63,9 @@ describeIfGit("workspace main-checkout guard", () => { utimes(path.join(fixture.repoPath("repo-b"), "src", "new.ts"), changed, changed), ])); const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, activeTask, fixture.repos, ["repo-a/docs/**"]); - expect(result.violations.find((finding) => finding.repo === "repo-a")?.files).toContain("src/outside.ts"); - expect(result.violations.find((finding) => finding.repo === "repo-b")?.files).toContain("src/new.ts"); + expect(result.violations).toEqual([]); + expect(result.warnings.find((finding) => finding.repo === "repo-a" && finding.reason === "uncommitted-only")?.files).toContain("src/outside.ts"); + expect(result.warnings.find((finding) => finding.repo === "repo-b" && finding.reason === "uncommitted-only")?.files).toContain("src/new.ts"); }); it("treats repo-local File Scope as declared scope for a single workspace repository", async () => { @@ -72,7 +78,8 @@ describeIfGit("workspace main-checkout guard", () => { await import("node:fs/promises").then(({ utimes }) => utimes(file, changed, changed)); const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, activeTask, fixture.repos, ["src/**"]); - expect(result.violations).toContainEqual(expect.objectContaining({ repo: "repo-a", files: ["src/local.ts"], evidence: "declared-scope-change" })); + expect(result.violations).toEqual([]); + expect(result.warnings).toContainEqual(expect.objectContaining({ repo: "repo-a", files: ["src/local.ts"], reason: "uncommitted-only", evidence: "declared-scope-change" })); }); it("uses firstExecutionAt instead of the later retry attempt anchor", async () => { @@ -85,29 +92,34 @@ describeIfGit("workspace main-checkout guard", () => { const retry = new Date(Date.now() + 60_000).toISOString(); const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, task({ firstExecutionAt: first, executionStartedAt: retry }), fixture.repos, []); expect(workspaceExecutionAnchor(task({ firstExecutionAt: first, executionStartedAt: retry }))).toBeLessThan(Date.parse(retry)); - expect(result.violations[0]).toMatchObject({ repo: "repo-a", evidence: "task-era-change" }); + expect(result.warnings[0]).toMatchObject({ repo: "repo-a", reason: "uncommitted-only", evidence: "task-era-change" }); }); - it("runs before no_commits in the production completion invariant and clears after remediation", async () => { + it("completes through uncommitted main-checkout dirt but still refuses undelivered work", async () => { fixture = await createWorkspaceFixture(["repo-a"]); const acquired = addEmptyWorktree(fixture); const activeTask = task({ workspaceWorktrees: { "repo-a": { ...acquired, branch: "fusion/fn-1001" } }, }); - writeFileSync(path.join(acquired.worktreePath, "proper-worktree.ts"), "export const proper = true;\n"); - execSync('git config user.email "test@example.com" && git config user.name "Test" && git add proper-worktree.ts && git commit -m "feat: proper worktree edit"', { cwd: acquired.worktreePath }); const mainFile = path.join(fixture.repoPath("repo-a"), "main-checkout.ts"); writeFileSync(mainFile, "export const bypass = true;\n"); const changed = new Date(Date.parse(activeTask.firstExecutionAt!) + 10_000); await import("node:fs/promises").then(({ utimes }) => utimes(mainFile, changed, changed)); - const blocked = await verifyWorktreeInvariants(invariantDeps(fixture), activeTask); - expect(blocked).toMatchObject({ ok: false, reason: "main_checkout_edit", repo: "repo-a" }); - expect(blocked.ok ? "" : blocked.observed).toContain("main-checkout.ts"); + // Work exists ONLY in the main checkout: completion is still refused, by the invariant that + // actually proves delivery (an acquired worktree with commits) rather than by the dirt itself. + const undelivered = await verifyWorktreeInvariants(invariantDeps(fixture), activeTask); + expect(undelivered).toMatchObject({ ok: false, reason: "no_commits" }); + + writeFileSync(path.join(acquired.worktreePath, "proper-worktree.ts"), "export const proper = true;\n"); + execSync('git config user.email "test@example.com" && git config user.name "Test" && git add proper-worktree.ts && git commit -m "feat: proper worktree edit"', { cwd: acquired.worktreePath }); + + // Same dirt, delivery committed in the acquired worktree: the land path stashes and restores + // that checkout, so completion must not stop for a state the merger is built to absorb. + expect(await verifyWorktreeInvariants(invariantDeps(fixture), activeTask)).toEqual({ ok: true }); unlinkSync(mainFile); - const remediated = await verifyWorktreeInvariants(invariantDeps(fixture), activeTask); - expect(remediated).toEqual({ ok: true }); + expect(await verifyWorktreeInvariants(invariantDeps(fixture), activeTask)).toEqual({ ok: true }); }); it("detects clean-tree direct main commits without a base range", async () => { @@ -156,7 +168,8 @@ describeIfGit("workspace main-checkout guard", () => { const changed = new Date(Date.parse(activeTask.firstExecutionAt!) + 10_000); await import("node:fs/promises").then(({ utimes }) => utimes(parent, changed, changed)); const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, activeTask, fixture.repos, []); - expect(result.violations).toContainEqual(expect.objectContaining({ repo: "repo-a", files: ["deleted.ts"], evidence: "task-era-change" })); + expect(result.violations).toEqual([]); + expect(result.warnings).toContainEqual(expect.objectContaining({ repo: "repo-a", files: ["deleted.ts"], reason: "uncommitted-only", evidence: "task-era-change" })); }); it("warns rather than blocks provably old operator dirt and ignores nested worktrees", async () => { diff --git a/packages/engine/src/executor/execution-prompt.ts b/packages/engine/src/executor/execution-prompt.ts index 14cf836ed3..1047e493ba 100644 --- a/packages/engine/src/executor/execution-prompt.ts +++ b/packages/engine/src/executor/execution-prompt.ts @@ -283,8 +283,8 @@ Do not repeatedly rerun a broad failing or hanging workspace command without a n workspaceConfig.repos.map((r: string) => `- \`${r}\``).join("\n") + `\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` + `with the repo name to get an isolated worktree path. ` + - /* FNXC:Workspace 2026-08-15-07:05: Completion mechanically refuses task-era main-checkout writes and commits, even outside File Scope or before acquisition. */ - `Work exclusively inside that returned path — never edit the repo's main checkout directly. This is mechanically enforced at \`fn_task_done\`: any file created, modified, or deleted, or any commit landed in a sub-repo main checkout during this run refuses completion regardless of File Scope or acquisition. Move the work into an acquired worktree and restore the main checkout before retrying.\n`; + /* FNXC:WorkspaceFinalization 2026-08-27-08:42: Completion refuses main-checkout COMMITS (they reach the shared branch unreviewed). Uncommitted main-checkout edits only warn — the merger already stashes/restores a dirty checkout — but they deliver nothing, so the acquired-worktree commit invariant still fails work that lives only there. */ + `Work exclusively inside that returned path — never edit the repo's main checkout directly. Committing in a sub-repo main checkout refuses completion at \`fn_task_done\`: that commit would reach the shared branch without review. Uncommitted edits left in a main checkout are reported but deliver nothing — completion still requires commits in the acquired worktree. Move the work into an acquired worktree and leave the main checkout as you found it.\n`; } return executionPrompt; diff --git a/packages/engine/src/executor/workspace-main-checkout-guard.ts b/packages/engine/src/executor/workspace-main-checkout-guard.ts index 24aa2c4045..2243406d34 100644 --- a/packages/engine/src/executor/workspace-main-checkout-guard.ts +++ b/packages/engine/src/executor/workspace-main-checkout-guard.ts @@ -3,6 +3,22 @@ * Completion, review, and merge inspect only acquired workspace worktrees. Probe every configured * main checkout so direct edits cannot bypass those surfaces. Classification uses execution time, * not File Scope: unenumerated and unscoped paths must not become an escape hatch. + * + * FNXC:WorkspaceFinalization 2026-08-27-08:42: + * Only COMMITS in a main checkout are a completion violation. Uncommitted status entries warn. + * Two measured reasons. (1) The very next pipeline stage absorbs a dirty main checkout: workspace + * lands call the same `landOneRepo`/`landSquash` mechanic as single-repo with `projectRootDir` set + * to the sub-repo main checkout, so a dirty tree there is stashed (untracked included) -> ff -> + * restored under `merger.allowDirtyLocalCheckoutSync`, or the ref advances atomically and the tree + * is left alone when the stash is impossible. Refusing completion for a state the merger is built + * to handle stops the board for nothing. (2) Attribution had no evidence: a File Scope match was + * treated as task work with NO timing test, so an operator editing the same feature in the shared + * checkout was indistinguishable from an agent that skipped `fn_acquire_repo_worktree` — and the + * refusal named an operator-only remedy, so the card could only loop. + * The dangerous cases keep hard refusals elsewhere: a task-attributed commit still returns + * `main_checkout_edit` (it would reach the shared branch unreviewed), and work that exists ONLY in + * the main checkout now falls through to the acquired-worktree `no_commits` invariant, which + * refuses because the acquired worktree carries no commits. */ import { exec } from "node:child_process"; import { existsSync, promises as fs } from "node:fs"; @@ -16,9 +32,11 @@ import { isAlwaysAllowedScopeLeakPath, workflowPathMatchesDeclaredScope } from " const execAsync = promisify(exec); const probeOptions = { encoding: "utf-8" as const, timeout: 10_000, maxBuffer: 1024 * 1024 }; export type MainCheckoutEvidence = "task-era-change" | "declared-scope-change" | "task-attributed-commit" | "post-anchor-commit"; -export type MainCheckoutWarningReason = "pre-existing-dirt" | "anchor-unresolved" | "commit-scan-unavailable"; +/** `uncommitted-only`: task-era or in-scope status entries with no commit behind them (2026-08-27). */ +export type MainCheckoutWarningReason = "pre-existing-dirt" | "anchor-unresolved" | "commit-scan-unavailable" | "uncommitted-only"; export type MainCheckoutFinding = { repo: string; files: string[]; commits: string[]; evidence: MainCheckoutEvidence }; -export type MainCheckoutWarning = { repo: string; files: string[]; commits: string[]; reason: MainCheckoutWarningReason }; +/** `evidence` is retained on downgraded findings so telemetry keeps the classification it had. */ +export type MainCheckoutWarning = { repo: string; files: string[]; commits: string[]; reason: MainCheckoutWarningReason; evidence?: MainCheckoutEvidence }; export type MainCheckoutGuardResult = { violations: MainCheckoutFinding[]; warnings: MainCheckoutWarning[]; skipped: string[] }; /** Earliest durable attempt timestamp, with a small filesystem clock tolerance. */ @@ -112,7 +130,16 @@ export async function detectWorkspaceMainCheckoutWork( else if (anchor !== null && mtime !== null && mtime >= anchor) taskFiles.push(file); else oldFiles.push(file); } - if (taskFiles.length) violations.push({ repo, files: taskFiles, commits: [], evidence: repoScope.length && taskFiles.some((file) => workflowPathMatchesDeclaredScope(file, repoScope)) ? "declared-scope-change" : "task-era-change" }); + // FNXC:WorkspaceFinalization 2026-08-27-08:42: status-only evidence is reported, never refused (see header). + if (taskFiles.length) { + warnings.push({ + repo, + files: taskFiles, + commits: [], + reason: "uncommitted-only", + evidence: repoScope.length && taskFiles.some((file) => workflowPathMatchesDeclaredScope(file, repoScope)) ? "declared-scope-change" : "task-era-change", + }); + } if (oldFiles.length) warnings.push({ repo, files: oldFiles, commits: [], reason: anchor === null ? "anchor-unresolved" : "pre-existing-dirt" }); if (anchor === null && statusFiles.length && !oldFiles.length) warnings.push({ repo, files: statusFiles, commits: [], reason: "anchor-unresolved" }); try { diff --git a/packages/engine/src/executor/worktree-verify-invariants.ts b/packages/engine/src/executor/worktree-verify-invariants.ts index 892b4d7ec8..f27345c76f 100644 --- a/packages/engine/src/executor/worktree-verify-invariants.ts +++ b/packages/engine/src/executor/worktree-verify-invariants.ts @@ -78,11 +78,18 @@ export async function verifyWorktreeInvariants( { rootDir: deps.rootDir, settings }, task, configuredRepos, declaredScope, ); const auditor = createRunAuditor(deps.store, deps.getRunContextFor(task.id)); + /* + FNXC:WorkspaceFinalization 2026-08-27-08:42: + Uncommitted main-checkout edits arrive here as `uncommitted-only` warnings rather than + violations. Keep their `evidence` in telemetry so the downgrade stays auditable (fixed enum, + ids/counts only) — without it, "was this in declared scope?" became unanswerable after the fact. + */ for (const warning of mainCheckout.warnings) { executorLog.warn(`${task.id}: workspace main-checkout guard warning repo=${warning.repo} reason=${warning.reason}`); await auditor.git({ type: "worktree:workspace-main-checkout-edit", target: warning.repo, metadata: { taskId: task.id, repo: warning.repo, fileCount: warning.files.length, commitCount: warning.commits.length, - reason: warning.reason, taskDoneRetryCount: task.taskDoneRetryCount ?? 0, outcome: "warned", + reason: warning.reason, ...(warning.evidence ? { evidence: warning.evidence } : {}), + taskDoneRetryCount: task.taskDoneRetryCount ?? 0, outcome: "warned", } }); } for (const repo of mainCheckout.skipped) { @@ -104,7 +111,7 @@ export async function verifyWorktreeInvariants( return { ok: false, reason: "main_checkout_edit", repo: firstMainCheckoutViolation.repo, observed: `${firstMainCheckoutViolation.evidence}: ${observed}`, - expected: `move task work into the acquired fusion/${task.id} worktree for ${firstMainCheckoutViolation.repo} (acquire it first if needed), restore its main checkout, then ask an operator to retry fn_task_done`, + expected: `no task-attributed commit in the ${firstMainCheckoutViolation.repo} main checkout — a commit there reaches the shared branch without review; move it onto the acquired fusion/${task.id} worktree branch, restore the main checkout, then ask an operator to retry fn_task_done`, }; } const zeroAcquire = classifyWorkspaceZeroAcquire(task, {