Keep successful verification responses quiet and return bounded, high-signal diagnostics for failures without hiding zero-work or green-while-red warnings.
Three related fixes, all originating from a `[api:error] Request failed`
log line showing a 500 on `GET /api/tasks/FN-8610/runtime-fallback`.
1. Missing/deleted tasks now return 404 instead of 500.
`getTaskImpl` signalled a miss with a bare `Error`, and route catches
only mapped errno `ENOENT` to 404 — a leftover from the file-backed
storage era. In Postgres mode nothing sets an errno code, so every
unknown/missing/soft-deleted/wrong-project read returned 500. Adds a
typed `TaskNotFoundError` (message byte-identical) plus a shared
`task-lookup-error` mapper applied across the task, session-diff,
git/GitHub, workflow and file-workspace route registrars. The same
bare throw existed on both archive-lifecycle delete paths, so
`DELETE /tasks/:id` was affected too.
2. 5xx logs now carry the origin stack.
`rethrowAsApiError` constructed a fresh `ApiError` from the message
and discarded the original, so the `FNXC:ApiErrorDiagnostics`
contract logged the rethrow site rather than the throw site — the
reported log entry had no stack at all. Threads `cause` through the
error factories and walks the chain (bounded, cycle-guarded).
3. Task deletions are attributable, and non-operator deletes notify.
`task:deleted` audit rows recorded `agentId: "system"` for every HTTP
delete, making an operator click indistinguishable from a script or
an agent; the calling agent's task id was accepted by the store and
then never persisted. Adds a `callerKind` union recorded in audit
metadata, tags every delete call site, and stamps a self-reported
`x-fusion-client` header from the dashboard client. When the caller
is `agent-tool` or `api-unattributed`, a best-effort notice is sent
to the operator mailbox; operator and engine deletes stay silent.
`x-fusion-client` is attribution, not authentication — anything can send
it. No delete-blocking, gating or permission logic is added here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace empty backendMode stubs with real AsyncDataLayer paths: archive ID
reservation and isTaskArchivedAsync, orphaned task.json re-import, health
snapshots via checkPostgresHealth, settings/agent memory caches for sync
readers, async builtin prompt overrides, and self-healing audit/health
callers that previously used dead sync SQLite fallbacks.
Completes 3b83282273. The classifier and the self-healing reader landed there but
nothing stamped `leaseNodeId`, so the pre-boot reclaim path was unreachable.
Wiring: InProcessRuntime -> TaskExecutor -> WorkflowGraphTaskRunner ->
WorkflowGraphExecutor, which writes the field onto the pending lease.
The executor takes `getLocalNodeId`, a GETTER rather than a value, because the
runtime resolves the node id asynchronously (a CentralCore read) partway through
start() while `executorOptions` is built earlier in the same method. A snapshot
taken at construction would freeze `undefined` and silently disable attribution
forever -- the failure mode where the feature looks wired, typechecks, and never
fires. Reading it at runner-construction time picks up the resolved id.
With this, a review gate whose session dies to an engine restart is reclaimed on
the next self-healing pass instead of waiting out the 15-minute staleness floor.
Peer-owned and legacy unattributed leases still take the floor, so the
double-dispatch protection multi-node depends on is unchanged.
Adds five classifier cases: own-node pre-boot reclaims; peer-node, unattributed,
own-node-post-boot, and no-identity-supplied all still adopt. Verified the first
is not vacuous -- disabling the branch fails exactly that case (1 failed / 13
passed) and no other.
Verified: tsc clean on core and engine, pnpm lint clean, pnpm test:gate green
(299 + 10 + 70), plan-review-lease + plan-review-single-owner +
self-healing-orphaned-pending-step-results green (27).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for FN-8603's remaining ~14-minute wait. Liveness for a pending
review gate is judged purely by a 15-minute staleness floor because a lease
records WHO took it (`leaseOwner` = run id) but not WHERE, and under multi-node
every engine sees every other engine's leases. A fresh-but-unknown lease might
be running on a peer, so the floor was the only safe test -- and a lease left by
this node's own crashed process is indistinguishable from it.
Adds `WorkflowStepResult.leaseNodeId` plus an optional `LocalNodeLeaseIdentity`
argument to `classifyReviewLease`. One narrow new case: a lease stamped with the
caller's OWN node id whose `startedAt` predates the caller's process boot is
provably dead -- the process that could have owned it is gone -- so it
classifies as `reclaim` immediately rather than aging out. Deliberately narrow,
because widening it is a double-dispatch risk: absent (legacy) or peer node ids
keep the floor, and a lease taken by this process after boot is still adopted.
InProcessRuntime.start() resolves the local node id from CentralCore (fail-soft;
on error it stays undefined and floor-only semantics apply) and passes it to
SelfHealingManager. The graph executor stamps the field when deps.localNodeId is
set.
NOT YET WIRED, so this is inert in production and behavior is unchanged end to
end: `localNodeId` is not threaded from WorkflowGraphTaskRunner /
WorkflowTaskRuntime down into the executor deps, so no lease actually carries a
`leaseNodeId` yet. The reader is ready; the writer needs that pass-through
(WorkflowGraphTaskRunnerDeps gains the field, the runner forwards it, and the
runtime supplies this.localNodeId). Stopping here rather than half-threading it.
Verified: tsc clean on core and engine, pnpm lint clean, pnpm test:gate green
(299 + 70), core workflow-step-results suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FN-8603 sat in-review for ~36 minutes after an engine restart killed its Code
Review session 34 seconds in. It did recover on its own; the cost was latency,
not a terminal park.
Sweep ordering. reconcile-orphaned-pending-step-results PRODUCES the failed
results that recover-failed-pre-merge-steps CONSUMES, but in the periodic
maintenance list it ran ~15 entries after it. A step orphaned in cycle N was
therefore rewritten to failed only after recovery had already scanned, so
nothing re-ran it until cycle N+1. Moved it immediately before its consumer and
removed the now-duplicated later entry. Startup recovery already ordered the two
correctly.
Post-review fix budget. Default raised 3 -> 10 per operator request. Three
passes is below the observed convergence length for the gates this fallback
actually governs -- Browser Verification and custom optional gates -- since Plan
Review and Code Review already resolve to "unbounded" when unset, and exhausting
the budget parks the card for a human. The declaration default and five inline
`settings.maxPostReviewFixes ?? 3` call sites in executor.ts/self-healing.ts had
drifted into separate literals, so raising one alone would have left every
unset-settings path on the old value; they now share the exported
DEFAULT_MAX_POST_REVIEW_FIXES.
Not done, and why. Re-dispatching a restart-orphaned lease immediately at
startup is the change that would close the remaining ~14-minute wait, but it is
unsound as specified: liveness is judged by a 15-minute lease-staleness floor
because leases carry no node attribution, so treating a pre-boot lease as dead
would let one node orphan another node's genuinely running review. Needs a node
id on the lease record first. Left the floor intact.
Verified: tsc clean on core and engine, pnpm lint clean, pnpm test:gate green,
self-healing orphaned-pending-step-results and optional-step-revision suites
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`moveTask`'s reopen-to-todo/triage block clears `task.worktree` but leaves
`task.branch` intact, and `moveTaskToReplanColumn` called it with no options.
A replan bounce therefore left the row split-brained: no worktree pointer, but
still owning `fusion/<id>`, which was still checked out in the worktree it had
just orphaned. The next planning acquisition skipped its resume branch (gated on
`task.worktree`), re-created the same branch, collided, and fell into
`cleanupConflictingWorktree` — force-remove + `git branch -D` + fresh
`git worktree add` + init command, on every bounce. Observed on FN-8603: two
Plan Review REVISE bounces burned two full teardown/rebuild cycles for nothing,
since planning writes its spec to the task store, not the worktree.
Pass `preserveWorktree: true` at the shared seam, so this covers every replan
mover — Plan Review REVISE, required-artifact recovery, and the executor and
scheduler spec-staleness and filesystem-validation rebounds. Acquisition still
re-validates, so a preserved pointer to a removed checkout self-heals as before;
the rest of the replan contract (steps reset, status/error cleared) is unchanged.
Regression coverage asserts the invariant across both replan-column shapes
(triage and plan-in-place todo) and all three reopen origins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prompt fix stops planners writing the verdict in prose, but it relies on
every model reading one sentence correctly. This closes the hole underneath it.
When the finalize read finds no spec at all, the planner's streamed reply is
searched for a line that is exactly `DUPLICATE: FN-NNNN`. If found, the engine
writes the canonical marker file and continues — so marker parsing, keep/delete
resolution, and the sourceMetadata.nearDuplicateOf that renders the operator's
decision all run on the unchanged file contract rather than a second code path
that could drift from it.
Deliberately narrow. The marker must occupy a whole line, only the first counts,
and recovery is gated on the plan being genuinely absent — a planner that wrote
a real spec is never overridden by something it said in passing. The text tail
is bounded because the verdict lands in the closing summary, and it tees off
onText rather than reading AgentLogger, whose buffer is flushed on a timer.
Verified both directions: the tests fail without the recovery block, and the
"wrote a real spec while mentioning a marker" case keeps its spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Route foreach/merger/worktree/self-healing skips, ntfy send bookkeeping, session-purpose runtime picks, planning using-model, and checkpoint rewind lines to debug so recoveries and failures stay visible in the operator log.
Route process spawn/exit, verification success paths, MCP connect, skill info listings, createFnAgent/session bookkeeping, and executor dispatch chatter through FUSION_DEBUG so the operator log pane keeps real lifecycle outcomes.
Planning moved into the task's own worktree but never published that path to
activeSessionRegistry, so the self-owned-branch reclaim sweep's FN-4819 liveness
guard was blind to a live planner. A zero-commit task branch trivially reads as
tip-already-merged, so the sweep ran `git worktree remove --force` on the tree a
planning session was using, the removal failed, and the failure escalated to
branch-conflict-unrecoverable — parking a healthy card paused with no operator
action.
Planning now claims its worktree through acquireActiveSessionPath (new "planning"
session kind) and releases it only while it still owns the record, so a live
executor that took over the same path mid-teardown is never cleared.
Also fixes planning starvation and its diagnosability:
- admitOldest walks past candidates whose lane declines instead of ending the
pass on candidates[0], unwinding each declined attempt's pre-held executor slot
and reservation exactly so a decline cannot leak capacity past maxConcurrent.
- Withheld planning admission emits a deduped task:plan-admission-throttled
run-audit event (ids/counts only), written fire-and-forget with the dedupe
marker set only after the write lands. Previously the binding gate lived only
in a log line that is persisted nowhere, so "why did this card sit queued to
plan?" was unanswerable after the fact.
Reviewed by 8 review agents; every finding acted on or recorded. A proposed
STALE_SEMAPHORE_EXCESS_REPAIR_MS 600s->180s reduction was reverted under review —
nested runs are already excluded from the reclaim floor, so the window guards
uncounted top-level holders such as a merge body, and shortening it would trade a
bounded visible stall for an unbounded silent cap breach.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recognise workflow-graph moves into in-review so gate entry no longer emits handoff-invariant violations, and split pause-abort provenance so engine teardowns are engine-abort instead of hard-cancel.
Close log-spam skeptic gaps: routine-scheduler pause and re-entrance no-ops, and peer-exchange zero-work sync cycles, move to debug with contract-test locks.
Second FN-8596 strand, found after the first fix shipped. Clearing the
stale `planning` status moved the card into a state owned by NOBODY:
- planning excluded it: stale `firstExecutionAt` from its first pass made
hasAdvancedPastPlanning true, and the previous fix only rescued cards
that still carried a planning-stage status;
- recoverAdvancedTriageTasks — the designated owner of that
"stranded-advanced" class — also excluded it, because it bails on
`workflowIrPinColumnId === "triage"`: it cannot resume a card into the
column it already occupies (the pin was plan-replan, which lives in
triage).
So the card sat indefinitely with no sweep, log, or audit event naming it.
hasAdvancedPastPlanning now decides on arrival order alone for any card in
the planner column: a stamp written BEFORE the card reached triage belongs
to a previous pass, whatever the status is now. A card that genuinely
advanced is still caught by the column check at the top, and one claimed by
execution AFTER landing here has a stamp newer than its arrival, so it
still reads advanced and stays with advanced-recovery. This flips one case
I added in the previous commit — production proved that classification
stranded the card.
Hardening, so this class cannot hide again:
- detectStalledCards: a detect-only watchdog emitting
`task:stall-watchdog-detected` for any non-terminal, unpaused card idle
past 30m with no live session and no queued continuation. Deduped per
shape. It deliberately does NOT mutate — a generic mutator racing the
specialized sweeps is the bug class this file keeps re-fixing, so
recovery stays with the sweep that owns each shape and this guarantees
visibility.
- The silent skips are now loud: runIfStillPlanningUnderTaskLock (all
four callers inherit it), the planning handoff moveTaskIf, and the four
requestPreMergeOptionalStepFix refusals now log why nothing was
scheduled and that the card was left parked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Make planning-guard and remediation no-ops emit warnings, and detect idle non-terminal cards with no session or continuation so FN-8596-class strands show up in logs and run-audit.
Root cause of the FN-8596 strand (card sat in Planning, doing nothing,
until an engine restart).
Plan Review returned REVISE, the graph rebounded the card to `triage` with
`needs-replan`, and triage claimed it — overwriting the status with the
TRANSIENT `planning`. `needs-replan` is a durable park that outranks the
execution timestamps, but `planning` is deliberately excluded from
REPLAN_PARK_STATUSES, so the card fell through to the stamp check. Those
stamps were written when it entered `in-progress` on its FIRST pass and are
never cleared, so the replanning card read as "advanced past planning" for
the rest of the session.
From there everything was a silent no-op:
updatePlanningStateIfStillCurrent returned false and its callers returned
with no log, no audit and no requeue. The revision session wrote the
revised PROMPT.md (via the store tool, which bypasses the guard) and the
finalize refused to hand the card off — "prompt written, then total
silence", status frozen at `planning`.
Stale stamps are now discriminated from a live claim by arrival order: a
stamp written BEFORE the card arrived in the planner column belongs to a
previous pass, while one written after arrival means execution genuinely
won the FN-8361 race and recovery must not clear the status out from under
it. A missing/unparseable columnMovedAt keeps the prior answer, so this can
only narrow the strand, never widen the race. The PR #2360
stranded-advanced class (stamps with no planning status) is untouched — all
30 pre-existing guard cases still pass.
Also warns when a planning finalize declines to hand off. That path was
completely silent, which is why this strand left nothing in any log.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Observed on FN-8596: a plan-review REVISE routed to `plan-replan`, triage
claimed the card with `status:"planning"` and ran the revision session, and
the session wrote the revised PROMPT.md then died without finalizing. The
card sat in `triage` with `status:"planning"`, no live planner, and no
workflow continuation.
That status makes the card invisible to triage rediscovery (it looks
claimed), and the only sweep that cleared it ran at STARTUP — so the card
was unrecoverable short of an engine restart. The leaked-slot reaper then
reclaimed its concurrency slot, which made it look idle without making it
runnable.
Adds a periodic counterpart in the poll loop. Clearing the status is the
whole repair: the card is back in triage with a real spec, so ordinary
rediscovery re-picks it. It does not move, pause, or fail the card.
Guards against racing a healthy planner: the in-process `processing` set,
plus a 20-minute staleness floor that also covers a planner owned by
another node this process cannot see. Operator parks are never touched.
This fixes the recovery gap, not the trigger — why that session failed to
finalize is still under investigation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-ups to running the pre-merge review gates in `in-review`. Each was
verified against the code before being fixed; one reported issue was
refuted and is noted below.
1. Symbol locks (packages/core/src/task-store/moves.ts)
FN-8306 made the lifecycle transition the symbol-lock RELEASE authority
but wrote no counterpart. That was harmless while a task only left WIP
at handoff/terminal; the gate crossing now releases the task's declared
symbols and the remediation node re-enters `in-progress` to edit the
same files in the same live worktree with its locks gone. Neither
acquire site (scheduler dispatch, claimDueWorkflowWorkItem) is on the
graph re-entry path. Adds a symmetric re-acquire on `!wip -> wip`.
Best-effort by design: a contended symbol logs and proceeds, which is
exactly the pre-fix posture, rather than parking the remediation behind
another holder and re-creating the stranding this change set removed.
2. Premature merge (packages/engine/src/self-healing.ts)
`recoverMergeableReviewTasks` was the only in-review sweep with no
liveness gate. The graph commits the column crossing at node entry and
writes the gate's pending lease two DB round trips later, and
`getTaskMergeBlocker` has no notion of "enabled but resultless", so in
that window the sweep could enqueue a merge with Code Review never run.
Filters `executingIds`, matching recoverGhostReviewTasks.
3. Orphan sweep (packages/engine/src/self-healing.ts)
The reported restart hazard is REFUTED: nothing re-attaches an in-review
graph run, so those leases are genuinely dead and marking them failed is
correct FN-8492 behavior. But the sweep also runs from periodic
maintenance in the same live process, where a tick between the lease
write and session registration could fail a gate that just started.
Honors a within-floor `classifyReviewLease`, matching the semantics Plan
Review already had. Cleanup of dead leases is delayed by the staleness
floor, not defeated. The audit event gains `needsOperatorBypass` for
`autoMerge:false` rows, which self-healing deliberately skips and only
fn_task_bypass_review can clear — previously indistinguishable from an
auto-recoverable rewrite.
4. Stall detection (packages/engine/src/planner-overseer.ts)
The `reviewer` and `merger` stages had no time-based check at all and
returned `progressing` unconditionally, so a hung gate produced no
signal however long it sat. Adds gate-anchored detection on both (a
plain in-review card with no reviewState resolves to `merger`, not
`reviewer`), keyed on the pending lease's own `startedAt` rather than
`columnMovedAt` so it cannot fire during a legitimate human merge-wait.
`cumulativeActiveMs` is documented, not changed: it now excludes gate
runtime, but adding the `timing` trait to `in-review` would count arbitrary
human merge-wait as active work — a worse distortion than the omission.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code Review and Browser Verification now run with the card in `in-review`
instead of `in-progress`, so the board shows the card under review with the
running step as a badge (matching the Coding (Ideas) preset). Their paired
remediation nodes stay in `in-progress`, so a changes-requested verdict
visibly sends the card back to implementation.
The column move IS the badge switch: the dashboard badge was already
lane-gated on `column === "in-review"`. Applied to the shared stepwise
coding IR, so it is inherited by builtin:coding (the default),
builtin:stepwise-coding, builtin:brainstorming and builtin:coding-ideas;
builtin:legacy-coding keeps its historical placement.
Two consequences handled:
- Capacity: `in-review` has no `wip` trait, so the slot is released during
review and the remediation crossing back into `in-progress` can hit the
non-bypassable in-transaction capacity check. The column boundary now
PARKS the run on a `capacity-exhausted` rejection instead of failing it,
preserving the failed gate result and worktree so the next graph run
retries once a slot frees. Non-capacity rejections still propagate.
- Reopen clears: `applyReopenFieldClears` wiped `workflowStepResults` on
every in-review -> in-progress move, which the remediation crossing now
performs routinely. That destroyed the remediation input, made
`routeRetryableRemediationGraphFailureToPreMergeFix` and
`recoverFailedPreMergeWorkflowStep` silently no-op, and — worse — made
both `getTaskMergeBlocker` branches vacuously false, so a card could
return to `in-review` and be mergeable with its gate never re-run. Now
exempted for graph-owned in-review -> in-progress crossings only;
operator reopens, merge bounces and every -> todo/triage rebound still
clear, so the executor's documented bounce invariant is unchanged.
Adds regression coverage for both (there was previously none for the
reopen clear in either direction), and annotates the unreachable legacy
scheduler dispatch block rather than mirroring the fix into dead code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to 13a2b2a9d, from a multi-agent review of that commit. Three of its
claims did not hold.
1. fn_delegate_task bypassed the gate entirely (P0). It reaches the same
createAgentTask primitive, was registered unconditionally in both session
lanes, and validated only that the TARGET agent is non-ephemeral — never the
caller. Under Deny an ephemeral worker could enumerate agents and delegate
unlimited tasks. It is now withheld under Deny, and also under
upon_validation: delegation has no proposal channel, so leaving it available
would launder a create past the operator review that policy requires.
2. The widened dedupe window was capped at 5 minutes. The store query in
branch-and-pr-entities.ts carried its own independent `?? 60_000` /
`min(300_000, …)` pair, so widening only duplicate-guard.ts under-delivered
and made the new ceiling unreachable. Both sites now share
FINGERPRINT_WINDOW_DEFAULT_MS / FINGERPRINT_WINDOW_MAX_MS.
3. The pi-extension gate does not fire at all. pi's ExtensionContext carries no
agentId — the read is a speculative cast and only tests supply one, so every
real call short-circuits as a human caller. The fail-closed direction is kept
for the day an identity signal exists, but the limitation is now documented
instead of implied to be enforcement.
Also: the session prompt now states when creation is disabled and names
fn_task_log as the fallback (the base prompt still taught fn_task_create, which
is the same instruction/capability mismatch that fed the retry storm);
suppression emits an `agent:task-create-withheld` run-audit event; and the two
source-text ratchet tests are replaced with behavioral assertions on the tool
list the executor actually hands the model — verified to fail when the guard is
broken, which the string assertions did not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of 2dbfe3d31 + 05b704dc6 surfaced real defects in both fixes:
- The unusable-worktree probe composed two helpers across an unnecessary
self-healing -> step-runner import edge, and the directory check added no
discriminating power over the `.git` probe. Replaced with one canonical
hasUsableWorktreeShape beside classifyTaskWorktree, which also applies the
repo-root gate (FN-6861) when a rootDir is available; both call sites pass one.
Its narrower guarantee vs the canonical classifier is now documented and
pinned by tests, including the de-registered shape it cannot see.
- REPLAN_PARK_STATUSES is derived from PLANNING_STAGE_STATUSES instead of
re-listed, so a new durable park status cannot be added to one set only.
- The preserve/clear decision no longer pretends to steer `worktree`: the rebound
is a reopen move, which clears it regardless. Documented, and the test now
asserts the durable row rather than only the updateTask argument.
- `branch` is cleared only when it is the re-derivable canonical fusion/<id>;
a non-canonical branch survives so a card's only commit pointer is not dropped.
- The recovery log named the recorded worktree even when the session had targeted
an AI-merge clean room. It now names the refused path and says whether the
recorded worktree was gone too.
- Added task:auto-recover-worktree-session-metadata so the decision is legible to
agents, not only in human log prose.
- isTaskStillInPlanningStage's parameter type now includes the execution stamps
its implementation reads.
- Test hygiene: real-fs fixtures wrapped in try/finally; changeset dev note
corrected; FN-8361 asserted at the discovery surface, not only in the guard
table.
Also captures the shared bug class in docs/solutions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator report: with project policy "Ephemeral agent follow-up tasks = Deny",
an executing agent filed ten follow-up tasks — five parallel fn_task_create
calls it reported as timed out, then five sequential retries.
Two defects:
1. Deny was advisory. fn_task_create was registered for every session and only
refused inside execute(), so the model still saw the tool, planned around it,
and retried it. The pi extension's isEphemeralCallerAgent also failed OPEN
whenever the caller id did not resolve to an agent row — which is the normal
shape of an ephemeral task-worker — so on that lane Deny was a no-op.
2. The deterministic content-fingerprint duplicate window was 60s, which only
covered concurrent in-flight creates. A retry two minutes later saw nothing
and filed a second task.
Fixes: isAgentTaskCreateToolAvailable() withholds the tool from ephemeral
sessions under Deny in both engine lanes (outer execution session, per-step
workflow session); isEphemeralCallerAgent fails closed on an unresolvable
caller id; the fingerprint window goes 60s -> 10m (clamp ceiling 5m -> 1h).
upon_validation keeps the tool, and permanent-agent and human/chat callers are
unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unusable-worktree recovery preserved task.worktree whenever the failing path
differed from it, treating the difference as proof the recorded worktree was
live. The reported strand had both gone: an AI-merge clean room refused as an
"incomplete worktree" while the task worktree had already been removed, so
every requeue re-dispatched into a missing directory ("Working directory does
not exist … Cannot execute bash commands") until the retry budget burned out
and the card parked failed in review. Preserve the recorded worktree only when
it is still a usable checkout; otherwise clear it so the next dispatch builds a
fresh one from the branch.
Also splits the planning-stage guard: only the DURABLE replan parks
(needs-replan, plan-review-unavailable) outrank sticky execution stamps.
"planning" is the transient planner claim, so a stamp landing on it still means
execution won the FN-8361 race.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan Review REVISE rebounds a card to a planner lane with status
needs-replan, but hasAdvancedPastPlanning read the sticky
firstExecutionAt/executionStartedAt stamps as proof the card had left
planning. Triage discovery filters on that guard, so a rebounded card was
never re-admitted and sat in triage/needs-replan forever ("stuck in
planning"). An explicit planning-stage status now outranks the stamps in
both planner lanes; a triage card stamped with no planning status is still
excluded so self-healing's advanced recovery keeps owning it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-pin the execSync allowlist after self-healing/executor line shifts, capture
implementation-session tools under the graph-owned pause harness, treat
worktree alone as not past planning for replan targets, and age starved-
refinement fixtures past the post-escalation cooldown window.
Planning sessions run in the task's own worktree with the coding tool
surface, but the spec path handed to the planner is relative
(.fusion/tasks/<id>/PROMPT.md) while finalization reads it against
rootDir. A planner using the generic write tool instead of
fn_task_prompt_write stranded the spec inside the worktree, where
finalization could not see it and worktree disposal destroyed it.
project.tasks also has no `prompt` column, so PROMPT.md was
filesystem-only and the project checkout was the sole durable copy of
every plan.
Add plan-artifact-writeback.ts:
- reconcileWorktreePlanArtifact copies a worktree-stranded PROMPT.md
back into the main project .fusion folder through
store.updateTask({ prompt }), keeping File Scope validation, the root
write, and the task.json sync atomic. Empty, absent, and identical
worktree copies are no-ops so a correct spec is never clobbered.
- mirrorPlanToProjectDb mirrors the authoritative plan into the `plan`
task document, which triage already reads as a planning-draft
fallback, making that recovery path DB-backed. Identical content is
skipped so revisions do not churn.
Both are best-effort: a failure never turns a good planning pass into an
error. Wired at the reconcile-before-finalize-read seam, at finalization
with the post-hygiene accepted content, and inside fn_task_prompt_write.
Covers the invariant rather than the repro: tests assert worktree-
stranded, root-only, empty worktree file, absent file, identical
content, persistence failure, redundant-mirror skip, and mirror failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fn_task_promote can now pass force:true to start execution when a task is still
waiting on planning or plan review, matching the dashboard's promote override.
The rejection message names the flag so a caller that hits the gate can decide,
and a forced release says the pending replan was cancelled rather than burying it.
Force stays opt-in per explicit promote request: the hold-release sweep and the
webhook event release have no force parameter, so FN-7648 still holds for every
automatic surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reclaim sweep's tip-already-merged arm vetoed on the branch tip's foreign
Fusion-Task-Id trailer alone. A task branch cut from the base that never
committed anything (planning aborted, card moved back to todo) points at the
PREVIOUS task's landed commit, so the veto fired on inherited metadata: the card
kept stale worktree/branch/baseCommitSha and re-logged
"already-merged rejected ... reason=foreign-task-tip" every sweep.
Hoist the merge-base diff proof already used by already-merged and
branch-misbound recovery into a shared foreignTipRejection helper and route all
three callers through it. Rejection still fires when the branch carries unique
content or the base already has this task's own commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promote on a held card printed the raw i18n key `board.rejection.unplannedForExecution`:
FN-8471 added the server-side code without a client case or catalog entry, so
translateRejection fell through to `t(messageKey, messageKey)`.
- Add the explicit rejection case (both translate helpers) plus the en catalog
entry and secondary-locale stubs.
- promoteHeldTask(..., { force }) waives ONLY the unplanned-for-execution gate;
hold membership, capacity and slot reservation still arbitrate. It clears a
needs-replan/plan-review-unavailable status so triage rediscovery cannot pull
the card back into the waived replan, and emits task:promote-forced-unplanned.
- POST /tasks/:id/promote accepts { force: true }; the board asks for explicit
confirmation first and only offers the override for this rejection.
Force stays operator-only — the sweep, the webhook release and fn_task_promote
never set it, so FN-7648 still holds for every automatic surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Withdrawing a card from planning (todo -> Ideas) now stops the work:
- triage aborts and disposes the planning session through the same path
pause/delete already use, and clears status:"planning" so the planning badge
goes away and the card reads as a plain idea again;
- the executor aborts in-flight graph work on any backward move out of
todo/triage, so a Plan Review does not keep streaming against a card the
operator pulled back;
- moving it back to todo needs no new code: the existing column wake fires and,
with the status cleared, the card is an ordinary planning candidate again.
Pre-execution worktrees (planning acquires one now) are reclaimed two ways: an
immediate release on an explicit withdrawal, and a self-healing sweep
`reconcile-pre-execution-worktrees`. The sweep is deliberately timid — 30 days
of complete inactivity, and it skips anything active or waiting (todo,
executing, in-review, done, paused, carrying any status, blocked, or scheduled
for recovery). Every real safety condition lives in the executor: never
executed, no live session, clean branch, nothing uncommitted.
hasAdvancedPastPlanning no longer reads a worktree as execution evidence.
Planning owns a worktree now, so that signal would have made every planning
write skip; execution timestamps carry the meaning instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contention prevention (why tasks shared a path at all):
- Planning ran `tools: "coding"` at the repo root, so every planner had write
tools in the operator's checkout and all planners shared one path. Planning
now acquires the task's own worktree (TriageProcessor.acquirePlanningWorktree
-> TaskExecutor.ensureTaskWorktreeForPlanning).
- Graph nodes with no worktree acquired one instead of falling back to rootDir,
so Plan Review / Code Review / custom gates all run isolated. Plan Review
re-acquires when its recorded worktree is gone, replacing FN-7996's
run-from-the-repo-root degrade. Workspace projects are unchanged.
- Registration goes through acquireActiveSessionPath, which reclaims a leaked
entry whose holder is provably dead and aged past the FN-5256 floor. A live
holder still contends — real serialization is never clobbered.
Classification (the reported symptom):
- A lease held by another task is no longer a provider failure. It carries
SESSION_CONTENTION_HOLD_VALUE, classifies transient, is excluded from
isNonPlanDefectPlanReviewFailure, and stops burning the node's fast retries.
- The executor waits it out on a 10-attempt 5s->60s ladder and then leaves the
task cleanly queued. There is no terminal branch: contention always ends, so
parking would only ask a human to press Retry on a condition that fixed
itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan Review needs no worktree, so it runs rooted at the project root. The
activeSessionRegistry key was the bare root path, so the second task to reach
Plan Review hit ActiveSessionPathHeldByForeignTaskError ("path ... is held by
task FN-1398; task FN-1403 may not overwrite it"). That surfaced as a Plan
Review provider failure, burned the in-place retry budget against a hold no
retry could clear, and left the task parked.
Task-scope the registry key for any session rooted at rootDir, in every project
mode — the workspace fix already did this for the shared browse-root. Root
exclusivity protects nothing here: write-capable nodes are refused at the root
outright, and every isPathActive consumer guards removable worktree paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quick-add "Start" collapses create+promote into one request: it submits the
workflow id AND the post-intake `todo` column together, so the card lands in
`todo` having never sat in the workflow's manual intake column. The intake test
in task-creation.ts only matched `triage` or the resolved intake column, so the
card got generateSpecifiedPrompt — whose hard-coded boilerplate steps
("Implement the required changes") no planner ever wrote.
That stranded the card permanently: triage's todo-discovery admits a card only
when its PROMPT.md reads as a seed, so the placeholder spec was classified
"already planned" and never planned, while nothing could execute it either
(steps: []). It sat in Todo forever with no log line in any lane. Observed on
FN-8587.
Creates into `todo` on a manual-intake workflow (resolved intake is not the
legacy `triage`) now get the bootstrap seed. The pinned contract for a plain
direct create into todo on the default workflow — which intentionally keeps
generateSpecifiedPrompt — is untouched, and both create sites are fixed in step.
Also instrument the hold/release sweep, which had reasons but no timings:
per-task held duration reported on release, a per-sweep summary breaking out the
prefetch cost (a sequential await per non-archived task, so it scales with board
size rather than with held cards), and a warn when a sweep exceeds 2s — so a
"ready card doesn't move" delay can be attributed between poll cadence, sweep
cost, and a card genuinely queued on capacity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the second half of the "started card does nothing" gap. Plan-in-place
workflows (Coding (Ideas)) finalize by clearing `status` in place — finalize
deliberately skips the triage->todo move — so a card that just became executable
emits only a bare task:updated. None of the scheduler's existing event wakes
(task:created, globalPause/enginePaused unpause, per-task unpause) fire for that
transition, so the operator paid one poll interval for planning to start and
another for execution to start.
Track ids seen with status "planning" and trigger a scheduling pass when they
return to a dispatchable state, mirroring the pausedTaskIds unpause tracker.
Guarded on !status, not paused/userPaused, and a schedulable column, so a
planning -> failed/awaiting-approval park does not trigger a pointless pass.
schedule()'s re-entrance guard drops the call if a pass is already in flight,
and the id is cleared on task:deleted alongside the other per-task sets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing Start on a Coding (Ideas) card only writes a column move — there is no
dispatch call in that path — so planning did not begin until the triage
processor's next timer tick, up to pollIntervalMs (15s default) later. The
"Started planning" toast was optimistic and the card just sat in Todo.
- Wake planning discovery on the store's task:updated/task:created event when a
task lands in todo/triage. Binding the wake to the store event rather than the
Start button covers every move surface (board drag, context menu, task detail,
List view, CLI, agent tools, POST /tasks/:id/move) by construction. The wake is
advisory: it only advances WHEN the poll runs, so every pause, seed-prompt,
dependency, and concurrency gate still applies.
- Admit a todo task whose PROMPT.md is missing instead of dropping it through a
silent `catch {}`. The scheduler KEEPS a candidate whose prompt it cannot read,
so such a card was invisible to planning while still visible to dispatch, with
no log line in either lane. Unreadable (non-ENOENT) prompts now log.
- Route the scheduler's dispatch filter through the shared isUnplannedSeedPrompt
predicate. Its open-coded strict bootstrap compare disagreed with triage on the
refinement-seed shape, leaving hold-release as the only thing between an
executor and a prompt containing just the operator's feedback text. The
predicate also normalizes line endings/trailing whitespace, so a CRLF or
trailing-newline round-trip no longer reclassifies an unplanned card as planned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>