diff --git a/.changeset/fn-9058-workspace-main-checkout-guard.md b/.changeset/fn-9058-workspace-main-checkout-guard.md new file mode 100644 index 0000000000..ed1701eb90 --- /dev/null +++ b/.changeset/fn-9058-workspace-main-checkout-guard.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 81e073e56a..4b0a1ef1a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- 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/` 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-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. diff --git a/docs/architecture.md b/docs/architecture.md index d94543c6b1..632bc4cb27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. - **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. + - **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 `/.fusion/recovery/worktrees`, or to `/.fusion-recovery/worktrees` after an `EXDEV` cross-filesystem refusal. Each actual recovery root retains the newest 10 recognized Fusion-generated entries; pruning is fail-soft and preserves unknown, symlinked, unreadable, or active paths. Worktree pool and self-healing scans exclude both `.ai-merge` and `.fusion-recovery` as internal container boundaries. - **Execution-only reused-base refresh (FN-8693):** planning creates isolated worktrees but does not refresh them; immediately before a graph `code` node, normal executor dispatch, or durable-agent heartbeat session, refresh-enabled reuse resolves the current integration target C1 and compares it with durable `task.baseCommitSha`. A clean no-own-commit checkout resets to C1; a clean own-commit checkout rebases and retains its resulting C2 `HEAD`, while storing C1—not C2—as the baseline. A durable C0/C1 mismatch is rechecked from git and durable metadata on every acquisition, so restart reconciliation needs no in-memory marker. Dirty, unresolved, unsupported worktrunk, git, conflict, persistence, and unprovable-reconciliation cases are typed non-execution outcomes that park before session start. If baseline persistence fails after git moves `HEAD`, the engine compensates to the original clean checkout and emits `worktree:base-refresh-persistence-failed-compensated`; otherwise it requires later proof-based reconciliation. Audit events are `worktree:base-refreshed`, `worktree:base-refresh-blocked`, `worktree:base-refresh-conflict`, `worktree:base-refresh-persistence-failed-compensated`, and `worktree:base-refresh-reconciled`. Plan/review/gate acquisition and merger acquisition remain excluded; production merger behavior is the unified clean-room `runAiMerge` path. diff --git a/packages/engine/src/__tests__/_workspace-fixture.ts b/packages/engine/src/__tests__/_workspace-fixture.ts index 5e94b78982..d671cf942b 100644 --- a/packages/engine/src/__tests__/_workspace-fixture.ts +++ b/packages/engine/src/__tests__/_workspace-fixture.ts @@ -24,7 +24,8 @@ export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): voi git(repoDir, 'git config user.name "Test"'); writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8"); 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 { diff --git a/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts new file mode 100644 index 0000000000..f3dbc25f35 --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/packages/engine/src/executor/execution-prompt.ts b/packages/engine/src/executor/execution-prompt.ts index 9c105bbd7c..2227680fc0 100644 --- a/packages/engine/src/executor/execution-prompt.ts +++ b/packages/engine/src/executor/execution-prompt.ts @@ -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") + `\n\nBefore editing files in any sub-repo, call \`fn_acquire_repo_worktree\` ` + `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; diff --git a/packages/engine/src/executor/workspace-main-checkout-guard.ts b/packages/engine/src/executor/workspace-main-checkout-guard.ts new file mode 100644 index 0000000000..b8cb596d64 --- /dev/null +++ b/packages/engine/src/executor/workspace-main-checkout-guard.ts @@ -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 { + 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 { + 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 }; +} diff --git a/packages/engine/src/executor/worktree-verify-invariants.ts b/packages/engine/src/executor/worktree-verify-invariants.ts index c23f35c068..cb4d4339ac 100644 --- a/packages/engine/src/executor/worktree-verify-invariants.ts +++ b/packages/engine/src/executor/worktree-verify-invariants.ts @@ -25,12 +25,13 @@ import { classifyWorkspaceZeroAcquire } from "./workspace-zero-acquire.js"; import { evaluatePromptDerivedNoCommitEligibility } from "./prompt-derived-eligibility.js"; import { getNoCommitEligibilityReason } from "./no-commit-eligibility.js"; import { resolveAuthoritativeExternalExecutionRoute } from "./resolve-authoritative-external-execution-route.js"; +import { detectWorkspaceMainCheckoutWork } from "./workspace-main-checkout-guard.js"; const execAsync = promisify(exec); export type WorktreeInvariantResult = | { 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 = { rootDir: string; @@ -64,6 +65,48 @@ export async function verifyWorktreeInvariants( // vacuously accepting work that review must honestly leave unavailable. if (workspaceConfig) { 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, { workspaceMode: true, noOpCompletion: options?.noOpCompletion, diff --git a/packages/engine/src/util/run-audit.ts b/packages/engine/src/util/run-audit.ts index cdd5386400..01d46d79b8 100644 --- a/packages/engine/src/util/run-audit.ts +++ b/packages/engine/src/util/run-audit.ts @@ -129,6 +129,8 @@ export type GitMutationType = // -failed: a sub-repo worktree acquisition threw; surfaced + audited, never swallowed. | "worktree:workspace-repo-acquire-busy" | "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: *