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.
This commit is contained in:
Fusion Agent
2026-08-27 08:49:10 +00:00
parent 71b09e677d
commit 286dd0a7ae
8 changed files with 106 additions and 22 deletions

View File

@@ -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`.

View File

@@ -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/<id>` 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.

View File

@@ -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 `<project>/.fusion/recovery/worktrees`, or to `<worktreesDir>/.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.
<!-- FNXC:MergerUnification 2026-08-09-12:04: Master-plan U0 made clean-room `runAiMerge` the sole production merge path. The legacy `aiMergeTask` auto-prerebase policy is retained but inert, so executor reused-base refresh must not describe it as live merger behavior. -->
- **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.

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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, {