Merge branch 'main' into fix/research-pipeline-htmlUrl-to-url

This commit is contained in:
gsxdsm
2026-05-25 13:28:23 -07:00
committed by GitHub
218 changed files with 4756 additions and 1138 deletions

View File

@@ -1,5 +1,294 @@
# @fusion/engine
## 0.33.0
### Minor Changes
- 98033bc: feat(engine): guard one engine per project per machine
Adds a per-machine singleton lock that engages before each engine
starts, preventing two `fn` dashboard processes from running engines
for the same project on the same host (a scenario that previously
caused worktree corruption and task-state races for in-process
projects).
The guard combines two independent checks:
- A `proper-lockfile`-backed file at `<project>/.fusion/engine.lock`
with stale-lock recovery.
- A loopback listener (UDS on POSIX, named pipe on Windows) on a
hashed per-project address.
Failures throw `EngineAlreadyRunningError`; both guards are released
on `stopAll()` / `pauseProject()`.
- db9928a: feat(engine): export `smartPull()` library for stash-aware fast-forward of a worktree
Standalone stash → fast-forward → pop implementation that the merger's upcoming `mergeAdvanceAutoSync` hook calls after advancing the integration-branch ref to auto-sync other worktrees still pinned at the previous tip. Returns a discriminated union (`clean-pull | stash-pull-pop | stash-pop-conflict | skipped-dirty | skipped-not-on-branch | failed`) and accepts an optional audit emitter so callers can record `pull:fast-forward`, `stash:push`, `stash:pop`, and `stash:pop-conflict` run-audit events.
The dashboard's user-triggered Pull continues to use the existing `POST /api/git/pull` integration path (which runs the AI-aware autostash through `restoreUnrelatedRootDirChanges`) and is unchanged by this changeset — `smartPull()` is intentionally simpler so the merger's post-advance auto-sync stays free of mid-merge AI conflict resolution.
- 4c31e88: feat(engine): merger auto-syncs project-root checkout after advancing integration-branch ref
Wires `mergeAdvanceAutoSync` into the merger's post-ref-advance code path. After `advanceIntegrationBranchRef` ff-updates `refs/heads/<integrationBranch>`, the merger now enumerates other worktrees still on that branch (typically the user's project-root checkout) and reconciles each one's index + working tree to the new tip via `syncWorktreeToHead`.
The reconciliation primitive is **not** a `git pull` — origin may still be at the previous tip (no `pushAfterMerge`), in which case `git pull --ff-only` is a no-op and a naive `stash → pull → pop` ends with the worktree restored to the old state. Instead `syncWorktreeToHead`:
1. Diffs the worktree against the _previous_ tip to isolate real user edits from the stale-index "phantom diff" that looks like inverted commits.
2. When the worktree is clean against the previous tip, runs `git reset --hard HEAD` to snap index + files forward.
3. In `stash-and-ff` mode with real edits, captures them as a binary patch against the previous tip, snaps to HEAD, then `git apply --3way` to restore. Untracked files are copied to a temp dir and restored after the snap. Patch conflicts surface as `synced-with-pop-conflict` with the patch left on disk for manual recovery.
Each per-worktree attempt emits a `merge:auto-sync` audit event (new `GitMutationType`) with the outcome; the per-step `pull:fast-forward`, `stash:push`, `stash:pop`, and `stash:pop-conflict` events that pass through the auditor are tagged `metadata.autoSync = true` so downstream consumers can attribute them.
The user-facing effect: with the default `mergeAdvanceAutoSync: "stash-and-ff"`, after a Fusion task merges the user's `git status` in the project-root checkout becomes clean and the working tree shows the new commits' content — no manual `git reset` or Pull-button click required. Set `mergeAdvanceAutoSync: "off"` to restore the legacy behavior (the Merge Advance Notice banner still surfaces and the user pulls by hand).
Backstopped by `merger-auto-sync.slow.test.ts` covering: clean-sync snaps both index and files forward, ff-only with real edits is a no-op, stash-and-ff preserves untracked local files across the snap, task worktrees on `fusion/fn-*` branches are correctly skipped, and an empty branch map emits nothing.
- 51fc826: fix(engine,core): dedup heartbeat-spawned follow-ups by parent task
Heartbeat agents create follow-up tasks via `fn_task_create`. Until
now, the intake similarity guard scoped candidates by `sourceAgentId`
only, so the same parent task could spawn many sibling tasks across
heartbeats whenever triage rewrote their titles enough to dodge the
title-fingerprint guard.
The task-scoped heartbeat now stamps `sourceParentTaskId` (and
`sourceRunId`) on every `fn_task_create`, and the intake duplicate
matcher treats a candidate as a sibling when it shares either the
caller's agent ID or the caller's parent task ID. Same-parent
siblings with similar descriptions are auto-archived as before.
Tool description and heartbeat prompts also now instruct agents to
scan existing open tasks before creating, as a belt-and-suspenders
layer above the deterministic dedup.
- d02cd38: feat(merger): scope pnpm verification to changed packages and short-circuit out-of-scope fix loop
In a pnpm workspace, inferDefaultTestCommand now derives the set of packages touched by the branch diff and emits `pnpm --filter "<pkg>...^" test` instead of `pnpm test`. This prevents flakes in unrelated packages from blocking merges. When git context is unavailable or changes are root-only, the command falls back to the unscoped `pnpm test`.
When the in-merge fix agent makes no changes and all failing test files are outside the branch's diff, the merger now marks the task `status: "failed"` immediately with a clear "out-of-scope flake" message rather than retrying into the limbo-recovery cycle.
### Patch Changes
- 02971ef: fix(engine): treat foreign-attributed commits already on main as promoted
`assertCleanBranchAtBase` flagged any commit in `baseSha..branchName`
whose `Fusion-Task-Id` trailer pointed at a different task as
contamination. That misclassified the FN-5475 cascade: the engine
fast-forwards local `main` with single-parent task commits, and any
worktree created during the brief window where local `main` carried a
sibling task's tip inherited that commit. The audit later (correctly)
saw the commit as not-yet-on-main from its merge-base perspective and
threw `BranchCrossContaminationError`.
The audit now skips foreign-attributed commits that are reachable from
local `main` (`git merge-base --is-ancestor <sha> main`). Commits on
main were promoted through integration regardless of whose trailer
they carry, and downstream branches that inherited them via main are
not contaminated.
Resume verifier (FN-5475 fix #2) and the auto-recovery handler
fallback (FN-5475 fix #3) remain in place as defense-in-depth for
the rarer variants (local main rewound, foreign commit not yet on
main when the audit fires).
- 9ce26ee: fix(engine): un-deadcode the bootstrap-misbinding auto-recovery fallback
The auto-recovery handler in `auto-recovery-handlers/branch-worktree.ts`
called `classifyBootstrapMisbinding` with `foreignCommits: []` because it
had no `BranchCrossContaminationError` in hand (it discovers the conflict
via `inspectBranchConflict`). The classifier's predicate gated on
`foreignCommits.length > 0`, so the input always resolved to
`isBootstrapMisbinding: false` and the re-anchor block was effectively
dead code.
The handler also used `ctx.task.baseCommitSha` as the contamination base,
which is deliberately preserved across sessions for diff math (FN-4417)
and can lag local `main` by many commits — causing legitimately-merged
landings to be classified as foreign at this layer.
Changes:
- `classifyBootstrapMisbinding` now derives the foreign-commit count from
its own `git log baseSha..branchName` walk; `input.foreignCommits` is
optional and advisory only. The result type gains `foreignCommitCount`.
- The `branch-worktree` recovery handler stops passing an empty array and
computes a fresh merge-base against local `main` (falling back to
`origin/main`), mirroring the executor's primary contamination path.
- Regression tests cover both the no-`foreignCommits` call shape and the
`foreignCommitCount` field.
- e708870: fix(engine): verify resumed worktree branches aren't bootstrap-misbound
`acquireTaskWorktree` short-circuited the resume path when
`task.worktree` existed on disk and classified `ok`, handing the
worktree back to the executor without inspecting its branch history.
If the branch had been created from a poisoned local-main tip (a
sibling task's commit), the executor preflight would later flag every
intermediate landing as foreign and the task would loop through
contamination recovery until pausing for human adjudication
(observed in the FN-5475 cascade).
The resume path now computes a fresh merge-base against local `main`
(falling back to `origin/main`) and runs `classifyBootstrapMisbinding`
on the branch. When the range is purely foreign with zero own commits,
it re-anchors the branch inline via `reanchorBranchToBase` and emits a
`branch:reanchor` audit event with `trigger: "resume-misbinding"`.
Mixed contamination (own + foreign, or non-attributed commits) is
deliberately left to the executor's existing primary path so the
richer adjudication flow still applies.
- a3ec2e5: fix(engine): never create task branches from arbitrary HEAD in autocorrect
`attemptBranchAutocorrect` previously fell back to `git checkout -B
<expected>` with no start point when rename was not applicable. If the
worktree's HEAD happened to be at a previous occupant's commit (e.g. an
orphaned tip from a different task), the new branch label silently
captured that commit — the "branch: Created from HEAD" contamination
pattern that the cross-contamination guard then refuses to auto-resolve.
This is the only branch-creation site in the engine that did not thread
a resolved base SHA; every other path (`prepareForTask`,
`reanchorBranchToBase`) already passes the base explicitly.
Autocorrect now verifies the expected ref exists and uses a plain
`git checkout`, so it can only _switch to_ an already-existing branch.
When the ref is missing it returns `failed`, letting upstream recovery
(which knows the proper base) re-anchor with `prepareForTask` /
`reanchorBranchToBase`.
- 408e20b: fix(merger): two root-cause fixes for tasks landing in Done with no commit on main
**Bug 1: sibling fusion/fn-\* branch as merge target** — `resolveTaskMergeTarget`
previously returned `task.baseBranch` unconditionally before falling back to the
project default. When a task was dispatched as a sibling/dependent off another
in-flight task's worktree, `baseBranch` ended up as the upstream's
`fusion/fn-<id>` branch. The merger then detached onto that sibling, squashed
on top of it, and advanced `refs/heads/fusion/fn-<id>` — never main. FN-5233's
squash (`84563e549`) stranded on `fusion/fn-5339`; FN-5530's
(`4140a3e0a`) stranded on `fusion/fn-5543`. The resolver now refuses any
`fusion/fn-\*` candidate as a merge destination and falls through to the
project default. The merger emits a new `merge:merge-target-rejected-fusion-sibling`
audit event so the upstream `baseBranch`-propagation bug stays observable.
**Bug 2: deadlock-recovery mis-attributed tasks to unrelated commits** —
`findLandedTaskCommit` step (4) used `git log --grep=FN-XXXX` which matches the
entire commit message (not just the subject) and blindly accepted the first
hit. FN-5441 and FN-5446 were both marked done against `e3dbfaae` — an
FN-5483 commit whose body merely _mentioned_ them by name in a paragraph about
a refusal. The grep fallback now fetches each candidate's body and re-verifies
ownership via a tightened `commitOwnedByTask`: trailers must be line-anchored
(`(?:^|\n)Fusion-Task-Id: <id>(?:\n|$)`), and the subject fallback must match
a conventional-commit form (`<type>(<id>):` or `<id>:`), not a substring.
Prose mentions can no longer claim a task.
The historical recovery for FN-5233 has been cherry-picked to main as
`2d2e5b809`. The other 11 affected tasks (FN-5441, FN-5446, FN-5472, FN-5484,
FN-5487, FN-5490, FN-5515, FN-5517, FN-5526, FN-5539, FN-5540, FN-5542)
remain in Done but need separate triage — 3 look like legitimate
verification-only no-ops, the remaining 9 likely lost real work.
- acf3502: fix(merger): refuse to finalize a task as no-op when modifiedFiles is non-empty
Third root-cause fix for tasks marked Done with no commit on main (the first
two — sibling-branch merge target + grep mis-attribution — landed in the
previous commit). When the executor produced edits but the squash didn't
land them as a commit (uncommitted in the worktree, squashed against the
wrong branch, branch dropped by reuse-handoff churn, etc.), the merger's
`classifyOwnedLandedEvidence` would return `proven-no-op` or
`no-changes-finalized` and both `aiMergeTask` and `recoverNoOpReviewTasks`
would happily move the task to Done while clearing `modifiedFiles` to `[]`
— silently destroying the audit trail of what was lost.
Both call sites now gate the no-op finalize on `task.modifiedFiles.length`:
if the task claims work was done but no commit landed, move the task back
to `todo` with progress preserved and emit a new
`task:finalize-lost-work-blocked` audit event. The next executor run
re-attempts the work; the operator sees the audit event in the run-audit
timeline.
The post-hoc `reconcileDoneTaskIntegrity` path is intentionally NOT gated —
it cleans up tasks that are already in Done (legacy state), which is
out-of-scope for the lost-work prevention. This matters: 9 lost-work tasks
were already in this state at sweep time (FN-5441, FN-5446, FN-5487,
FN-5490, FN-5517, FN-5526, FN-5539, FN-5540, FN-5542) and need to be
re-spec'd as fresh tasks rather than auto-reconciled. See
`docs/incidents/2026-05-23-lost-work-tasks.md` for the per-task catalog.
- dc94494: fix(engine,dashboard): close 7 code-review findings on the mergeAdvanceAutoSync hook
Tightens the freshly-landed merger auto-sync feature based on a structured code review.
**Data-loss fixes in `syncWorktreeToHead`:**
- Untracked-file restore now compares against `git ls-tree -r --name-only HEAD` to detect when the new tip introduced a tracked file at the same path; collisions are reported in `untrackedSkippedAsTracked` and the user's bytes stay in the stage dir instead of clobbering the merged content.
- When `git apply --3way` fails because a patched file was deleted/renamed at the new tip (`--diff-filter=U` returns nothing because nothing got staged), `conflictedFiles` falls back to parsing `diff --git a/<p> b/<p>` headers out of the captured patch — so the conflict surfaces with the right file names instead of `[]`.
- `git ls-files` / `diff` calls now pass `-c core.quotePath=false` so paths with non-ASCII or special characters round-trip through `copyFileSync` instead of failing on backslash-escaped octal tokens.
- The stash-and-ff path re-verifies `rev-parse HEAD === newSha` immediately before each destructive `reset --hard HEAD`; a concurrent merger advance now bails with `skipped-head-not-at-new-sha` (with the captured patch preserved on disk) instead of applying the patch against the wrong tree.
- The stage dir is now tracked with a `preserveStageDir` flag in a `try/finally`: it is rm'd on all clean paths and on `skipped-head-not-at-new-sha` exits, but preserved whenever the user's edits live only in `patchPath` (pop-conflict, untracked-collides-with-tracked, reset failure, outer exception).
- Patch is written to disk before the apply attempt, not only on failure, so a crash between snapshot and apply doesn't lose the user's edits.
**Multi-worktree-same-branch fix:**
- New `getRegisteredWorktreeBranches` helper in `worktree-pool.ts` returns ALL `(branch, worktreePath)` entries rather than collapsing duplicates into a `Map<branch, path>`. Multiple worktrees can legitimately share a branch when the user created secondary checkouts via `git worktree add --force -b`; the merger now syncs every one of them instead of silently skipping all but the last.
**Contract + surfacing fixes:**
- JSDoc on `merge:auto-sync` GitMutationType now documents the actually-emitted outcome strings (`clean-sync`, `synced-with-edits-restored`, `synced-with-pop-conflict`, `skipped-*`, `failed`, `enumeration-failed`, `exception`) and the actual `stage` enum, replacing the obsolete `smartPull`-shaped strings.
- `GET /api/tasks/merge-advance-events` now joins `merge:auto-sync` events within a ±5min window of each advance and returns them in a new `autoSync: AutoSyncOutcome[]` field; `useMergeAdvanceNotice` exposes the same shape so the banner can surface pop-conflicts (including `patchPath` pointing at the user's saved edits) instead of leaving them in a black hole.
**Hygiene:**
- Merger's setting read now uses `normalizeMergeAdvanceAutoSyncMode(settings.mergeAdvanceAutoSync)` (the exported normalizer) instead of an inline equality check + `as unknown` cast that bypassed type-checking.
**New backstop tests** in `merger-auto-sync.slow.test.ts`:
- Untracked file colliding with a newly-tracked path is NOT overwritten and the merged content survives.
- `git apply --3way` failure on a file deleted at the new tip populates `conflictedFiles` from the patch header.
**Route test** asserts `autoSync` outcomes are joined onto the matching advance event within the time window.
- bf4428c: fix(merger): require fast-forward ref advances and read integration tip from refs/heads/&lt;branch&gt;
Closes a class of "orphaned merge" bug where a subsequent merger could overwrite the integration branch tip with a sibling commit, leaving the previous squash reachable only from a feature branch.
Two coupled fixes:
1. `advanceIntegrationBranchRef` now refuses non-fast-forward advances. The CAS check still guards against concurrent ref movement, but the new `merge-base --is-ancestor` check additionally requires the new sha to descend from the expected current sha. Non-FF attempts return `reason: "non-fast-forward-advance"` instead of silently orphaning the prior tip.
2. `runMerge` resolves the integration-branch tip via `git rev-parse --verify refs/heads/<integrationBranch>` instead of `git rev-parse HEAD` in `rootDir`. In reuse-task-worktree mode, `rootDir`'s HEAD can lag behind the shared ref after a sibling merger advanced it via `update-ref` without re-checking-out — using HEAD there caused the eventual squash commit to parent off an earlier sha and orphan the previously-merged tip.
Together these uphold the invariant: local `<integrationBranch>` only advances via fast-forward, and the merger never builds a squash off a stale base sha.
- 0c0839e: fix(merger): treat non-FF ref-advance as concurrent-advance so it triggers retry
When the merger's squash commit was built off a stale integration tip (integration moved between squash prep and `update-ref`), the FF guard in `advanceIntegrationBranchRef` correctly refused the swap with reason `non-fast-forward-advance` — but the caller in `merger.ts` only mapped `concurrent-advance` to `IntegrationBranchConcurrentAdvanceError`. The non-FF case fell through as a plain `Error`, failing the task instead of routing to the FN-4500/FN-5083 rebind/retry path. Both reasons share a root cause (integration tip moved during the merge window), so they now share the retry path. Observed on FN-5576.
- ec1269f: feat(merger): auto-rehome FF-recoverable orphan commits during contamination recovery
Follow-up to the FF-only ref advance fix: contamination recovery now classifies a fourth bucket — `orphan-our-advance` — and fast-forwards the integration branch onto pre-fix orphan commits when safe.
When the executor's contamination handler sees a "unique" foreign commit, it now also asks:
- Does the commit's `Fusion-Task-Id` trailer point at a `done` task?
- Is the commit unreachable from `refs/heads/<integrationBranch>`?
If both, the commit is an orphan from the pre-fix non-FF ref-advance bug. Recovery attempts to fast-forward the integration branch onto the orphan:
- **FF possible** (integration tip is an ancestor of the orphan): advance via `advanceIntegrationBranchRef`, then drop the orphan from the task branch alongside `already-upstream` commits. Emits `merger:orphan-rehome-ff`.
- **Non-FF** (orphan diverges from integration tip — would require cherry-pick): refuse to auto-rehome. The commit stays in `genuinelyUnique` for human adjudication, but the recovery log line now includes the exact `git cherry-pick <sha>` command an operator can run to unstick it. Emits `merger:orphan-rehome-refused`.
The non-FF refusal is intentional: cherry-pick into the integration branch from inside automated recovery introduces conflict-resolution surface that's too high blast radius for a never-event recovery path.
- Updated dependencies [408e20b]
- Updated dependencies [ec6643e]
- Updated dependencies [a201f56]
- Updated dependencies [4c31e88]
- Updated dependencies [51fc826]
- @fusion/core@0.33.0
- @fusion/pi-claude-cli@0.33.0
## 0.32.0
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@fusion/engine",
"version": "0.32.0",
"version": "0.33.0",
"license": "MIT",
"description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",

View File

@@ -151,7 +151,13 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
it("auto-finalizes proven no-op and clears stale modifiedFiles", async () => {
// FN-5490/FN-5517/FN-5526/FN-5540 regression: the previous contract here
// was "auto-finalize proven no-op and clear stale modifiedFiles", which
// turned out to be the bug — claimed modifiedFiles + no commit = lost work
// (uncommitted in the worktree or squashed against the wrong branch), not
// a legitimate no-op. The merger now refuses to finalize and moves the
// task back to todo with progress preserved instead.
it("FN-5490: refuses no-op finalize when modifiedFiles are claimed without a commit", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-noop-finalize-"));
repos.push(repo);
git(repo, "git init -b main");
@@ -183,10 +189,17 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
const store = createStore(task);
const result = await aiMergeTask(store, repo, "FN-C");
expect(result.merged).toBe(true);
expect(result.noOpMerge).toBe(true);
expect((store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(([, patch]) => patch?.modifiedFiles?.length === 0)).toBe(true);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(true);
// Lost-work guard fires — task does NOT advance to done, does NOT have
// modifiedFiles cleared, and gets moved back to todo with progress.
expect(result.merged).toBe(false);
expect(result.error).toMatch(/lost-work/);
expect(
(store.updateTask as ReturnType<typeof vi.fn>).mock.calls.some(
([, patch]) => Array.isArray(patch?.modifiedFiles) && patch.modifiedFiles.length === 0,
),
).toBe(false);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "done")).toBe(false);
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "todo")).toBe(true);
}, 20_000);
it("blocks FN-4653 shape: foreign start-point branch with no FN-owned commits", async () => {

View File

@@ -970,7 +970,15 @@ describe("aiMergeTask — merge details collection", () => {
});
const result = await aiMergeTask(store, "/tmp/root", "FN-3469");
expect(result.merged).toBe(false);
expect(result.merged).toBe(true);
expect((store.emit as ReturnType<typeof vi.fn>).mock.calls).toContainEqual([
"task:merged",
expect.objectContaining({
merged: true,
mergeConfirmed: true,
commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a",
}),
]);
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
(call: any[]) => call[1]?.mergeDetails !== undefined,
@@ -1100,4 +1108,3 @@ describe("aiMergeTask — merge details collection", () => {
});
});

View File

@@ -630,9 +630,65 @@ describe("aiMergeTask — build verification", () => {
expect(installCall).toBeDefined();
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
"Syncing dependencies before merge build verification: pnpm install --frozen-lockfile",
"Syncing dependencies before merge verification: pnpm install --frozen-lockfile",
);
});
it("syncs dependencies before test verification when install state is missing", async () => {
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
mockedExistsSync.mockImplementation((path: any) => {
const pathStr = String(path);
if (pathStr.includes("node_modules") || pathStr.endsWith(".pnp.cjs")) return false;
return true;
});
let cachedQuietChecks = 0;
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "2 files changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --name-only --diff-filter=U")) return "" as any;
if (cmdStr.includes("git diff --cached --name-only")) {
return "package.json\npackages/desktop/package.json" as any;
}
if (cmdStr.includes("pnpm install --frozen-lockfile")) return "Lockfile is up to date" as any;
if (cmdStr.includes("diff --cached --quiet")) {
cachedQuietChecks += 1;
return cachedQuietChecks === 1 ? "1" as any : "0" as any;
}
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051" },
[{ id: "FN-051", worktree: "/tmp/root/.worktrees/KB-051", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
testCommand: "pnpm test",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-051");
expect(result.merged).toBe(true);
expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).includes("pnpm install --frozen-lockfile")),
).toBe(true);
});
});
// ── Deterministic Merge Verification Tests ──────────────────────────────
@@ -2868,6 +2924,10 @@ describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
expect(result?.command).toBe(`pnpm --filter "@fusion/dashboard...^" test`);
expect(result?.testSource).toBe("inferred-scoped");
expect(mockedExecSync).toHaveBeenCalledWith(
'git diff --name-only "main"..."fusion/fn-123"',
expect.objectContaining({ cwd: "/tmp/root", encoding: "utf-8" }),
);
});
it("returns command with 2 filters when 2 packages are changed", () => {

View File

@@ -279,6 +279,13 @@ describe("PluginRunner", () => {
pluginRunner.invokeHook("onLoad")
).rejects.toThrow("Hook failed");
});
it("should isolate hook invocation errors in invokeHookSafe", async () => {
mockPluginLoader.invokeHook = vi.fn().mockRejectedValue(new Error("Hook failed"));
await pluginRunner.init();
await expect(pluginRunner.invokeHookSafe("onLoad")).resolves.toBeUndefined();
});
});
describe("getPluginTools()", () => {

View File

@@ -122,7 +122,6 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
try {
const rootHeadBefore = git(rootDir, "git rev-parse HEAD");
const rootTrackedStatusBefore = git(rootDir, "git status --porcelain --untracked-files=no");
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
@@ -141,9 +140,12 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
const advanced = audits.find((event) => event.mutationType === "merge:integration-ref-advance");
expect(advanced?.metadata).toMatchObject({ advanceMode: "update-ref", succeeded: true });
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
const rootTrackedStatusAfter = git(rootDir, "git status --porcelain --untracked-files=no");
expect(rootTrackedStatusAfter).not.toBe(rootTrackedStatusBefore);
expect(rootTrackedStatusAfter).toContain("fn-5279-ri-happy.ts");
// 4c31e885b (engine auto-sync) keeps the project root's working tree
// in step with the advanced ref, so the new file is a tracked, clean
// path at HEAD rather than appearing as a dirty/untracked entry. Verify
// landing via `git ls-files` (commit-reachable) instead of `git status`.
const rootLsFilesAfter = git(rootDir, "git ls-files");
expect(rootLsFilesAfter).toContain("packages/engine/src/fn-5279-ri-happy.ts");
} finally {
await fixture.cleanup();
}

View File

@@ -0,0 +1,126 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DEFAULT_SETTINGS, TaskStore, type Task } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Scheduler } from "../../scheduler.js";
import { SelfHealingManager } from "../../self-healing.js";
type Fixture = { rootDir: string; store: TaskStore; scheduler: Scheduler; selfHealing: SelfHealingManager };
async function createFixture(autoMerge = true): Promise<Fixture> {
const rootDir = await mkdtemp(join(tmpdir(), "fusion-fn5566-"));
await mkdir(join(rootDir, ".fusion"), { recursive: true });
await writeFile(join(rootDir, "README.md"), "# test\n", "utf8");
const store = new TaskStore(rootDir, undefined, { inMemoryDb: true });
await store.init();
await store.updateSettings({ ...DEFAULT_SETTINGS, autoMerge } as any);
const scheduler = new Scheduler(store as any);
const selfHealing = new SelfHealingManager(store, { rootDir, getExecutingTaskIds: () => new Set<string>() });
return { rootDir, store, scheduler, selfHealing };
}
async function createTask(store: TaskStore, input: Partial<Task>): Promise<Task> {
return store.createTask({ title: "task", description: "task", prompt: "## File Scope\n- packages/engine/src/**\n", steps: [], ...input } as any);
}
describe("reliability interactions: FN-5566 / FN-5446 soft-delete blocker residue", () => {
const fixtures: Fixture[] = [];
afterEach(async () => {
while (fixtures.length) {
const fx = fixtures.pop()!;
fx.scheduler.stop();
fx.selfHealing.stop();
fx.store.close();
await rm(fx.rootDir, { recursive: true, force: true });
}
});
it("covers direct-delete blocker residue and blockedBy-only paths", async () => {
const fx = await createFixture();
fixtures.push(fx);
const blocker = await createTask(fx.store, { column: "todo" });
const other = await createTask(fx.store, { column: "todo" });
const depA = await createTask(fx.store, { column: "todo", status: "blocked", dependencies: [blocker.id], blockedBy: blocker.id });
const depB = await createTask(fx.store, { column: "todo", status: "blocked", dependencies: [other.id], blockedBy: blocker.id });
await fx.store.deleteTask(blocker.id, { removeDependencyReferences: true });
const depAAfter = await fx.store.getTask(depA.id);
const depBAfter = await fx.store.getTask(depB.id);
expect(depAAfter.blockedBy ?? null).toBeNull();
expect(depAAfter.status ?? null).toBeNull();
expect(depAAfter.dependencies).not.toContain(blocker.id);
expect(depBAfter.blockedBy ?? null).toBeNull();
expect(depBAfter.status ?? null).toBeNull();
expect(depBAfter.dependencies).toEqual([other.id]);
});
it("event-driven reconciliation reblocks dependents to next unresolved dependency", async () => {
const fx = await createFixture();
fixtures.push(fx);
const blocker = await createTask(fx.store, { column: "in-progress" });
const other = await createTask(fx.store, { column: "todo" });
const dep = await createTask(fx.store, { column: "todo", status: "blocked", blockedBy: blocker.id, dependencies: [other.id, blocker.id] });
const now = new Date().toISOString();
const db = fx.store.getDatabase();
db.prepare("UPDATE tasks SET deletedAt = ?, \"column\" = 'archived', updatedAt = ? WHERE id = ?").run(now, now, blocker.id);
fx.store.emit("task:deleted", await fx.store.getTask(blocker.id, { includeDeleted: true }));
await vi.waitFor(async () => {
const depAfter = await fx.store.getTask(dep.id);
expect(depAfter.blockedBy).toBe(other.id);
expect(depAfter.status).toBe("queued");
});
});
it("reconciles soft-delete column drift with audit and preserves FN-5208 invariants", async () => {
const fx = await createFixture();
fixtures.push(fx);
const drift = await createTask(fx.store, { column: "in-review" });
await fx.store.deleteTask(drift.id);
const db = fx.store.getDatabase();
db.prepare("UPDATE tasks SET \"column\" = 'in-review' WHERE id = ?").run(drift.id);
const first = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
const second = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
const row = db.prepare("SELECT deletedAt, \"column\" as column, allowResurrection FROM tasks WHERE id = ?").get(drift.id) as any;
expect(first.reconciled).toBe(1);
expect(second.reconciled).toBe(0);
expect(row.column).toBe("archived");
expect(row.deletedAt).toBeTruthy();
expect(row.allowResurrection).toBe(0);
const auditEvents = (fx.store as any).getRunAuditEvents({ mutationType: "task:soft-delete-column-reconciled", limit: 10 }) as any[];
expect(auditEvents).toHaveLength(1);
});
it("clearStaleBlockedBy handles missed task:deleted event with soft-deleted-blocker reason", async () => {
const fx = await createFixture();
fixtures.push(fx);
const blocker = await createTask(fx.store, { column: "todo" });
const dep = await createTask(fx.store, { column: "todo", status: "blocked", blockedBy: blocker.id, dependencies: [] });
await fx.store.deleteTask(blocker.id, { removeDependencyReferences: true });
await fx.store.updateTask(dep.id, { blockedBy: blocker.id, status: "blocked" as any });
await fx.selfHealing.clearStaleBlockedBy();
const depAfter = await fx.store.getTask(dep.id);
expect(depAfter.blockedBy ?? null).toBeNull();
expect(depAfter.log.some((entry) => entry.action.includes("soft-deleted at"))).toBe(true);
});
it("FN-5147 composition: live in-review tasks remain untouched when autoMerge=false", async () => {
const fx = await createFixture(false);
fixtures.push(fx);
const live = await createTask(fx.store, { column: "in-review", status: "failed" });
const result = await fx.selfHealing.reconcileSoftDeletedColumnDrift();
const liveAfter = await fx.store.getTask(live.id);
expect(result.reconciled).toBe(0);
expect(liveAfter.column).toBe("in-review");
});
});

View File

@@ -58,7 +58,7 @@ function createStore(tasks: TestTask[], leakDeleted = false) {
return store as any;
}
describe("reliability interactions: FN-5528 soft-delete deadlock scan exclusion", () => {
describe("reliability interactions: FN-5566/FN-5528 soft-delete deadlock scan exclusion", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-22T02:00:00.000Z"));

View File

@@ -44,8 +44,9 @@ describe("sandbox wiring", () => {
});
const stub = makeStub({ run });
__setSandboxBackendForTests(stub);
const controller = new AbortController();
const result = await __runConfiguredCommandForTests("echo hi", "/tmp", 1200, { A: "1" });
const result = await __runConfiguredCommandForTests("echo hi", "/tmp", 1200, { A: "1" }, undefined, controller.signal);
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("echo hi", {
@@ -54,6 +55,7 @@ describe("sandbox wiring", () => {
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
env: { A: "1" },
signal: controller.signal,
});
expect((stub.runStreaming as any)).not.toHaveBeenCalled();
expect(result).toMatchObject({
@@ -77,6 +79,7 @@ describe("sandbox wiring", () => {
bufferExceeded: false,
});
__setSandboxBackendForTests(makeStub({ run }));
const controller = new AbortController();
const result = await __executePostMergeScriptStepForTests(
{ updateTask: vi.fn() } as any,
@@ -84,6 +87,8 @@ describe("sandbox wiring", () => {
{ scriptName: "post" } as any,
"/tmp/worktree",
{ scripts: { post: "echo post" } } as any,
undefined,
controller.signal,
);
expect(result.success).toBe(true);
@@ -92,6 +97,7 @@ describe("sandbox wiring", () => {
encoding: "utf-8",
timeoutMs: 120_000,
maxBuffer: 10 * 1024 * 1024,
signal: controller.signal,
});
});

View File

@@ -520,6 +520,37 @@ describe("Scheduler", () => {
expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted");
});
it("FN-5496: task:deleted clears blockedBy but preserves status for in-progress dependents", async () => {
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
const dependent = createMockTask({
id: "FN-DEP",
column: "in-progress",
blockedBy: "FN-DEL",
status: "running",
dependencies: ["FN-DEL"],
});
const tasks = [dependent];
const listTasks = vi.fn(async (options?: { column?: string; includeArchived?: boolean }) => {
if (options?.column === "todo") return tasks.filter((task) => task.column === "todo");
if (options?.column === "in-progress") return tasks.filter((task) => task.column === "in-progress");
return tasks;
});
const store = createMockStore({
listTasks,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4, globalPause: false, enginePaused: false }),
});
new Scheduler(store);
const deletedHandler = (store.on as any).mock.calls.find((call: any) => call[0] === "task:deleted")?.[1];
deletedHandler(deleted);
await flushAsyncWork();
expect(store.updateTask).toHaveBeenCalledWith("FN-DEP", { blockedBy: null });
expect(store.updateTask).not.toHaveBeenCalledWith("FN-DEP", expect.objectContaining({ status: null }));
expect(store.logEntry).toHaveBeenCalledWith("FN-DEP", "Auto-unblocked (FN-5496): blocker FN-DEL was soft-deleted");
});
it("FN-5496: task:deleted repoints blockedBy when another dependency remains unresolved", async () => {
const deleted = createMockTask({ id: "FN-DEL", column: "todo" });
const live = createMockTask({ id: "FN-LIVE", column: "in-progress" });

View File

@@ -6172,7 +6172,7 @@ describe("clearStaleBlockedBy", () => {
column: "in-review",
status: "failed",
mergeRetries: 0,
error: "Refusing to start coding agent in missing worktree: /Users/eclipxe/Projects/kb/.worktrees/bright-wren",
error: "Refusing to start coding agent in missing worktree: /tmp/test-project/.worktrees/bright-wren",
steps: [{ status: "done" }, { status: "pending" }] as any,
});
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });

View File

@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { detectDanglingTaskDocReferences, formatDanglingDiagnostic } from "../spec-validation/task-document-references.js";
describe("detectDanglingTaskDocReferences", () => {
@@ -81,9 +80,4 @@ describe("detectDanglingTaskDocReferences", () => {
expect(formatted).toContain("REVISE — Dangling task-document references");
expect(formatted).toContain("Step 0, Step 4, Step 5");
});
it("can read the FN-5110 fixture prompt", async () => {
const fixture = await readFile("/Users/eclipxe/Projects/kb/.fusion/tasks/FN-5110/PROMPT.md", "utf8");
expect(fixture).toContain("# Task: FN-5110");
});
});

View File

@@ -15,9 +15,9 @@ describe("worktree-stale-registration", () => {
});
it("parseStaleRegistrationPath parses FN-5056 fixture", () => {
const fixture = `Failed to create worktree: Command failed: git worktree add \"/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive\" \"fusion/fn-4995\"\nPreparing worktree (checking out 'fusion/fn-4995')\nfatal: '/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive' is a missing but already registered worktree; use 'add -f' to override, or 'prune' or 'remove' to clear`;
const fixture = `Failed to create worktree: Command failed: git worktree add \"/repo/.worktrees/fast-tiger/.worktrees/happy-olive\" \"fusion/fn-4995\"\nPreparing worktree (checking out 'fusion/fn-4995')\nfatal: '/repo/.worktrees/fast-tiger/.worktrees/happy-olive' is a missing but already registered worktree; use 'add -f' to override, or 'prune' or 'remove' to clear`;
expect(parseStaleRegistrationPath(fixture)).toBe(
"/Users/eclipxe/Projects/kb/.worktrees/fast-tiger/.worktrees/happy-olive",
"/repo/.worktrees/fast-tiger/.worktrees/happy-olive",
);
});

View File

@@ -1,3 +1,4 @@
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the executor prompt.
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
@@ -594,6 +595,7 @@ async function runConfiguredCommand(
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<RunCommandResult> {
const backend = getConfiguredCommandSandboxBackend(auditor);
const result = await backend.run(command, {
@@ -602,6 +604,7 @@ async function runConfiguredCommand(
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
...(extraEnv !== undefined && { env: extraEnv }),
...(signal !== undefined && { signal }),
});
return {
@@ -621,8 +624,9 @@ export async function __runConfiguredCommandForTests(
timeoutMs: number,
extraEnv?: NodeJS.ProcessEnv,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<RunCommandResult> {
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv, auditor);
return runConfiguredCommand(command, cwd, timeoutMs, extraEnv, auditor, signal);
}
// ── Tool parameter schemas (module-level for reuse in ToolDefinition generics) ──
@@ -1048,6 +1052,8 @@ export class TaskExecutor {
private activeStepExecutors = new Map<string, StepSessionExecutor>();
/** Active pre-merge workflow step sessions per task. */
private activeWorkflowStepSessions = new Map<string, AgentSession>();
/** Active configured-command abort controllers keyed by task. */
private activeConfiguredCommandControllers = new Map<string, Set<AbortController>>();
private readonlyWorkflowStepAuditDone = false;
/**
* Reviewer subagent sessions per task. Reviewers (`reviewer.ts`) create their
@@ -1130,6 +1136,27 @@ export class TaskExecutor {
}
}
private registerConfiguredCommandController(taskId: string, controller: AbortController): void {
const controllers = this.activeConfiguredCommandControllers.get(taskId) ?? new Set<AbortController>();
controllers.add(controller);
this.activeConfiguredCommandControllers.set(taskId, controllers);
}
private unregisterConfiguredCommandController(taskId: string, controller: AbortController): void {
const controllers = this.activeConfiguredCommandControllers.get(taskId);
if (!controllers) return;
controllers.delete(controller);
if (controllers.size === 0) {
this.activeConfiguredCommandControllers.delete(taskId);
}
}
private createConfiguredCommandAbortError(taskId: string, command: string): Error {
const error = new Error(`Configured command aborted for ${taskId}: ${command}`);
error.name = "AbortError";
return error;
}
private getAutoRecoveryDispatcher(audit: RunAuditor): AutoRecoveryDispatcher {
if (this.options.autoRecoveryDispatcher) return this.options.autoRecoveryDispatcher;
const fileScopeHandler = createFileScopeAutoRecoveryHandler({
@@ -1274,6 +1301,18 @@ export class TaskExecutor {
return true;
}
if ((latestTask && latestTask.column !== "in-progress") || this.userCanceledTaskIds.has(taskId)) {
this.clearCompletedTaskWatchdog(taskId);
executorLog.log(`${taskId}: completion handoff deferred — task no longer active (${context})`);
await this.store.logEntry(
taskId,
`Completion handoff deferred — task no longer active (${context})`,
undefined,
this.getRunContextFor(taskId),
).catch(() => undefined);
return true;
}
return this.shouldDeferCompletionForGlobalPause(taskId, context);
}
@@ -1608,6 +1647,14 @@ export class TaskExecutor {
hadActiveSurface = true;
this.deleteActiveWorkflowStepSession(taskId);
}
const claimedConfiguredCommands = this.activeConfiguredCommandControllers.get(taskId);
if (claimedConfiguredCommands && claimedConfiguredCommands.size > 0) {
hadActiveSurface = true;
this.activeConfiguredCommandControllers.delete(taskId);
for (const controller of claimedConfiguredCommands) {
controller.abort();
}
}
const claimedSubagents = this.activeSubagentSessions.has(taskId);
if (claimedSubagents) {
hadActiveSurface = true;
@@ -1671,6 +1718,7 @@ export class TaskExecutor {
...this.activeSessions.keys(),
...this.activeStepExecutors.keys(),
...this.activeWorkflowStepSessions.keys(),
...this.activeConfiguredCommandControllers.keys(),
...this.activeSubagentSessions.keys(),
]);
@@ -1810,6 +1858,7 @@ export class TaskExecutor {
this.activeSessions.has(task.id)
|| this.activeStepExecutors.has(task.id)
|| this.activeWorkflowStepSessions.has(task.id)
|| this.activeConfiguredCommandControllers.has(task.id)
)
) {
executorLog.log(`Pausing ${task.id} — awaiting in-flight session disposal`);
@@ -1982,6 +2031,18 @@ export class TaskExecutor {
// When globalPause transitions from false → true, terminate all active agent sessions.
store.on("settings:updated", ({ settings, previous }) => {
if (settings.globalPause && !previous.globalPause) {
for (const [taskId, controllers] of this.activeConfiguredCommandControllers) {
executorLog.log(`Global pause — aborting configured command(s) for ${taskId}`);
this.pausedAborted.add(taskId);
this.options.stuckTaskDetector?.untrackTask(taskId);
for (const controller of controllers) {
controller.abort();
}
this.activeConfiguredCommandControllers.delete(taskId);
this.loopRecoveryState.delete(taskId);
this.spawnedAgents.delete(taskId);
this.stuckAborted.delete(taskId);
}
// Dispose every reviewer subagent across every task. The per-task loops
// below handle main + step sessions; reviewers live in their own map
// and would otherwise outlive the global pause.
@@ -3082,21 +3143,42 @@ export class TaskExecutor {
}
const hadAssignedWorktree = Boolean(task.worktree);
const acquisition = await acquireTaskWorktree({
task,
rootDir: this.rootDir,
store: this.store,
settings,
pool: this.options.pool,
logger: executorLog,
audit,
runContext: this.getRunContextFor(task.id),
runInitCommand: true,
createWorktree: this.createWorktree.bind(this),
runConfiguredCommand,
taskEnv,
secretsStore: this.options.secretsStore,
});
const taskCommandAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
const acquisition = await (async () => {
try {
return await acquireTaskWorktree({
task,
rootDir: this.rootDir,
store: this.store,
settings,
pool: this.options.pool,
logger: executorLog,
audit,
runContext: this.getRunContextFor(task.id),
runInitCommand: true,
createWorktree: this.createWorktree.bind(this),
runConfiguredCommand: (command, cwd, timeoutMs, env) =>
runConfiguredCommand(
command,
cwd,
timeoutMs,
env,
audit,
taskCommandAbortController.signal,
).then((result) => {
if (taskCommandAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, command);
}
return result;
}),
taskEnv,
secretsStore: this.options.secretsStore,
});
} finally {
this.unregisterConfiguredCommandController(task.id, taskCommandAbortController);
}
})();
worktreePath = acquisition.worktreePath;
if (acquisition.reclaimed) {
@@ -3118,18 +3200,35 @@ export class TaskExecutor {
const scriptCommand = settings.scripts?.[settings.setupScript];
if (scriptCommand) {
const setupStartedAt = Date.now();
const setupAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, setupAbortController);
try {
const setupResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, taskEnv, audit);
const setupResult = await runConfiguredCommand(
scriptCommand,
worktreePath,
120_000,
taskEnv,
audit,
setupAbortController.signal,
);
if (setupAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, scriptCommand);
}
if (setupResult.spawnError || setupResult.timedOut || setupResult.exitCode !== 0) {
throw new Error(configuredCommandErrorMessage(setupResult));
}
await this.store.logEntry(task.id, `[timing] Setup script '${settings.setupScript}' completed in ${Date.now() - setupStartedAt}ms`, scriptCommand, this.getRunContextFor(task.id));
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
const execError = err instanceof Error ? err : new Error(String(err));
const message = "stderr" in execError && typeof (execError as Record<string, unknown>).stderr === "string"
? String((execError as Record<string, unknown>).stderr)
: execError.message;
await this.store.logEntry(task.id, `Setup script '${settings.setupScript}' failed: ${message}`, undefined, this.getRunContextFor(task.id));
} finally {
this.unregisterConfiguredCommandController(task.id, setupAbortController);
}
} else {
await this.store.logEntry(task.id, `Setup script '${settings.setupScript}' not found in scripts map — skipping`, undefined, this.getRunContextFor(task.id));
@@ -4084,8 +4183,7 @@ export class TaskExecutor {
executorLog.log(`${task.id}: session registered (model=${describeModel(session)}, stuckDetector=${!!stuckDetector})`);
// Invoke plugin onAgentRunStart hook (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (this.options.pluginRunner as any)?.invokeHook("onAgentRunStart", task.id);
void this.options.pluginRunner?.invokeHookSafe("onAgentRunStart", task.id);
try {
// Record activity on prompt start (heartbeat for stuck detection)
@@ -4661,8 +4759,7 @@ export class TaskExecutor {
});
}
// Invoke plugin onAgentRunEnd hook (fire-and-forget)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (this.options.pluginRunner as any)?.invokeHook("onAgentRunEnd", task.id);
void this.options.pluginRunner?.invokeHookSafe("onAgentRunEnd", task.id);
}
};
@@ -7715,18 +7812,33 @@ ${failureFeedback}
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
const scriptAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, scriptAbortController);
try {
const scriptResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, extraEnv, createRunAuditor(this.store, {
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-script", task.id),
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
taskId: task.id,
phase: "execute",
}));
const scriptResult = await runConfiguredCommand(
scriptCommand,
worktreePath,
120_000,
extraEnv,
createRunAuditor(this.store, {
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-script", task.id),
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
taskId: task.id,
phase: "execute",
}),
scriptAbortController.signal,
);
if (scriptAbortController.signal.aborted) {
throw this.createConfiguredCommandAbortError(task.id, scriptCommand);
}
if (scriptResult.spawnError || scriptResult.timedOut || scriptResult.exitCode !== 0) {
return { success: false, error: configuredCommandErrorMessage(scriptResult) };
}
return { success: true, output: `Script '${scriptName}' completed successfully` };
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
const execError = err instanceof Error ? err : new Error(String(err));
const stderr = "stderr" in execError && typeof execError.stderr === "string" ? execError.stderr.trim() : "";
const stdout = "stdout" in execError && typeof execError.stdout === "string" ? execError.stdout.trim() : "";
@@ -7738,6 +7850,8 @@ ${failureFeedback}
if (!parts.length) parts.push(execError.message || "Unknown error");
const errorOutput = parts.join("\n");
return { success: false, error: errorOutput };
} finally {
this.unregisterConfiguredCommandController(task.id, scriptAbortController);
}
}

View File

@@ -567,8 +567,8 @@ async function syncDependenciesForMerge(
}
throwIfAborted(signal, taskId);
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
mergerLog.log(`${taskId}: syncing dependencies before merge verification`);
await store.logEntry(taskId, `Syncing dependencies before merge verification: ${installCommand}`);
try {
await execAsync(installCommand, {
cwd: rootDir,
@@ -621,8 +621,23 @@ export type OwnedLandedClassification =
details: Record<string, unknown>;
};
function escapeRegexForOwnership(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Decide whether a git commit belongs to a given task. Line-anchored trailers
* and subject-anchored conventional commits only — prose mentions never count.
* Mirrors `commitOwnedByTask` in self-healing.ts (FN-5441/FN-5446 regression).
*/
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId);
if (new RegExp(`(?:^|\\n)${escapeRegexForOwnership(FUSION_TASK_ID_TRAILER_KEY)}: ${escapeRegexForOwnership(taskId)}\\s*(?:\\n|$)`).test(body)) {
return true;
}
const subjectAnchor = new RegExp(
`^(?:[A-Za-z]+(?:\\([^)]*\\b${escapeRegexForOwnership(taskId)}\\b[^)]*\\))?:|${escapeRegexForOwnership(taskId)}:)`,
);
return subjectAnchor.test(subject);
}
async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promise<OwnedLandedCommit | null> {
@@ -1029,7 +1044,7 @@ export function packageNamesForFiles(rootDir: string, files: string[]): string[]
*
* @internal Exported for testing only.
*/
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, _branch: string): string | null {
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, branch: string): string | null {
// 1. Read and parse pnpm-workspace.yaml
const workspacePath = join(rootDir, "pnpm-workspace.yaml");
let workspaceContent: string;
@@ -1045,11 +1060,11 @@ export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string,
const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
if (packageRoots.length === 0) return null;
// 3. Get the changed files between base and branch tip
// 3. Get the changed files between base and the branch tip passed by caller.
let changedFilesOutput: string;
try {
changedFilesOutput = execSync(
`git diff --name-only ${quoteArg(baseBranch)}...HEAD`,
`git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
{ cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
).toString();
} catch {
@@ -7886,6 +7901,44 @@ export async function aiMergeTask(
}
if (classification.kind === "proven-no-op" || classification.kind === "no-changes-finalized") {
// FN-5490/FN-5517/FN-5526/FN-5540 guard: the classifier only sees git
// evidence, but the task itself can attest that work happened. When
// modifiedFiles is non-empty AND no commit landed, that's lost work
// (uncommitted in the worktree, or the squash committed the wrong tree)
// — NOT a legitimate no-op. Demote to the unproven-recovery path which
// moves the task back to todo with progress preserved instead of
// clearing modifiedFiles to [].
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
const reason = `lost-work-detected: ${task.modifiedFiles.length} modifiedFiles claimed but no commit landed`;
await store.updateTask(taskId, { error: reason });
await store.logEntry(
taskId,
`Finalize blocked (lost-work guard): task claims ${task.modifiedFiles.length} modifiedFiles but classification would finalize as no-op — moving back to todo with progress preserved`,
JSON.stringify({
modifiedFilesSample: task.modifiedFiles.slice(0, 5),
classification: classification.kind,
}, null, 2),
);
await (store as any).recordRunAuditEvent?.({
domain: "database",
mutationType: "task:finalize-lost-work-blocked",
target: taskId,
metadata: {
modifiedFilesCount: task.modifiedFiles.length,
classification: classification.kind,
},
});
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
await releaseReuseHandoffEarly("lost-work-blocked");
return {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
error: reason,
};
}
const noOpReason = classification.kind === "proven-no-op"
? `branch has zero commits ahead of ${classification.baseRef}`
: "verification-only finalize: no branch and no owned commits";
@@ -8078,6 +8131,7 @@ export async function aiMergeTask(
}
if (classification.kind === "owned-commit") {
const mergedAt = new Date().toISOString();
await store.updateTask(taskId, {
mergeDetails: {
commitSha: classification.commit.sha,
@@ -8085,16 +8139,27 @@ export async function aiMergeTask(
insertions: classification.commit.insertions,
deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject,
mergedAt: new Date().toISOString(),
mergedAt,
mergeConfirmed: true,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
},
});
result.merged = true;
result.mergeConfirmed = true;
result.commitSha = classification.commit.sha;
result.filesChanged = classification.commit.filesChanged;
result.insertions = classification.commit.insertions;
result.deletions = classification.commit.deletions;
result.mergeCommitMessage = classification.commit.subject;
result.mergedAt = mergedAt;
result.mergeTargetBranch = mergeTarget.branch;
result.mergeTargetSource = mergeTarget.source;
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
} else {
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
const mergedAt = new Date().toISOString();
await store.updateTask(taskId, {
modifiedFiles: [],
mergeDetails: {
@@ -8103,17 +8168,25 @@ export async function aiMergeTask(
noOpMerge: true,
noOpReason,
landedFiles: [],
mergedAt: new Date().toISOString(),
mergedAt,
prNumber: task.prInfo?.number,
mergeTargetBranch: classification.baseRef,
mergeTargetSource: mergeTarget.source,
},
});
result.merged = true;
result.mergeConfirmed = true;
result.noOp = true;
result.noOpMerge = true;
result.noOpReason = noOpReason;
result.mergedAt = mergedAt;
result.mergeTargetBranch = classification.baseRef;
result.mergeTargetSource = mergeTarget.source;
await store.logEntry(taskId, `Auto-finalized no-op (proven): start point on ${classification.baseRef}; modifiedFiles cleared`);
}
// Audit trail: record merge completion (FN-1404)
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: false } });
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: true } });
await completeTask(store, taskId, result);
return result;
}
@@ -9735,7 +9808,14 @@ export async function aiMergeTask(
audit,
});
if (!advanceResult.advanced) {
if (advanceResult.reason === "concurrent-advance") {
// `non-fast-forward-advance` has the same root cause as
// `concurrent-advance` — integration moved during the merge window,
// here detected by ancestry rather than CAS old-value mismatch —
// so route it through the same rebind/retry path (FN-5576).
if (
advanceResult.reason === "concurrent-advance"
|| advanceResult.reason === "non-fast-forward-advance"
) {
throw new IntegrationBranchConcurrentAdvanceError({
integrationBranch,
expectedCurrentSha,
@@ -10562,7 +10642,7 @@ export async function executeMergeAttempt(
}
}
if (buildCommand) {
if (testCommand || buildCommand) {
throwIfAborted(options.signal, taskId);
const stagedFiles = await getStagedFiles(rootDir);
if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) {
@@ -11517,7 +11597,7 @@ async function runPostMergeWorkflowSteps(
try {
const result = stepMode === "script"
? await executePostMergeScriptStep(store, taskId, ws, cwd, settings, auditor)
? await executePostMergeScriptStep(store, taskId, ws, cwd, settings, auditor, mergeOptions.signal)
: await executePostMergePromptStep(store, taskId, ws, rootDir, cwd, settings, mergeOptions);
const completedAt = new Date().toISOString();
@@ -11579,6 +11659,7 @@ async function executePostMergeScriptStep(
cwd: string,
settings: Settings,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<{ success: boolean; output?: string; error?: string }> {
const scriptName = workflowStep.scriptName!.trim();
const scripts = settings.scripts || {};
@@ -11594,6 +11675,7 @@ async function executePostMergeScriptStep(
encoding: "utf-8",
timeoutMs: 120_000,
maxBuffer: 10 * 1024 * 1024,
...(signal !== undefined && { signal }),
});
if (result.exitCode === 0 && !result.signal && !result.timedOut && !result.bufferExceeded && !result.spawnError) {
@@ -11621,8 +11703,9 @@ export async function __executePostMergeScriptStepForTests(
cwd: string,
settings: Settings,
auditor?: RunAuditor,
signal?: AbortSignal,
): Promise<{ success: boolean; output?: string; error?: string }> {
return executePostMergeScriptStep(store, taskId, workflowStep, cwd, settings, auditor);
return executePostMergeScriptStep(store, taskId, workflowStep, cwd, settings, auditor, signal);
}
/** Execute a prompt-mode post-merge workflow step using an AI agent in the provided execution directory. */

View File

@@ -1024,7 +1024,7 @@ export class PluginRunner {
/**
* Invoke a hook with error isolation and logging.
*/
private async invokeHookSafe(hookName: keyof FusionPlugin["hooks"], ...args: unknown[]): Promise<void> {
async invokeHookSafe(hookName: keyof FusionPlugin["hooks"], ...args: unknown[]): Promise<void> {
try {
await this.withTimeout(
this.invokeHook(hookName, ...args),

View File

@@ -1,3 +1,4 @@
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the reviewer prompt.
/**
* Reviewer — spawns a separate pi agent to review a worker's plan or code.
*

View File

@@ -418,6 +418,7 @@ export type DatabaseMutationType =
| "task:auto-archived-ghost-bug"
| "task:auto-archived-duplicate"
| "task:auto-reconciled-self-defeating-dep"
| "task:soft-delete-column-reconciled"
| "task:dependency-cycle-rejected"
| "task:dependency-cycle-detected"
| "task:auto-reconciled-dependency-cycle"
@@ -497,6 +498,14 @@ export type DatabaseMutationType =
| "session:runtime-resolved"
| "task:in-review-stall-deadlock-disposed"
| "task:finalize-unproven-blocked"
/**
* FN-5490/FN-5517/FN-5526/FN-5540 lost-work guard: the merger or self-heal
* sweep refused to finalize a task as no-op because its record claimed
* `modifiedFiles` while no commit landed. Task is moved back to todo with
* progress preserved instead of silently clearing modifiedFiles to [].
* Metadata: { modifiedFilesCount, classification, baseRef? }
*/
| "task:finalize-lost-work-blocked"
| "task:integrity-reconcile-modified-files"
| "task:integrity-warning"
/** FN-5092 watchdog: stale `status: "merging"` / `"merging-pr"` cleared on a done/archived task. Metadata: { previousColumn, previousStatus, ageMs, mergeConfirmed?: boolean } */

View File

@@ -801,6 +801,7 @@ export class SelfHealingManager {
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns().then(() => undefined) },
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks().then(() => undefined) },
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks().then(() => undefined) },
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift().then(() => undefined) },
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
{ name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies().then(() => undefined) },
{ name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) },
@@ -1458,6 +1459,7 @@ export class SelfHealingManager {
{ name: "recover-stale-heartbeat-runs", fn: () => this.recoverStaleHeartbeatRuns() },
{ name: "recover-running-on-inactive-tasks", fn: () => this.recoverAgentsRunningOnInactiveTasks() },
{ name: "recover-drifted-agent-task-links", fn: () => this.recoverDriftedAgentTaskLinks() },
{ name: "reconcile-soft-delete-column-drift", fn: () => this.reconcileSoftDeletedColumnDrift() },
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
{ name: "auto-rebound-paused-scope-decay", fn: () => this.autoReboundPausedScopeDecay() },
{ name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() },
@@ -2944,16 +2946,26 @@ export class SelfHealingManager {
}
const integrationBase = task.baseBranch || await resolveIntegrationBranch(this.options.rootDir, undefined);
const existingCandidatesByRef = new Map<string, { branch: string; aheadCount: number }>();
// Dedup by resolved SHA, not by lowercase name. On case-insensitive
// filesystems (macOS APFS default) two case-variant refs resolve to the
// same underlying ref → same SHA → collapse to canonical. On
// case-sensitive filesystems (Linux) two case-variants are physically
// distinct refs with distinct SHAs → keep both, so downstream detects
// the ambiguity rather than silently picking one.
const candidateByRefSha = new Map<string, { branch: string; aheadCount: number }>();
const normalizedCandidate = canonicalFusionBranchName(task.id);
for (const branch of candidates) {
let branchSha: string;
try {
await execAsync(`git show-ref --verify --quiet ${shellQuote(`refs/heads/${branch}`)}`, {
const { stdout } = await execAsync(`git rev-parse --verify ${shellQuote(`refs/heads/${branch}`)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
branchSha = stdout.trim();
} catch {
continue;
}
if (!branchSha) continue;
let comparisonBase = integrationBase;
try {
@@ -2978,18 +2990,16 @@ export class SelfHealingManager {
timeout: 30_000,
});
const aheadCount = Number.parseInt(aheadCountRaw.stdout.trim(), 10);
const normalizedBranchRef = branch.toLowerCase();
const existingCandidate = existingCandidatesByRef.get(normalizedBranchRef);
const normalizedCandidate = canonicalFusionBranchName(task.id);
if (!existingCandidate || branch === normalizedCandidate) {
existingCandidatesByRef.set(normalizedBranchRef, {
const existing = candidateByRefSha.get(branchSha);
if (!existing || branch === normalizedCandidate) {
candidateByRefSha.set(branchSha, {
branch,
aheadCount: Number.isFinite(aheadCount) ? aheadCount : 0,
});
}
}
const existingCandidates = [...existingCandidatesByRef.values()];
const existingCandidates = [...candidateByRefSha.values()];
if (existingCandidates.length === 0) {
await this.emitBranchRebindAuditEvent({
@@ -3607,6 +3617,48 @@ export class SelfHealingManager {
this.lastDbCorruptionNotifiedAt = now;
}
async reconcileSoftDeletedColumnDrift(): Promise<{ reconciled: number }> {
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return { reconciled: 0 };
const db = this.store.getDatabase();
// FN-5147 invariant: only rows with deletedAt are eligible, so live
// in-review tasks (including autoMerge: false workflows) are never moved.
const candidates = db.prepare("SELECT id, \"column\" AS column FROM tasks WHERE deletedAt IS NOT NULL AND \"column\" != 'archived'").all() as Array<{ id: string; column: Task["column"] }>;
if (candidates.length === 0) return { reconciled: 0 };
let reconciled = 0;
const now = new Date().toISOString();
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("fn5566-soft-delete-column", "global"),
agentId: "self-healing",
phase: "reconcile-soft-delete-column-drift",
});
for (const candidate of candidates) {
db.prepare("UPDATE tasks SET \"column\" = 'archived', updatedAt = ? WHERE id = ?").run(now, candidate.id);
await auditor.database({
type: "task:soft-delete-column-reconciled",
target: candidate.id,
metadata: { previousColumn: candidate.column },
});
log.log(`[self-heal] reconcile-soft-delete-column-drift: ${candidate.id} previous=${candidate.column} → archived`);
reconciled++;
}
if (reconciled > 0) {
db.bumpLastModified();
}
return { reconciled };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`reconcileSoftDeletedColumnDrift: failed: ${message}`);
return { reconciled: 0 };
}
}
async clearStaleBlockedBy(): Promise<number> {
try {
const settings = await this.store.getSettings();
@@ -3888,6 +3940,7 @@ export class SelfHealingManager {
const seenCycleSignatures = new Set<string>();
for (const task of tasks) {
if (task.deletedAt) continue;
if (!task.dependencies.length) continue;
try {
@@ -3989,7 +4042,7 @@ export class SelfHealingManager {
return recovered;
}
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:finalize-lost-work-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-healing-integrity", taskId),
agentId: "self-healing",
@@ -4159,6 +4212,32 @@ export class SelfHealingManager {
await this.store.updateTask(task.id, { mergeDetails });
await this.store.logEntry(task.id, `Auto-finalized: recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
} else {
// FN-5490/FN-5517/FN-5526/FN-5540 guard: same lost-work check as
// merger.ts:aiMergeTask. The self-heal path was the historical
// primary site of the bug — it would clear `modifiedFiles: []`
// (line below) while moving the task to Done, silently destroying
// the audit trail of the lost work. Now we refuse to finalize and
// move the task back to todo with progress preserved so the next
// executor run can re-attempt.
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
await this.store.logEntry(
task.id,
`Finalize blocked (lost-work guard): task claims ${task.modifiedFiles.length} modifiedFiles but classification would finalize as no-op — moving back to todo with progress preserved`,
JSON.stringify({
modifiedFilesSample: task.modifiedFiles.slice(0, 5),
classification: "proven-no-op",
baseRef: classification.baseRef,
}, null, 2),
);
await this.recordIntegrityAudit(task.id, "task:finalize-lost-work-blocked", {
modifiedFilesCount: task.modifiedFiles.length,
classification: "proven-no-op",
baseRef: classification.baseRef,
});
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" });
recovered++;
continue;
}
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}),
@@ -5435,6 +5514,7 @@ export class SelfHealingManager {
let recovered = 0;
for (const task of candidates) {
if (task.deletedAt) continue;
const blockedDependents = dependentsByBlocker.get(task.id) ?? [];
const blockedTaskIds = blockedDependents.map((dep) => dep.id);
try {

View File

@@ -473,6 +473,9 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
}
await store.logEntry(task.id, `[timing] Worktree init command completed in ${Date.now() - initStartedAt}ms`, settings.worktreeInitCommand, runContext);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw err;
}
await store.logEntry(task.id, `[timing] Worktree init command failed after ${Date.now() - initStartedAt}ms`, undefined, runContext);
const message = err instanceof Error ? err.message : String(err);
const outcome = formatInitFailureOutcome(initResult, err);