FN-9058: block workspace main-checkout edits

Prevent workspace tasks from completing after task-era edits in configured sub-repository main checkouts.

- Detect dirty and recent commit evidence across every workspace main checkout before invariant checks.
- Block attributable edits with bounded retries while warning safely for inconclusive evidence.
- Add audit telemetry, documentation, changeset, and real-git regression coverage.

Files changed:
 .../fn-9058-workspace-main-checkout-guard.md       |   7 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |   1 +
 .../engine/src/__tests__/_workspace-fixture.ts     |   3 +-
 .../executor-workspace-main-checkout-guard.test.ts | 164 +++++++++++++++++++++
 packages/engine/src/executor/execution-prompt.ts   |   3 +-
 .../src/executor/workspace-main-checkout-guard.ts  | 128 ++++++++++++++++
 .../src/executor/worktree-verify-invariants.ts     |  45 +++++-
 packages/engine/src/util/run-audit.ts              |   2 +
 9 files changed, 351 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-9058

Fusion-Task-Lineage: b5dd58bd-4bd9-41fd-b3d5-e07270f3abca

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 00:41:44 -07:00
parent c7779e44ac
commit 43889bc684
9 changed files with 351 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent workspace tasks from completing after edits to a sub-repo main checkout.
category: fix
dev: Adds workspace-main-checkout-guard, main_checkout_edit precedence, retry-stable anchoring, warn-vs-block evidence handling, bounded HEAD commit scanning, and audit telemetry.

View File

@@ -305,6 +305,7 @@ Scoped exception (FN-5819/FN-8823): while project auto-merge is On, shared-branc
- FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move. - FN-6793/FN-6797: self-healing emits `task:reconcile-in-review-unmet-dependencies` when it rebounds an `in-review` task whose declared dependencies are still unmet, and `task:reconcile-in-review-unmet-dependencies-no-action` when pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation blocks that backward move.
- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` for proven branch absence or exhausted `evidence-unavailable` branch reads), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, a live sub-repo worktree (workspace-aware liveness), or `evidence-unavailable` blocks that backward move. The bounded evidence-exhaustion reason is `evidence-unavailable-exhausted`; audit metadata remains ids/counts/outcomes-only. - Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` for proven branch absence or exhausted `evidence-unavailable` branch reads), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, a live sub-repo worktree (workspace-aware liveness), or `evidence-unavailable` blocks that backward move. The bounded evidence-exhaustion reason is `evidence-unavailable-exhausted`; audit metadata remains ids/counts/outcomes-only.
- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor. Archived-role and soft-deleted owners are terminal; live merging, executing, or merge-pending owners are untouched. - Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor. Archived-role and soft-deleted owners are terminal; live merging, executing, or merge-pending owners are untouched.
- 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.
- 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-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. - 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.
- FN-7514: the planner overseer's per-task oversight loop (`PlannerRecoveryController.tick`) emits `overseer:oversight-withheld-human-control` when the pure `evaluateOverseerHumanControl` guard withholds ALL oversight action (no steering, retry, targeted-fix, or pending confirmation) for a task that is user-paused (`task.userPaused===true`, or `task.paused===true` with no `pausedReason`) or ineligible for auto-merge processing per `allowsAutoMergeProcessing` (`autoMerge:false`/PR-based human-review terminal contract). The guard runs BEFORE FN-7513's confirmation classification, so a withheld task never records a pending confirmation. Metadata: `{ taskId, reason: "user-paused" | "auto-merge-off-human-review", stage, oversightLevel }`; deduped per (taskId, withheld reason) so it is not re-emitted every poll while the reason is unchanged. - FN-7514: the planner overseer's per-task oversight loop (`PlannerRecoveryController.tick`) emits `overseer:oversight-withheld-human-control` when the pure `evaluateOverseerHumanControl` guard withholds ALL oversight action (no steering, retry, targeted-fix, or pending confirmation) for a task that is user-paused (`task.userPaused===true`, or `task.paused===true` with no `pausedReason`) or ineligible for auto-merge processing per `allowsAutoMergeProcessing` (`autoMerge:false`/PR-based human-review terminal contract). The guard runs BEFORE FN-7513's confirmation classification, so a withheld task never records a pending confirmation. Metadata: `{ taskId, reason: "user-paused" | "auto-merge-off-human-review", stage, oversightLevel }`; deduped per (taskId, withheld reason) so it is not re-emitted every poll while the reason is unchanged.

View File

@@ -627,6 +627,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
- **Planning**: the planning processor generates task plans (`PROMPT.md`) and selects eligible planning tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. Each attempt captures the authoritative artifact baseline and owns its fallback callback provenance. Only a settled, fallback-free attempt that changed that exact baseline and passes deterministic validation may hand off to workflow Plan Review. Empty, unchanged, or fallback-engaged attempts use the shared bounded `recoveryRetryCount`/`nextRecoveryAt` backoff; exhaustion persists an actionable planning error and never signals successful handoff. After a prompt settles, triage awaits the originating runtime's finite `settleFallbackDispatch` lifecycle signal, then awaits every observer callback admitted by that signal before deciding. A configured runtime that cannot supply this signal fails closed through the same bounded planning recovery rather than handing a potentially fallback-authored plan to review. This deliberately never inspects arbitrary Node timers: clean planner housekeeping can schedule unrelated one-shot or recurring timers without delaying admission. A callback from an obsolete attempt remains scoped to that attempt. Explicit duplicate-marker closure runs only after this same clean-attempt admission. If the stuck-task detector kills a not-yet-approved planning session after a non-empty `PROMPT.md` draft exists, the retry is requeued as `needs-replan` and seeds the next prompt in revision mode from that draft instead of cold-starting. A newly added dependency in a hold lane follows the same durable `needs-replan` path; it never clears status, so a planner interrupted after prompt persistence remains claimable and cannot silently bypass the approval/release handoff. When `PROMPT.md` is absent, a non-empty `plan` task document written through `fn_task_document_write` is the fallback seed; missing or whitespace-only drafts still cold-start. - **Planning**: the planning processor generates task plans (`PROMPT.md`) and selects eligible planning tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier. Each attempt captures the authoritative artifact baseline and owns its fallback callback provenance. Only a settled, fallback-free attempt that changed that exact baseline and passes deterministic validation may hand off to workflow Plan Review. Empty, unchanged, or fallback-engaged attempts use the shared bounded `recoveryRetryCount`/`nextRecoveryAt` backoff; exhaustion persists an actionable planning error and never signals successful handoff. After a prompt settles, triage awaits the originating runtime's finite `settleFallbackDispatch` lifecycle signal, then awaits every observer callback admitted by that signal before deciding. A configured runtime that cannot supply this signal fails closed through the same bounded planning recovery rather than handing a potentially fallback-authored plan to review. This deliberately never inspects arbitrary Node timers: clean planner housekeeping can schedule unrelated one-shot or recurring timers without delaying admission. A callback from an obsolete attempt remains scoped to that attempt. Explicit duplicate-marker closure runs only after this same clean-attempt admission. If the stuck-task detector kills a not-yet-approved planning session after a non-empty `PROMPT.md` draft exists, the retry is requeued as `needs-replan` and seeds the next prompt in revision mode from that draft instead of cold-starting. A newly added dependency in a hold lane follows the same durable `needs-replan` path; it never clears status, so a planner interrupted after prompt persistence remains claimable and cannot silently bypass the approval/release handoff. When `PROMPT.md` is absent, a non-empty `plan` task document written through `fn_task_document_write` is the fallback seed; missing or whitespace-only drafts still cold-start.
- **Executor**: `TaskExecutor` (`executor.ts`) implements tasks in worktrees - **Executor**: `TaskExecutor` (`executor.ts`) implements tasks in worktrees
- **Workspace acquisition shape:** per-repo acquisition persists only `workspaceWorktrees`; it never exposes a sub-repo path or branch through singular `task.worktree`/`task.branch`, including if the final workspace-state write fails. This preserves workspace classification for dashboard rendering, self-healing, and executor dispatch. - **Workspace acquisition shape:** per-repo acquisition persists only `workspaceWorktrees`; it never exposes a sub-repo path or branch through singular `task.worktree`/`task.branch`, including if the final workspace-state write fails. This preserves workspace classification for dashboard rendering, self-healing, and executor dispatch.
- **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.
- **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. - **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. --> <!-- 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. - **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

@@ -24,7 +24,8 @@ export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): voi
git(repoDir, 'git config user.name "Test"'); git(repoDir, 'git config user.name "Test"');
writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8"); writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8");
git(repoDir, "git add README.md"); git(repoDir, "git add README.md");
git(repoDir, "git commit -m 'init'"); // FNXC:Workspace 2026-08-15-07:05: Fixture initialization predates task anchors, so completion guards can distinguish operator baseline commits from task-era bypass commits.
git(repoDir, "GIT_AUTHOR_DATE='2000-01-01T00:00:00Z' GIT_COMMITTER_DATE='2000-01-01T00:00:00Z' git commit -m 'init'");
} }
export interface WorkspaceFixture { export interface WorkspaceFixture {

View File

@@ -0,0 +1,164 @@
/*
* 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.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { execSync } from "node:child_process";
import { mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { detectWorkspaceMainCheckoutWork, workspaceExecutionAnchor } from "../executor/workspace-main-checkout-guard.js";
import { verifyWorktreeInvariants } from "../executor/worktree-verify-invariants.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
const settings = {} as Settings;
function task(overrides: Partial<Task> = {}): Task {
const start = new Date(Date.now() + 1_000).toISOString();
return { id: "FN-1001", title: "guard", description: "", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: start, updatedAt: start, firstExecutionAt: start, executionStartedAt: start, ...overrides } as Task;
}
function invariantDeps(fixture: WorkspaceFixture, declaredScope: string[] = []) {
return {
rootDir: fixture.rootDir,
store: {
getSettings: vi.fn().mockResolvedValue(settings),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue(declaredScope),
} as unknown as TaskStore,
workspaceConfig: { repos: fixture.repos },
getActiveWorktreePaths: () => [],
getRunContextFor: () => undefined,
emitWorktreeReanchoredAudit: async () => undefined,
};
}
function addEmptyWorktree(fixture: WorkspaceFixture, repo = "repo-a"): { worktreePath: string; baseCommitSha: string } {
const baseCommitSha = fixture.git(repo, "git rev-parse HEAD");
const worktreePath = path.join(fixture.repoPath(repo), ".worktrees", "fn-1001");
fixture.git(repo, `git worktree add -b fusion/fn-1001 ${worktreePath} HEAD`);
return { worktreePath, baseCommitSha };
}
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 () => {
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");
fixture.git("repo-a", "git add src/outside.ts");
mkdirSync(path.join(fixture.repoPath("repo-b"), "src"), { recursive: true });
writeFileSync(path.join(fixture.repoPath("repo-b"), "src", "new.ts"), "export {};\n");
const activeTask = task();
const changed = new Date(Date.parse(activeTask.firstExecutionAt!) + 10_000);
await import("node:fs/promises").then(({ utimes }) => Promise.all([
utimes(path.join(fixture.repoPath("repo-a"), "src", "outside.ts"), changed, changed),
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");
});
it("uses firstExecutionAt instead of the later retry attempt anchor", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const first = new Date(Date.now() + 1_000).toISOString();
mkdirSync(path.join(fixture.repoPath("repo-a"), "src"), { recursive: true });
const retryFile = path.join(fixture.repoPath("repo-a"), "src", "retry.ts");
writeFileSync(retryFile, "export {};\n");
await import("node:fs/promises").then(({ utimes }) => utimes(retryFile, new Date(Date.parse(first) + 10_000), new Date(Date.parse(first) + 10_000)));
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" });
});
it("runs before no_commits in the production completion invariant and clears after remediation", 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");
unlinkSync(mainFile);
const remediated = await verifyWorktreeInvariants(invariantDeps(fixture), activeTask);
expect(remediated).toEqual({ ok: true });
});
it("detects clean-tree direct main commits without a base range", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const activeTask = task({ workspaceWorktrees: {} });
const file = path.join(fixture.repoPath("repo-a"), "committed.ts");
writeFileSync(file, "export const direct = true;\n");
const commitDate = new Date(Date.parse(activeTask.firstExecutionAt!) + 10_000).toISOString();
fixture.git("repo-a", "git add committed.ts");
fixture.git("repo-a", `GIT_AUTHOR_DATE='${commitDate}' GIT_COMMITTER_DATE='${commitDate}' git commit -m 'fix(FN-1001): direct main edit'`);
const sha = fixture.git("repo-a", "git rev-parse HEAD");
expect(fixture.git("repo-a", "git merge-base HEAD main")).toBe(sha);
const result = await verifyWorktreeInvariants(invariantDeps(fixture), activeTask);
expect(result).toMatchObject({ ok: false, reason: "main_checkout_edit", repo: "repo-a" });
expect(result.ok ? "" : result.observed).toContain(sha.slice(0, 12));
expect(result.ok ? "" : result.observed).toContain("task-attributed-commit");
});
it("keeps task-attributed backdated commits and skips configured non-repositories", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const activeTask = task({ workspaceWorktrees: {} });
const file = path.join(fixture.repoPath("repo-a"), "backdated.ts");
writeFileSync(file, "export const direct = true;\n");
fixture.git("repo-a", "git add backdated.ts");
fixture.git("repo-a", "GIT_AUTHOR_DATE='2000-01-01T00:00:00Z' GIT_COMMITTER_DATE='2000-01-01T00:00:00Z' git commit -m 'fix(FN-1001): skewed' ");
// The bypass commit predates acquisition, so it is already at the recorded base and a base..HEAD
// detector would be empty; task attribution must still make the bounded HEAD scan block it.
const acquired = addEmptyWorktree(fixture);
activeTask.workspaceWorktrees = { "repo-a": { ...acquired, branch: "fusion/fn-1001" } };
const direct = await detectWorkspaceMainCheckoutWork(
{ rootDir: fixture.rootDir, settings }, activeTask, ["repo-a", "repo-a/not-a-repo"], [],
);
expect(direct.violations).toContainEqual(expect.objectContaining({ repo: "repo-a", evidence: "task-attributed-commit" }));
expect(direct.skipped).toContain("repo-a/not-a-repo");
});
it("classifies task-era deletions from their parent directory mtime", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const activeTask = task();
const deleted = path.join(fixture.repoPath("repo-a"), "deleted.ts");
writeFileSync(deleted, "export {};\n");
fixture.git("repo-a", "git add deleted.ts && GIT_AUTHOR_DATE='2000-01-01T00:00:00Z' GIT_COMMITTER_DATE='2000-01-01T00:00:00Z' git commit -m baseline-deleted");
unlinkSync(deleted);
const parent = path.dirname(deleted);
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" }));
});
it("warns rather than blocks provably old operator dirt and ignores nested worktrees", async () => {
fixture = await createWorkspaceFixture(["repo-a"]);
const file = path.join(fixture.repoPath("repo-a"), "old.txt");
writeFileSync(file, "operator dirt\n");
const old = new Date(Date.now() - 120_000);
await import("node:fs/promises").then(({ utimes }) => utimes(file, old, old));
const nested = path.join(fixture.repoPath("repo-a"), ".worktrees", "task", "nested.ts");
mkdirSync(path.dirname(nested), { recursive: true });
writeFileSync(nested, "ignored\n");
const activeTask = task({ firstExecutionAt: new Date(Date.now() + 600_000).toISOString(), executionStartedAt: new Date(Date.now() + 600_000).toISOString() });
const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, activeTask, fixture.repos, []);
expect(result.violations).toEqual([]);
expect(result.warnings).toContainEqual(expect.objectContaining({ repo: "repo-a", reason: "pre-existing-dirt", files: ["old.txt"] }));
rmSync(path.dirname(path.dirname(nested)), { recursive: true, force: true });
});
});

View File

@@ -277,7 +277,8 @@ Do not repeatedly rerun a broad failing or hanging workspace command without a n
workspaceConfig.repos.map((r: string) => `- \`${r}\``).join("\n") + workspaceConfig.repos.map((r: string) => `- \`${r}\``).join("\n") +
`\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` + `\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` +
`with the repo name to get an isolated worktree path. ` + `with the repo name to get an isolated worktree path. ` +
`Work exclusively inside that returned path — never edit the repo's main checkout directly.\n`; /* 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`;
} }
return executionPrompt; return executionPrompt;

View File

@@ -0,0 +1,128 @@
/*
* FNXC:Workspace 2026-08-15-07:05:
* 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.
*/
import { exec } from "node:child_process";
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import type { Settings, Task } from "@fusion/core";
import { deriveRepoScopeSubset, normalizeRepoRelPath } from "../worktree/workspace-paths.js";
import { resolveWorktreesDir } from "../worktree/worktree-paths.js";
import { isAlwaysAllowedScopeLeakPath, workflowPathMatchesDeclaredScope } from "./workflow-feedback-paths.js";
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";
export type MainCheckoutFinding = { repo: string; files: string[]; commits: string[]; evidence: MainCheckoutEvidence };
export type MainCheckoutWarning = { repo: string; files: string[]; commits: string[]; reason: MainCheckoutWarningReason };
export type MainCheckoutGuardResult = { violations: MainCheckoutFinding[]; warnings: MainCheckoutWarning[]; skipped: string[] };
/** Earliest durable attempt timestamp, with a small filesystem clock tolerance. */
export function workspaceExecutionAnchor(task: Task): number | null {
const executionValues = [task.firstExecutionAt, task.executionStartedAt]
.map((value) => typeof value === "string" ? Date.parse(value) : Number.NaN)
.filter(Number.isFinite);
const values = executionValues.length
? executionValues
: [typeof task.createdAt === "string" ? Date.parse(task.createdAt) : Number.NaN].filter(Number.isFinite);
return values.length ? Math.min(...values) - 5_000 : null;
}
function isWithin(candidate: string, parent: string): boolean {
const relative = path.relative(parent, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
}
async function nearestMtime(filePath: string): Promise<number | null> {
let candidate = filePath;
while (true) {
try { return (await fs.stat(candidate)).mtimeMs; } catch { /* climb for deletions */ }
const parent = path.dirname(candidate);
if (parent === candidate) return null;
candidate = parent;
}
}
function parseStatus(stdout: string): string[] {
return stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean);
}
function parseCommits(stdout: string): Array<{ sha: string; committedAt: number; body: string }> {
return stdout.split("\x1e").filter(Boolean).flatMap((record) => {
const [sha, timestamp, ...body] = record.split("\x1f");
const committedAt = Number(timestamp) * 1000;
return sha && Number.isFinite(committedAt) ? [{ sha, committedAt, body: body.join("\x1f") }] : [];
});
}
/**
* Read-only evidence collector for task-era writes in configured workspace main checkouts.
* It intentionally scans status with untracked files and a bounded HEAD window rather than a
* diff-base range: a main checkout's branch advances with the bypass commit, making base..HEAD empty.
*/
export async function detectWorkspaceMainCheckoutWork(
deps: { rootDir: string; settings: Settings },
task: Task,
repos: readonly string[],
declaredScope: readonly string[],
): Promise<MainCheckoutGuardResult> {
const violations: MainCheckoutFinding[] = [];
const warnings: MainCheckoutWarning[] = [];
const skipped: string[] = [];
const anchor = workspaceExecutionAnchor(task);
const workspaceWorktrees = task.workspaceWorktrees ?? {};
const repoKeys = [...new Set([...repos, ...Object.keys(workspaceWorktrees)])].map(normalizeRepoRelPath).filter(Boolean).sort();
const recordedPaths = Object.values(workspaceWorktrees).map((entry) => path.resolve(entry.worktreePath));
for (const repo of repoKeys) {
const checkout = path.resolve(deps.rootDir, repo);
if (!existsSync(checkout) || recordedPaths.some((candidate) => candidate === checkout)) { skipped.push(repo); continue; }
try {
const { stdout: insideWorkTree } = await execAsync("git rev-parse --is-inside-work-tree", { ...probeOptions, cwd: checkout });
const { stdout: topLevel } = await execAsync("git rev-parse --show-toplevel", { ...probeOptions, cwd: checkout });
// FNXC:Workspace 2026-08-15-07:27:
// A configured path can sit inside an enclosing Git checkout without being a repository itself.
// Require its canonical top-level to be itself so an invalid repo entry cannot inspect unrelated
// operator work or consume fn_task_done's bounded refusal budget.
if (insideWorkTree.trim() !== "true" || await fs.realpath(topLevel.trim()) !== await fs.realpath(checkout)) {
skipped.push(repo);
continue;
}
} catch { skipped.push(repo); continue; }
const repoScope = deriveRepoScopeSubset(declaredScope, repo);
const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings));
const excluded = (file: string) => {
const absolute = path.resolve(checkout, file);
return file === ".fusion" || file.startsWith(".fusion/") || isWithin(absolute, worktreesDir) || recordedPaths.some((candidate) => isWithin(absolute, candidate));
};
let statusFiles: string[] = [];
try {
const { stdout } = await execAsync("git status --porcelain=v1 -uall --no-renames -z", { ...probeOptions, cwd: checkout });
statusFiles = parseStatus(stdout).filter((file) => !excluded(file));
} catch { skipped.push(repo); continue; }
const taskFiles: string[] = [];
const oldFiles: string[] = [];
for (const file of statusFiles) {
const mtime = await nearestMtime(path.resolve(checkout, file));
const inScope = !isAlwaysAllowedScopeLeakPath(file) && workflowPathMatchesDeclaredScope(file, repoScope);
if (inScope) taskFiles.push(file);
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" });
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 {
const { stdout } = await execAsync("git log -n 200 --format=%H%x1f%ct%x1f%B%x1e HEAD", { ...probeOptions, cwd: checkout });
const attributed = new RegExp(`(?:${task.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}|Fusion-Task-Id:\\s*${task.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "i");
for (const commit of parseCommits(stdout)) {
const evidence: MainCheckoutEvidence | null = attributed.test(commit.body) ? "task-attributed-commit" : anchor !== null && commit.committedAt >= anchor ? "post-anchor-commit" : null;
if (evidence) violations.push({ repo, files: [], commits: [commit.sha], evidence });
}
} catch { warnings.push({ repo, files: [], commits: [], reason: "commit-scan-unavailable" }); }
}
return { violations, warnings, skipped };
}

View File

@@ -25,12 +25,13 @@ import { classifyWorkspaceZeroAcquire } from "./workspace-zero-acquire.js";
import { evaluatePromptDerivedNoCommitEligibility } from "./prompt-derived-eligibility.js"; import { evaluatePromptDerivedNoCommitEligibility } from "./prompt-derived-eligibility.js";
import { getNoCommitEligibilityReason } from "./no-commit-eligibility.js"; import { getNoCommitEligibilityReason } from "./no-commit-eligibility.js";
import { resolveAuthoritativeExternalExecutionRoute } from "./resolve-authoritative-external-execution-route.js"; import { resolveAuthoritativeExternalExecutionRoute } from "./resolve-authoritative-external-execution-route.js";
import { detectWorkspaceMainCheckoutWork } from "./workspace-main-checkout-guard.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
export type WorktreeInvariantResult = export type WorktreeInvariantResult =
| { ok: true } | { ok: true }
| { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string; repo?: string }; | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits" | "main_checkout_edit"; observed: string; expected: string; repo?: string };
export type WorktreeInvariantDeps = { export type WorktreeInvariantDeps = {
rootDir: string; rootDir: string;
@@ -64,6 +65,48 @@ export async function verifyWorktreeInvariants(
// vacuously accepting work that review must honestly leave unavailable. // vacuously accepting work that review must honestly leave unavailable.
if (workspaceConfig) { if (workspaceConfig) {
const workspaceWorktrees = task.workspaceWorktrees ?? {}; const workspaceWorktrees = task.workspaceWorktrees ?? {};
const configuredRepos = Array.isArray((workspaceConfig as { repos?: unknown }).repos)
? (workspaceConfig as { repos: string[] }).repos
: [];
// FNXC:Workspace 2026-08-15-07:05:
// This must precede zero-acquire and per-worktree returns: a direct main-checkout commit
// leaves the acquired worktree empty, otherwise masking the actual, clearable bypass as no_commits.
const declaredScope = typeof deps.store.parseFileScopeFromPrompt === "function"
? await deps.store.parseFileScopeFromPrompt(task.id).catch(() => [] as string[])
: [];
const mainCheckout = await detectWorkspaceMainCheckoutWork(
{ rootDir: deps.rootDir, settings }, task, configuredRepos, declaredScope,
);
const auditor = createRunAuditor(deps.store, deps.getRunContextFor(task.id));
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",
} });
}
for (const repo of mainCheckout.skipped) {
await auditor.git({ type: "worktree:workspace-main-checkout-edit", target: repo, metadata: {
taskId: task.id, repo, fileCount: 0, commitCount: 0, taskDoneRetryCount: task.taskDoneRetryCount ?? 0, outcome: "skipped",
} });
}
const firstMainCheckoutViolation = mainCheckout.violations[0];
if (firstMainCheckoutViolation) {
await auditor.git({ type: "worktree:workspace-main-checkout-edit", target: firstMainCheckoutViolation.repo, metadata: {
taskId: task.id, repo: firstMainCheckoutViolation.repo, fileCount: firstMainCheckoutViolation.files.length,
commitCount: firstMainCheckoutViolation.commits.length, evidence: firstMainCheckoutViolation.evidence,
taskDoneRetryCount: task.taskDoneRetryCount ?? 0, outcome: "blocked",
} });
const observed = [
...firstMainCheckoutViolation.files.slice(0, 10),
...firstMainCheckoutViolation.commits.slice(0, 10).map((sha) => sha.slice(0, 12)),
].join(", ");
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 retry fn_task_done; only 3 requeue attempts are available`,
};
}
const zeroAcquire = classifyWorkspaceZeroAcquire(task, { const zeroAcquire = classifyWorkspaceZeroAcquire(task, {
workspaceMode: true, workspaceMode: true,
noOpCompletion: options?.noOpCompletion, noOpCompletion: options?.noOpCompletion,

View File

@@ -129,6 +129,8 @@ export type GitMutationType =
// -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed.
| "worktree:workspace-repo-acquire-busy" | "worktree:workspace-repo-acquire-busy"
| "worktree:workspace-repo-acquire-failed" | "worktree:workspace-repo-acquire-failed"
/* FNXC:Workspace 2026-08-15-07:05: Main-checkout guard reports only ids/counts/fixed outcomes. */
| "worktree:workspace-main-checkout-edit"
/** /**
* worktrunk run-audit metadata shape: * worktrunk run-audit metadata shape:
* *