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:
7
.changeset/main-checkout-guard-commits-only.md
Normal file
7
.changeset/main-checkout-guard-commits-only.md
Normal 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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user