Merge branch 'main' into fix/research-pipeline-htmlUrl-to-url
This commit is contained in:
@@ -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/<branch>
|
||||
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 } */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user