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:
7
.changeset/fn-9058-workspace-main-checkout-guard.md
Normal file
7
.changeset/fn-9058-workspace-main-checkout-guard.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
128
packages/engine/src/executor/workspace-main-checkout-guard.ts
Normal file
128
packages/engine/src/executor/workspace-main-checkout-guard.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user