Root-causes 4 correlated CTO-report agent failures where durable non-ephemeral agents got stuck in `error` state indefinitely because the heartbeat scheduler stops ticking error-state agents entirely, and self-healing's recovery sweep previously only considered them when their manager row was missing.
- SelfHealingManager: scope the `managerMissing` gate to the "running" orphan-detection path only, so "error"-state durable agents with a present/active manager now fall through to the existing transient/operator-actionable/active-execution/cooldown/retry-budget recovery guards instead of being skipped outright
- Add FNXC:AgentHeartbeat comment documenting the FN-7672 incident and rationale for the scoping change
- Extend self-healing.test.ts with coverage for manager-present durable agents in error state
- Add changeset (patch) describing the fix for release notes
- Update docs/agents.md accordingly
Files changed:
.changeset/fn-7672-durable-agent-recovery.md | 7 ++
docs/agents.md | 2 +
packages/engine/src/__tests__/self-healing.test.ts | 129 ++++++++++++++++++++-
packages/engine/src/self-healing.ts | 24 +++-
4 files changed, 160 insertions(+), 2 deletions(-)
Fusion-Task-Id: FN-7672
Fusion-Task-Lineage: 6676dc9e-66e7-4f70-804a-cccf77e8d337
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Prototyped extending the @fusion/core pre-bundle alias lever to @fusion/engine's relative-import production graph reached by the 18 gate files, but an A/B showed no clear win over the @fusion/core-only bundle, so the change was not landed and only the rationale is recorded.
- Added an FNXC:EngineTests comment block in packages/engine/vitest.config.ts documenting the FN-7670 prototype (171 first-party files → 35 output files via esbuild multi-entry splitting)
- Recorded the negative A/B result: byte-size growth of 14 separate large root bundles offset per-file-dispatch savings, with no clear win beyond host run-to-run noise
- Left the vitest alias wiring unchanged at the @fusion/core-only bundle state, pointing future attempts to FN-7670's task docs for full analysis and to consider a single combined engine-graph entry instead of 14 separate root entries
Files changed:
packages/engine/vitest.config.ts | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
Fusion-Task-Id: FN-7670
Fusion-Task-Lineage: efd27f94-a6c4-49c7-a78e-50213fd42a24
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Prototype and land a rebuilt-every-run esbuild bundle of the @fusion/core gate-safe barrel closure, collapsing the engine-core gate's per-fork Vite SSR import-phase cost (18 forks x ~430-file closure re-resolved from scratch) into a single file load per fork.
- Add scripts/build-engine-core-gate-bundle.mjs: esbuild-bundles packages/core/src/index.gate.ts (220 first-party files, packages:"external" so third-party/node: imports stay external, treeShaking:false to preserve side effects) into packages/core/.gate-bundle/core.mjs + core.meta.json
- Wire the builder into packages/engine/vitest.config.ts's engine-core project globalSetup (alongside the existing vitest-teardown hook) so the bundle is rebuilt fresh before every gate invocation, and repoint the @fusion/core resolve.alias at the bundled output instead of index.gate.ts source
- Place the bundle output at packages/core/.gate-bundle/ as a sibling of packages/core/node_modules/ (not nested inside it) to avoid Vite SSR's external-dep heuristic, which would otherwise silently defeat vi.mock interception for imports nested in the bundle
- Gitignore packages/core/.gate-bundle/ and add a matching ESLint ignore entry so the generated bundle text is never linted or committed
- Add esbuild ^0.25.12 as a root devDependency (pnpm-lock.yaml updated accordingly)
- Document the pre-bundling rationale, placement constraints, and measured A/B wall-time results in docs/testing.md
Verified: pnpm test:gate passes (335/335 engine-core tests, 63/63 CLI ci-shape tests), engine package typecheck clean, eslint clean on touched files.
Files changed:
.gitignore | 11 ++
docs/testing.md | 3 +
eslint.config.mjs | 10 ++
package.json | 1 +
packages/engine/vitest.config.ts | 50 ++++++++-
pnpm-lock.yaml | 3 +
scripts/build-engine-core-gate-bundle.mjs | 174 ++++++++++++++++++++++++++++++
7 files changed, 247 insertions(+), 5 deletions(-)
Fusion-Task-Id: FN-7669
Fusion-Task-Lineage: 62b06b2a-4ac6-45ae-ac79-9771132bc303
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Introduces a project-scoped @fusion/core barrel used only by the engine-core
gate project, so new feature modules added to the full barrel don't silently
inflate the gate's transform/import cost.
- Add packages/core/src/index.gate.ts, a copy of the full @fusion/core barrel
minus export statements for modules added since the last re-audit baseline
(i.e. it still re-exports everything the full barrel does except newly
added, gate-irrelevant feature modules).
- Update packages/engine/vitest.config.ts to add a project-scoped
resolve.alias mapping @fusion/core -> packages/core/src/index.gate.ts for
the engine-core project only; engine-default/engine-reliability/engine-slow
and @fusion/engine continue to resolve the full barrel.
- Document the gate-safe barrel and its audit procedure in docs/testing.md.
Files changed:
docs/testing.md | 3 +
packages/core/src/index.gate.ts | 2102 ++++++++++++++++++++++++++++++++++++++
packages/engine/vitest.config.ts | 17 +
3 files changed, 2122 insertions(+)
Fusion-Task-Id: FN-7667
Fusion-Task-Lineage: 054ec89a-d973-44dd-b9ac-ad266f553f01
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Duplicate tasks created by the same agent are no longer auto-archived by default; they are flagged for review instead, controlled by a new opt-in project setting.
- Add project setting `autoArchiveDuplicateTasksEnabled` (default false) gating the FN-4892 same-agent duplicate intake path
- Add `flagSameAgentDuplicate` path and `nearDuplicateOf` metadata used when auto-archive is disabled; tombstone-resurrection blocking is unchanged
- Wire the setting through core settings schema/types/store, dashboard SchedulingSection UI, and i18n strings
- Update docs (settings-reference.md, task-management.md) to describe the new default-off behavior
- Add a changeset for the @runfusion/fusion minor release
- Extend duplicate-intake, tombstone-window, store-parent-task-dedup, and reliability-interaction tests to cover both flag states
Files changed:
$(cat /tmp/fn7658_stat.txt)
Fusion-Task-Id: FN-7658
Fusion-Task-Lineage: 7d0d1074-1020-48a8-b96f-186154c2c408
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Blocks planning/intake column cards from entering processing columns regardless of literal column id, so renamed custom intake/planning columns are covered by the same guard as the legacy todo column.
- Add isUnplannedForExecution() in hold-release.ts: true when task.status==="planning", or when the card sits in the legacy todo column or a column carrying the intake trait AND its PROMPT.md still equals the bootstrap stub.
- Route issueRelease() (used by the sweep, promoteHeldTask, and releaseHeldTaskByEvent) through this guard before releasing into any countsTowardWip processing column.
- Update scheduler.ts's reserveSlot guard to use the same trait-based predicate instead of a hardcoded "todo" column id check.
- Add regression tests in hold-release.test.ts and scheduler-workflow-cutover.test.ts covering renamed intake/planning columns.
- Document the invariant in docs/architecture.md and docs/workflow-steps.md.
- Add changeset (patch) describing the fix.
Files changed:
.changeset/fn-7648-unplanned-intake-cards-never-execute.md | 7 +
docs/architecture.md | 2 +
docs/workflow-steps.md | 2 +
packages/engine/src/__tests__/hold-release.test.ts | 238 +++++++++++++++++++++
packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts | 60 +++++-
packages/engine/src/hold-release.ts | 60 ++++++
packages/engine/src/scheduler.ts | 26 +--
7 files changed, 378 insertions(+), 17 deletions(-)
Fusion-Task-Id: FN-7648
Fusion-Task-Lineage: a4b54d30-f86d-4eb9-9cf2-6ac55b6dbe58
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fix API keys/OAuth credentials in ~/.fusion/agent/auth.json being clobbered when the desktop app and CLI-served web app run concurrently on one machine.
- Reload primary auth storage from disk (primary.reload()) before persisting a refreshed OAuth credential, so a concurrent process's newer login/refresh for the same provider isn't overwritten by this process's stale in-flight refresh.
- Re-check credential identity against the freshly reloaded disk state before writing the refreshed token back.
- Add cross-process regression coverage exercising concurrent auth.json read-modify-write scenarios.
- Add changeset documenting the fix and its dependency on the pi-coding-agent locked per-provider merge (>=0.80.x).
Files changed:
.changeset/fn-7646-auth-storage-coordination.md | 7 +
.../src/__tests__/auth-storage-concurrency.test.ts | 234 +++++++++++++++++++++
packages/engine/src/auth-storage.ts | 27 +++
3 files changed, 268 insertions(+)
Fusion-Task-Id: FN-7646
Fusion-Task-Lineage: de39f08d-2d9f-46ff-b293-c603e3268ecf
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fixes the heartbeat timer audit so it repairs not just missing timer registrations but also 'zombie' ones — timer entries that remain present in memory after their underlying interval silently stopped firing. Long-interval (~1h) agents were most affected since a single lost tick compounded into hours of staleness before self-healing noticed.
- HeartbeatTriggerScheduler audit now computes staleness (elapsed vs repair-stale threshold) up front for every timer-eligible agent, not only for agents missing a timer entry
- Present-but-stale timer entries are now treated as non-advancing and force cleared/re-registered via registerAgent() (which already clears any existing timer before re-arming)
- Fresh (non-stale) present timers are left alone so healthy short-interval agents are never force-re-armed or double-ticked
- Repair reason/log messages now distinguish zombie-timer-rearmed repairs from missing-registration repairs, and the summary log reports counts for each
- Added heartbeat-scheduler tests covering the zombie-timer repair path
- Added changeset and a docs/architecture.md note
Files changed:
.changeset/fn-7645-heartbeat-rearm.md | 7 +
docs/architecture.md | 1 +
.../src/__tests__/heartbeat-scheduler.test.ts | 223 +++++++++++++++++++++
packages/engine/src/agent-heartbeat.ts | 42 +++-
4 files changed, 266 insertions(+), 7 deletions(-)
Fusion-Task-Id: FN-7645
Fusion-Task-Lineage: 652bc2eb-a660-4306-9f85-d2d5f9ca7e38
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fixes the code-review/plan-review/CE gate workflow node failing with a blank "(no feedback captured)" message when a dispatch or infra exception (not a reviewer verdict) causes the step to fail.
- WorkflowGraphExecutor now synthesizes a non-blank WorkflowStepResult.output when an enabled optional-group (code-review, plan-review, browser-verification) or CE source:"node" skill-gate template node fails via dispatch/infra exception
- Diagnostic output is derived from the node:<id>:error context-patch key, falling back to the failure value, then a stable sentinel
- status, verdict extraction, edge routing, and self-healing's latestFailedPreMergeStep selection are unchanged
- Added regression test coverage: workflow-graph-optional-group-no-feedback.test.ts
- Added changeset (patch) documenting the fix for Runfusion/Fusion#1946
Files changed:
.changeset/fn-7642-code-review-no-feedback-diagnostic.md | 7 +
packages/engine/src/__tests__/workflow-graph-optional-group-no-feedback.test.ts | 246 +++++++++++++++++++++
packages/engine/src/workflow-graph-executor.ts | 104 ++++++++-
3 files changed, 355 insertions(+), 2 deletions(-)
Fusion-Task-Id: FN-7642
Fusion-Task-Lineage: 1329e907-652f-4230-a945-5a9d7040ae69
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
## Summary
Fixes the failing **full-suite** CI run on `main` ([run
28874651861](https://github.com/Runfusion/Fusion/actions/runs/28874651861))
— all 4 test shards were red. ~32 test files failing across engine +
dashboard (src + app), rooted in ~13 distinct causes from recent main
commits. All resolved; the merge gate and full engine/dashboard suites
are green locally.
## Root causes & fixes
### Engine (shards 1 & 2)
- **`appendAgentLog` 6th timing arg (FN-7503, `2797803c0`)** —
`agent-logger.ts` now passes an optional
`{durationMs,timeToFirstTokenMs}` 6th arg; many
executor/heartbeat/merger tests asserted the old 5-arg form. Added a
shared timing-tolerant helper `agent-log-assertions.ts` (asserts
`taskId/text/type`, tolerant of the timing object) and applied it across
affected files — so future timing fields won't re-break every executor
test.
- **`reconcileSupersededGeneratedFixFeatures` (mission)** —
`mission-execution-loop.ts` calls a method the test's missionStore mock
lacked; added a no-op stub (the real `MissionStore` already implements
it).
- **`ModelFallbackExhaustedError` / `proseSignalsClearApproval` /
`extractJsonObjectCandidates` missing from `vi.mock`** — converted stale
hand-written mocks (`../pi.js`, `../reviewer.js` in
`executor-test-helpers.ts`) to `importOriginal`-spread so real exports
carry through.
- **Workspace product fixes (2):**
- `merger-ai.ts` — `landWorkspaceTask` now recovers the integration-tip
sha as `landedSha` when the A1 trailer-fallback proved a sub-repo landed
but its sha was never persisted, so `finalizeWorkspaceTask` can build
merge proof (was stranding partial-land retries in-review).
- `worktree-acquisition.ts` — `acquireWorkspaceRepoWorktree` strips the
shared project `integrationBranch/baseBranch` overrides before
forwarding to `acquireTaskWorktree` (FN-7360's `freshStartPoint` was
resolving an absent shared branch).
- **FN-7360 extra `git symbolic-ref` exec** — updated worktree
exec-count assertions for the new `resolveIntegrationBranch` call.
- **Planner-overseer / stepwise-workflow / workflow-graph /
workflow-prompt / executor-step-session / liveness-gate / checkout /
ce-workflow / triage-split** — test-alignments for intentional behavior
changes (FN-7229 retry-cap, FN-7265 review-node removal, FN-7335
pause-abort logging, FN-7577 recovery-budget, FN-7577 overseer denial
loop, specifyTask single promptWithFallback call, FN-4944
already-on-main noop log, FN-7486 ownership short-circuit).
### Dashboard API (shard 3)
- **`store.on('task:moved')` (FN-7337)** — `createServer` now registers
the listener; backed the 4 affected MockStores with EventEmitter (shared
root cause across chat-routes.rooms, register-git-github,
routes-run-cited-goals, routes-sandbox-audit).
- **`routes-agent-import`** — core mock converted to
`importOriginal`-spread (was missing FN-7444 planning-deepening
constants).
- **`session-resume-history`** — engine mock missing
`resolveMcpServersForStore`.
- **`task-create-workflow-route`** — `builtin:legacy-coding`
defaultSteps now include `plan-review` (FN-7224/7226).
- **GitLab parity** — added the missing `[GitLab Parity Inventory]`
cross-link in `docs/signals-connectors.md`.
### Dashboard app (shard 4)
- Test-alignments for intentional product changes: FN-7057 (workflow
selection preservation), FN-7340 (footer concurrency geometry), FN-7156
(Missions overview default), FN-7342/FN-6825 (board scroll + workflow
switcher), FN-7352 (openDetailTask 3rd arg), FN-7261 (backdrop dismiss
default-off), FN-7234 (non-authoritative fetch failures), plus a missing
`fetchWorkflowOptionalSteps` mock.
### MCP coverage
- `mcp-surface-coverage` forwarding needle updated for FN-7446's
`resolvePlanningMcpServers` helper.
## Approach notes
- Each fix is the **minimal** change at the correct source (test-update
where a recent commit intentionally changed behavior; product-fix for
the 2 real regressions). No assertion was loosened/deleted to force a
pass; no timeout appeasement.
- Coordination: work was partitioned by package across parallel
subagents (engine / dashboard-src / dashboard-app) with Main as the sole
git committer (path-scoped commits) after an early shared-index reset
wiped in-progress edits — process was tightened mid-flight.
## Verification
- **Full engine suite**: green (9231 passed; the lone local-only
`custom-providers-openai-completions` import error is stale local
`pi-ai@0.79.9` vs the lockfile's `0.80.3` — CI's fresh install resolves
`/compat`; it passed in the original CI run).
- **Dashboard API** (`dashboard-api-quality-backfill`): 242 files / 3185
tests / 0 failures.
- **Dashboard app** (`dashboard-app-quality-backfill`): all targeted
files green (37 + 95 tests).
- **Merge gate** (`pnpm test:gate`): engine-core 326 + ci-shape 63, plus
nohup/4040/appeasement/changeset-format checks — all pass.
- 2 changesets added for the published-`@runfusion/fusion` behavior
fixes (workspace landedSha, sub-repo worktree branch-strip).
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved reliability for partial workspace land retries by recovering
the exact proven landed commit so durable merge proofs can complete.
* Fixed per-sub-repo worktree creation by removing invalid branch
override settings, preventing worktree-add failures.
* Dashboard stability updates: preserve mobile board scroll during
stabilization/restore, correct task filtering when workflows are
missing, ensure the Chat tab appears for done tasks, and refine
modal-dismiss and responsive popover behavior.
* **Documentation**
* Expanded the GitLab connector section with GitLab parity context and a
GitLab Parity Inventory reference.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
findProvenLandedCommit now keeps --grep as a prefilter but verifies each
candidate carries an actual 'Fusion-Task-Id: <taskId>' trailer line via
git show -s --format=%B, so a later commit that merely mentions the trailer
text in its body cannot be selected. Regression covers a body-mention
intervening commit.
findProvenLandedCommit returns the task's own trailer commit (or recorded
landedSha when still an ancestor) instead of rev-parse on the integration
tip, so an intervening sub-repo land can't attribute a later unrelated
commit. Regression: intervening commit after lost persist recovers tipAfterFirst.
recoverActiveMissions (mission-execution-loop.ts:263) calls
missionStore.reconcileSupersededGeneratedFixFeatures per slice; the 5
MissionExecutionLoop-backed mocks here omitted it, so recovery threw
(TypeError) at the slice loop and aborted before processTaskOutcome /
ensureFeatureAssertionLinked / startValidatorRun ran — 4 tests failed.
Add a no-op stub (matches mission-execution-loop.test.ts reference) with
an FNXC:MissionReconcile note. No-op is correct: supersession is not
exercised by these tests.
A single retry can still lose the race on busy repos if origin moves again
in the pull-rebase/push window. Generalize the one-shot retry into a bounded
loop (3 attempts, 2s/5s/10s backoff), re-pulling+rebasing before each push
attempt and bailing early once the failure is no longer non-fast-forward.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When pushAfterMerge fails (or throws) in direct-merge mode, the daemon
only wrote to the process-wide mergerLog and a transient MergeResult
field, then unconditionally marked the task done. There was no durable
record on the task or in the audit trail, so a diverged local main
could go unnoticed indefinitely — this is how our local main drifted
162 commits from origin before it was caught by hand.
Record the failure through the two channels merger.ts already has for
this: the dormant "push:origin" GitMutationType via audit.git(), and a
task log entry via store.logEntry(). Both calls are best-effort
(.catch(() => undefined)) so a logging failure can't abort the merge
flow itself — task completion behavior is unchanged, the failure is
just no longer invisible.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Executors could previously treat a pending approval as a normal turn end and go hunt for ungated workarounds instead of stopping. This change makes wait-for-approval a hard suspend point.
- wait-for-approval now suspends the in-flight executor session via awaitAbortInFlightTaskWork
- Dedupe identical pending approvals so repeated waits don't pile up
- Executor prompts now carve out awaiting-approval as a legitimate turn end (agent-prompts.ts)
- Extend provisioning-gate and agent-action-gate coverage for the new suspend/carveout behavior
- Add changeset (patch) documenting the fix for release notes
- Update docs/agents.md and docs/architecture.md to describe the new blocking behavior
Files changed:
.changeset/fn-7608-awaiting-approval-blocking.md | 7 ++
docs/agents.md | 1 +
docs/architecture.md | 1 +
packages/core/src/agent-prompts.ts | 5 +
.../engine/src/__tests__/agent-action-gate.test.ts | 82 +++++++++++++
.../executor-approval-gate-suspend.test.ts | 128 +++++++++++++++++++++
.../executor-approval-prompt-carveout.test.ts | 61 ++++++++++
packages/engine/src/agent-heartbeat.ts | 13 +++
packages/engine/src/executor.ts | 28 +++++
packages/engine/src/pi.ts | 22 +++-
.../sandbox/__tests__/provisioning-gate.test.ts | 29 +++++
packages/engine/src/sandbox/provisioning-gate.ts | 11 ++
12 files changed, 384 insertions(+), 4 deletions(-)
Fusion-Task-Id: FN-7608
Fusion-Task-Lineage: 9e42d8ee-bda7-4ef1-b159-46c2100bbc48
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Task creation surfaces stopped hardcoding column:"triage", so new tasks now land in the selected-or-default workflow's resolved intake column instead of always jumping to Planning/triage.
- Removed hardcoded column:"triage" override in engine's createTaskCreateTool (fn_task_create), letting TaskStore.createTask resolve the landing column from the workflow's intake-trait column.
- Removed the equivalent hardcoded override in the pi extension's fn_task_create, and updated its response text to echo the actual landing column instead of a fixed "Column: triage" string.
- Fixed signal-route, GitHub-import, and planning-subtask-route task creation to stop forcing column when no workflowId is given (or, for planning subtask routes, even when one is provided).
- Custom workflows with a non-triage intake column (e.g. Inbox) now correctly capture new cards inert until released, while the default builtin:coding workflow still resolves to "triage" byte-identically.
- Added regression coverage (agent-tools-intake-column.test.ts, extension-workflow-tools.test.ts) and a patch changeset documenting the fix.
Files changed:
.changeset/fn-7611-intake-column.md | 7 ++
.../src/__tests__/extension-workflow-tools.test.ts | 70 +++++++++++
packages/cli/src/extension.ts | 10 +-
.../src/__tests__/register-signal-routes.test.ts | 8 +-
.../dashboard/src/__tests__/routes-github.test.ts | 2 -
.../dashboard/src/routes/register-git-github.ts | 16 ++-
.../src/routes/register-planning-subtask-routes.ts | 24 +++-
.../dashboard/src/routes/register-signal-routes.ts | 8 +-
.../__tests__/agent-tools-intake-column.test.ts | 138 +++++++++++++++++++++
packages/engine/src/__tests__/agent-tools.test.ts | 1 -
packages/engine/src/agent-tools.ts | 21 +++-
11 files changed, 288 insertions(+), 17 deletions(-)
Fusion-Task-Id: FN-7611
Fusion-Task-Lineage: daf7f755-b1c7-4859-b74f-f15593d5e79e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fixes workspace-mode (workspaceWorktrees) tasks failing auto-merge under mergeStrategy=pull-request, where processPullRequestMergeTask threw "could not determine repository" because the workspace root is a container of independent git sub-repos, not itself a git repo.
- Hoist an isWorkspaceTask check in ProjectEngine's merge dispatch (project-engine.ts) before the mergeStrategy branch, so workspace tasks always fall through to the existing direct/landWorkspaceTask path regardless of configured mergeStrategy.
- Add processPullRequestMergeTask and syncGroupPrCallback defense-in-depth guards (task-lifecycle.ts) that throw the new named WorkspaceTaskMergeError if a workspace task ever reaches the PR-merge path.
- Add engine tests covering multi-repo, single-repo, and zero-commit no-op workspace tasks under mergeStrategy=pull-request, plus a non-regression test for the legacy single-worktree PR path.
- Add CLI tests asserting the new guards throw WorkspaceTaskMergeError.
- Add a patch changeset describing the fix.
Files changed:
.changeset/fn-7610-workspace-pr-merge-routing.md | 7 ++
.../src/commands/__tests__/task-lifecycle.test.ts | 56 +++++++++
packages/cli/src/commands/task-lifecycle.ts | 33 ++++-
.../engine/src/__tests__/project-engine.test.ts | 140 +++++++++++++++++++++
packages/engine/src/project-engine.ts | 18 ++-
5 files changed, 252 insertions(+), 2 deletions(-)
Fusion-Task-Id: FN-7610
Fusion-Task-Lineage: 31768b77-d9a9-4a79-a055-bbc6b228a1c4
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Two engine test files fail on current `main` (17c4007). Both are stale
**test-mock** breakages — no production code is touched.
### 1. `restart.integration.test.ts`
Its `vi.mock("../pi.js", …)` factory replaces the module wholesale but
omits `ModelFallbackExhaustedError`. `triage.ts` guards its catch block
with `err instanceof ModelFallbackExhaustedError` (imported from
`pi.js`), so evaluating that guard throws *"No
ModelFallbackExhaustedError export is defined on the mock"*.
Fix: export a plain `Error`-subclass stub from the factory. No restart
test enters the fallback-exhausted branch, so `instanceof` simply
returns `false` — a faithful stub.
### 2. `reliability-interactions/mission-validation-trigger-gap.test.ts`
Two recovery-path `missionStore` mocks omit `getMission`. #1910's
mission-active gate now walks `getSlice → getMilestone → getMission`
inside `resolveFeatureMission`. The resulting throw is swallowed by
`processTaskOutcome`'s `catch`, aborting recovery before it can ensure
assertions / start the validator run — surfacing as
`ensureFeatureAssertionLinked` asserted called-once but seen 0 times.
Fix: add `getMission` returning an active mission to both mocks.
### Verification
```
npx vitest run src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts src/__tests__/restart.integration.test.ts
Test Files 2 passed (2)
Tests 54 passed (54)
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Tests**
* Improved coverage for mission recovery and restart flows, making
validation scenarios more reliable.
* Fixed test mocks so recovery and triage paths can run without
unexpected errors during assertions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Problem
The mission validator runs its read-only judge session with `cwd:
this.rootDir` — the engine's **main working copy**. When a task's merge
landed on the remote (or in another worktree) and `rootDir` was never
fetched/reset to that commit, the judge reads **pre-merge files** and
returns a spurious `fail`.
#1917's premerge column guard does not catch this case: by the time
validation runs the task column is already `done`, so execution falls
through to `handleValidationFail` and mints a **bogus Fix Feature** for
code that is actually correct and merged.
## Fix
A symmetric second guard in the `fail` branch, placed **after** the
#1917 premerge column check:
- `isValidationWorkspaceStale(feature)` resolves the task's integration
SHA and runs `git merge-base --is-ancestor <sha> HEAD` in `rootDir`.
- **Only affirmative staleness evidence defers.** `--is-ancestor` exit
`1` (the SHA is *not* an ancestor of HEAD → the workspace predates the
merge) → defer the fail to **inconclusive**, so a later validation
judges the merged code.
- Every other outcome trusts the fail: exit `0` (ancestor → workspace is
fresh), no integration SHA available, or a bad/unknown object (exit
`128`).
Fail-open doctrine, matching #1917: a guard may only ever **defer** a
fail, never **suppress** one on missing or unreadable data.
## Tests
Four real-git cases in `mission-execution-loop.test.ts` (skipped when
`git` is unavailable):
1. Judged checkout predates the merged commit → fail deferred to
inconclusive, no Fix Feature minted, emits `validation:inconclusive`.
2. Merged commit is an ancestor of HEAD (fresh workspace) → normal fail
path, Fix Feature minted, emits `validation:failed`.
3. Task carries no integration SHA → fail open (normal fail).
4. Integration SHA is an unknown object (exit 128) → fail open (normal
fail).
Verified with stash-red/restore-green discipline: with the production
guard stashed, case (1) goes red while the three fail-open guardrails
stay green — proving case (1) exercises the fix. Full file: 62 passed.
`tsc --noEmit`: clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Validation failures are now deferred when the workspace appears to be
out of date with merged changes, reducing incorrect failure reports.
* Tasks with linked work continue to use the usual failure path when the
current workspace is up to date.
* Staleness checks now avoid masking real validation failures when no
merge reference is available or when the reference can’t be verified.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
The mission validator runs its read-only judge session with cwd:
this.rootDir — the engine's main working copy. When a task's merge landed
on the remote or in another worktree and rootDir was never fetched/reset to
it, the judge reads PRE-merge files and returns a spurious `fail`. #1917's
premerge column guard doesn't catch this: the task column is already `done`,
so it falls through to handleValidationFail and mints a bogus Fix Feature.
Add a symmetric second guard in the fail branch, after the premerge column
check: isValidationWorkspaceStale resolves the task's integration SHA and
runs `git merge-base --is-ancestor <sha> HEAD` in rootDir. Only affirmative
staleness evidence (exit 1 = NOT an ancestor) defers the fail to
inconclusive; exit 0 (ancestor/fresh), a missing SHA, or a bad object
(exit 128) all trust the fail. Fail-open: a guard may DEFER a fail, never
SUPPRESS one on missing or unreadable data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
When a coding agent produces **zero commits** relative to base, the
AI-merge path wedges the card terminally:
1. `runAiMerge` → `landOneRepo` builds a clean-room worktree and runs a
dependency install.
2. On a non-workspace land the dep-install step throws hard (`if
(!ctx.nonFatalDependencySync) throw depsErr;`).
3. The throw is transient-classified and retried up to
`MAX_AUTO_MERGE_TRANSIENT_RETRIES` → `Auto-merge transient retries
exhausted (3/3)`.
4. The card is parked `failed` (→ archived), even though the correct
outcome for an empty branch is a no-op finalize.
The truly-empty branch *would* reach `outcome: "empty"` anyway via
`mergeAndReview` producing no `squashSha` — but only **after** the
throw-prone churn that fails first.
The canonical `aiMergeTask`/`classifyOwnedLandedEvidence` path already
has an early empty-own-diff fast-path; the `runAiMerge` → `landOneRepo`
path did not.
## Fix
Short-circuit inside `landOneRepo`, right after the `tipSha` computation
and **before** the clean-room build, when the branch is a confident zero
commits ahead of the integration tip:
```ts
const aheadRaw = await git(["rev-list", "--count", `${integrationBranch}..${branch}`], repoRootDir).catch(() => "");
if (Number.parseInt(aheadRaw.trim(), 10) === 0) {
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
return { outcome: "empty", tipSha, integrationBranch };
}
```
- Returns the **identical** `{ outcome: "empty", tipSha,
integrationBranch }` shape and the same `merge:ai-empty` audit event the
downstream already handles, so `runAiMerge`'s empty-outcome handling
(block-to-todo / no-op finalize) is unchanged.
- **Only** short-circuits on a confident `0`: a git failure yields `""`
→ `parseInt` → `NaN` (≠ 0) and falls through to the normal path — no
behavior change on error.
- Placed in `landOneRepo` (not `runAiMerge`) because it is shared by
both the single-repo and workspace per-repo callers.
## Test
New test in `merger-ai.test.ts` asserts the merge agent is **never
invoked** for a 0-ahead branch, the result is a no-op, `main` is
unmoved, and the card moves to `done` with `preserveProgress`. Without
the fix the branch reaches the clean room and `mergeAndReview` invokes
the merge agent, so the assertion fails — it genuinely guards the
short-circuit.
Full engine merge-suite regression run is green (merger-ai,
workspace-merger and lease variants, cleanup, dependency-sync,
group-merge, classify-owned-landed-evidence).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved merge handling for branches with no new commits ahead of the
target branch.
* Empty or already-synced branches now finish as a no-op instead of
triggering merge work.
* Tasks in this scenario still move to done, while the main branch
remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Problem
`MissionExecutionLoop.runFeatureValidation` treats a validator "fail"
verdict as authoritative regardless of whether the linked task's code
has actually landed. When validation fires while the task is still
mid-pipeline — an in-review PR, an external merge train, a deferred base
sync — the validator judges a checkout that predates the merge,
concludes the feature "is not present", and `handleValidationFail` mints
a Fix feature for work that is already done.
We hit this in production (2026-07-05): a recovery-path validation ran
against four features whose implementing tasks were in-review in an
external merge pipeline. All four "failed" → four duplicate Fix tasks
were created one minute after the real work merged. Worse, the Fix
tasks' planned file scopes included hot shared files, so their
file-scope leases serialized the entire board until they were manually
archived.
## Fix
Before dispatching a `fail` verdict, resolve the linked task's column.
If it affirmatively shows the task has **not** completed (any column
other than `done`/`archived`), route the outcome to
`handleValidationInconclusive` (R21 — completes the run as `blocked`,
logs `verification_inconclusive`, notifies autopilot, **spawns no Fix
feature**) with a "code not merged yet — validation deferred" reason. A
later validation (post-merge recovery pass) judges the real merged code.
**Fails open by design** — the guard may only ever *defer* a fail, never
suppress one on missing data. Missing `taskId`, missing task, unreadable
store, or unknown column all fall through to the normal
`handleValidationFail` path:
```ts
private async getPremergeTaskColumn(taskId: string | undefined): Promise<string | null> {
if (!taskId) return null;
const linkedTask = await this.taskStore.getTask(taskId).catch(() => null);
const column = linkedTask?.column;
if (!column || column === "done" || column === "archived") return null;
return column;
}
```
The vanilla flow is unaffected: the scheduler triggers validation on
`toColumn === "done"`, so by the time a normally-triggered validation
runs the task is already `done` and the guard is a no-op. Only
recovery-path / re-validation runs that race an unmerged task are
deferred.
## Tests
Three new tests in `mission-execution-loop.test.ts` (`premerge guard`
describe):
1. fail verdict + linked task `in-review` → routes to inconclusive: no
Fix feature, run completed as `blocked`, `validation:inconclusive`
emitted (not `validation:failed`), `verification_inconclusive` mission
event logged
2. fail verdict + linked task `done` → normal fail path: Fix feature
created, `validation:failed` emitted
3. fail verdict + `taskStore.getTask` rejects → fails open to the normal
fail path
`npx vitest run src/__tests__/mission-execution-loop.test.ts`: 58/58
green. `npx tsc --noEmit`: clean. Full engine suite: the 46 failures
across 24 files present on my branch fail **identically on clean
`17c4007`** (verified by re-running the same files on a detached
checkout of upstream main) — all pre-existing/environment-dependent,
none related to this change.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Validation failures now account for whether the linked task is
actually merged. If the task is still in progress, the result is marked
as inconclusive instead of creating a fix flow.
* Added clearer handling when task details can’t be read, so normal
failure behavior still applies.
* Improved validation status reporting and event logging for merged vs.
unmerged task states.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Problem
A transient error at merge time can flag an in-review card `failed` even
when its PR actually squash-merged on the remote (human merge,
merge-train, etc.). `recoverAlreadyMergedReviewTasks` runs the
already-merged **evidence detector** only against the **local** base
ref. If this process never fetched the merge, the owned commit is absent
locally → the detector returns `null` → `landed` is null → the card
never finalizes and **holds its file-scope lease forever**, wedging
every other task that touches the same files.
## Fix — fetch-then-prove
When a `failed` in-review candidate has a recorded PR
(`getPrimaryPrInfo`) and the local base yields no owned commit:
1. best-effort `git fetch origin <base>` (new `refreshRemoteBaseRef`
helper), then
2. re-run the **same** evidence detector against `origin/<base>`.
The detector's owned-commit proof and every foreign-ownership guard
inside it remain the **sole** finalize gate, so this only un-wedges a
genuinely-merged task — it never phantom-finalizes on unproven state.
**Safety:**
- Gated on a recorded PR — no PR ⇒ nothing could have merged remotely ⇒
no fetch.
- Fail-closed — a fetch error (offline / auth / no remote) is swallowed;
if `origin/<base>` can't be resolved the card is left untouched.
- No new dependency, no github client seam — direct git only.
## Tests
Two real-git tests in `self-healing-already-merged.real-git.test.ts`,
both verified to **fail without the prod change**:
- **fetch-then-prove positive:** a PR squash-merges on a bare remote
while the local base stays stale → recovery fetches, proves the owned
SHA against `origin/main`, and finalizes the card to `done`
(`mergeConfirmed: true`, worktree removed).
- **phantom-finalize guard:** remote base advances with a commit owned
by a *different* task → the fetch still runs, but the detector proves
nothing → the card is left `failed`/`in-review`, never healed.
Full self-heal suite: 594 passed. Engine typecheck clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved recovery for review tasks that appear already merged when the
local base branch is stale.
* The app now refreshes the remote base branch before re-checking merge
status, helping finalize tasks correctly and clean up completed
worktrees.
* Added coverage for cases where the remote base has moved forward with
either the merged commit or unrelated changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->