Files
fusion/packages/engine/src/executor/wire-executor-lifecycle.ts
Fusion Agent 33f4797ceb FN-126: fix inert sync-lane validation
Prevent inert synchronization lanes from bypassing merge-gate validation while preserving task-lane cache and archive lifecycle behavior.

- Reject sync-resolved lane conversions in the static validator.
- Preserve task-lane cache emissions and active-session cleanup across task mutations and archival.
- Add regression coverage, baseline updates, documentation, and a release changeset.

Files changed:
 .changeset/fn-126-inert-sync-lane.md               |   7 +
 .../a-falling-count-is-not-evidence.md             |  12 +
 .../task-lane-cache-emitter-preservation.test.ts   | 269 +++++++++++++++++++++
 .../core/src/task-store/archive-lifecycle-2.ts     |   3 +-
 packages/core/src/task-store/moves.ts              |   3 +-
 packages/core/src/task-store/task-artifacts-ops.ts |   3 +-
 packages/core/src/task-store/task-update.ts       |   3 +-
 packages/core/src/task-store/update-task-deps.ts   |   3 +-
 ...xecutor-archive-releases-active-session.test.ts |  80 ++++++
 packages/engine/src/executor.ts                    |   4 +-
 .../src/executor/executor-side-effect-hosts.ts     |   2 +-
 .../executor/is-backward-move-out-of-planning.ts   |   7 +-
 .../src/executor/task-executor-graph-facades.ts    |   2 +-
 .../engine/src/executor/task-executor-imports.ts   |   1 -
 .../engine/src/executor/wire-executor-lifecycle.ts |  18 +-
 .../check-inert-sync-lane-conversions.test.mjs     |  83 +++++--
 scripts/check-inert-sync-lane-conversions.mjs      |  19 +-
 scripts/lib/inert-sync-lane-baseline.json          |   6 +-
 18 files changed, 479 insertions(+), 46 deletions(-)

Fusion-Task-Id: FN-126

Fusion-Task-Lineage: 0c7a1a3a-9446-44f8-955d-df6c402dfd31

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-23 01:36:12 +00:00

939 lines
53 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-03-22:40:
* TaskExecutor constructor lifecycle wiring peeled from executor.ts (U4).
*
* Registers task-move/archive disposers and task:moved / task:deleted /
* task:updated / settings:updated listeners. Free function so the class
* constructor stays a thin wire-up of deps.
*
* FNXC:CodeOrganization 2026-08-04-04:00:
* buildWireExecutorLifecycleDeps owns the field/method name lists so TaskExecutor's
* constructor is a one-liner (store/rootDir/options + facadeFields/Methods bag).
*/
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import type { Task, TaskStore, TaskMoveLanes, RunMutationContext } from "@fusion/core";
import {
canonicalizeWorktreePath,
registerArchiveWorkspaceWorktreeDisposer,
registerArchiveWorktreeDisposer,
registerTaskMoveDisposer,
resolveEffectiveAgent,
} from "@fusion/core";
import { RemovalReason, removeWorktree } from "../worktree/worktree-pool.js";
import { activeSessionRegistry } from "../agents/active-session-registry.js";
import { resolveExecutorSessionModel } from "../agents/agent-session-helpers.js";
import { executorLog } from "../logger.js";
import { mergeEffectiveSettings } from "../project/effective-settings.js";
import { resolveExternalExecutionCheckoutRoute } from "../execution/external-execution-checkout.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { TaskExecutorOptions, ActiveExecutorSessionState } from "./task-executor-options.js";
import type { PausedAbortProvenance } from "./paused-abort-provenance.js";
import type { StepSessionExecutor } from "../execution/step-session-executor.js";
import { extractOwnSettings } from "./agent-binding-pure.js";
import { formatCommentForInjection } from "./execution-prompt.js";
import { detectReviewHandoffIntent } from "./pseudo-pause.js";
import { createSeenSteeringIds } from "./task-predicates.js";
import { facadeFields, facadeMethods } from "./facade-methods.js";
import { WorkflowAgentCapacity } from "../agents/workflow-agent-capacity.js";
const execFileAsync = promisify(execFile);
/** Field names collected from TaskExecutor for lifecycle listeners. */
const WIRE_LIFECYCLE_FIELDS = [
"activeConfiguredCommandControllers", "activeSessions", "activeStepExecutorSeenSteeringIds",
"activeStepExecutors", "activeSubagentSessions", "activeWorkflowGraphAbortControllers",
"activeWorkflowStepSessionSeenSteeringIds", "activeWorkflowStepSessions",
"approvalResumeAfterUnwind", "approvalSuspended", "effectiveColumnAgentByTask", "executing",
"graphColumnAgentResolver", "graphRouting", "graphSeamGoverningNodeId", "loopRecoveryState",
"pendingTaskDisposals", "recoveringCompleted", "spawnedAgents", "stuckAborted",
"userCanceledTaskIds", "workflowLifecycleMovesInFlight",
] as const;
/** Method names bound from TaskExecutor for lifecycle listeners. */
const WIRE_LIFECYCLE_METHODS = [
"awaitAbortInFlightTaskWork", "clearWorkflowRerunWatchdog", "deleteActiveWorkflowStepSession",
"dispatchUnpauseResume", "disposeSubagentsForTask", "execute", "executeReviewHandoff",
"getAssignedAgentRuntimeConfig", "getModelRegistry", "getRunContextFor",
"isBackwardMoveOutOfPlanning", "markPausedAborted", "releasePreExecutionWorktree",
"removeOwnWorktreeWithReconcile", "resetMergeStateIfNeeded", "resolveResumeLanes",
"terminateAllChildren", "trackTaskDisposal",
] as const;
export type WireExecutorLifecycleDeps = {
store: TaskStore;
rootDir: string;
options: TaskExecutorOptions;
// Mutable maps/sets owned by TaskExecutor (mutated by listeners)
activeConfiguredCommandControllers: Map<string, Set<AbortController>>;
activeSessions: Map<string, ActiveExecutorSessionState>;
activeStepExecutorSeenSteeringIds: Map<string, Set<string>>;
activeStepExecutors: Map<string, StepSessionExecutor>;
activeSubagentSessions: Map<string, Set<AgentSession>>;
activeWorkflowGraphAbortControllers: Map<string, AbortController>;
activeWorkflowStepSessionSeenSteeringIds: Map<string, Set<string>>;
activeWorkflowStepSessions: Map<string, AgentSession>;
approvalResumeAfterUnwind: Set<string>;
approvalSuspended: Set<string>;
effectiveColumnAgentByTask: Map<string, string>;
executing: Set<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- host maps typed on TaskExecutor
graphColumnAgentResolver: Map<string, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- host routing set
graphRouting: any;
graphSeamGoverningNodeId: Map<string, string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- host loop recovery map
loopRecoveryState: Map<string, any>;
pendingTaskDisposals: Map<string, Promise<void>>;
recoveringCompleted: Set<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- host spawned map
spawnedAgents: Map<string, any>;
stuckAborted: Map<string, boolean>;
userCanceledTaskIds: Set<string>;
workflowLifecycleMovesInFlight: Set<string>;
// Methods
awaitAbortInFlightTaskWork: (...args: any[]) => Promise<void>;
clearWorkflowRerunWatchdog: (taskId: string) => void;
deleteActiveWorkflowStepSession: (taskId: string) => void;
dispatchUnpauseResume: (task: Task) => Promise<boolean>;
disposeSubagentsForTask: (taskId: string, reason: string) => void;
execute: (task: Task) => Promise<void>;
executeReviewHandoff: (...args: any[]) => Promise<unknown>;
getAssignedAgentRuntimeConfig: (...args: any[]) => Promise<Record<string, unknown> | undefined>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getModelRegistry: () => Promise<any>;
getRunContextFor: (taskId: string) => RunMutationContext | undefined;
isBackwardMoveOutOfPlanning: (taskId: string, from: string, to: string, lanes: TaskMoveLanes | undefined) => boolean;
markPausedAborted: (taskId: string, provenance?: PausedAbortProvenance, source?: string) => void;
releasePreExecutionWorktree: (...args: any[]) => Promise<unknown>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
removeOwnWorktreeWithReconcile: (input: any) => Promise<void>;
resetMergeStateIfNeeded: (task: Task, from: string) => Promise<Task>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolveResumeLanes: (...args: any[]) => Promise<any>;
terminateAllChildren: (taskId: string) => Promise<void>;
trackTaskDisposal: (taskId: string, disposal: Promise<void>) => void;
};
export type WireExecutorLifecycleResult = {
unregisterTaskMoveDisposer: (() => void) | undefined;
unregisterArchiveWorktreeDisposer: (() => void) | undefined;
unregisterArchiveWorkspaceWorktreeDisposer: (() => void) | undefined;
};
/**
* Build lifecycle deps from a TaskExecutor-shaped host (store/rootDir/options + maps/methods).
* Keeps the constructor free of the field/method name lists.
* Host is `object` because TaskExecutor's store/rootDir/options are private constructor params
* and are not publicly assignable to a structural type with those property names.
*/
export function buildWireExecutorLifecycleDeps(host: object): WireExecutorLifecycleDeps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- private TaskExecutor surface
const h = host as any;
return {
store: h.store as TaskStore,
rootDir: h.rootDir as string,
options: h.options as TaskExecutorOptions,
...facadeFields(host, WIRE_LIFECYCLE_FIELDS),
...facadeMethods(host, WIRE_LIFECYCLE_METHODS),
} as WireExecutorLifecycleDeps;
}
export function wireExecutorLifecycle(deps: WireExecutorLifecycleDeps): WireExecutorLifecycleResult {
/*
FNXC:EngineDiagnostics 2026-07-26-09:39:
Executor bookkeeping that fires on every dispatch/session (construct, execute() entry, worktree ready, session create/register, prompt start, graph event stream, column-boundary warns-as-info, model/plugin setup, skip/duplicate/no-op guards) is debug-only (FUSION_DEBUG=executor). Keep log/warn/error for lifecycle outcomes operators act on: Starting task, ✓/✗ completion, failures, requeues, handoffs, stuck kills, verification failures, real moves.
*/
executorLog.debug(`TaskExecutor constructed (rootDir=${deps.rootDir}, hasSemaphore=${!!deps.options.semaphore}, hasStuckDetector=${!!deps.options.stuckTaskDetector})`);
const unregisterTaskMoveDisposer = registerTaskMoveDisposer(deps.store, async (task) => {
// Start both paths without awaiting between them. Each synchronously
// detaches its current targets before its first await, fencing late
// cleanup from a replacement execution after the move timeout expires.
const children = deps.terminateAllChildren(task.id);
const activeWork = deps.awaitAbortInFlightTaskWork(task.id, "user moved task from in-progress to todo", {
userCanceled: true,
});
await Promise.all([children, activeWork]);
});
/* FNXC:WorkflowLifecycle 2026-07-16-10:00: Executor replaces the baseline only for its own TaskStore, so archive awaits abort/sweep/removal before branch deletion without cross-store coupling. */
const unregisterArchiveWorktreeDisposer = registerArchiveWorktreeDisposer(deps.store, async (task) => {
/*
FNXC:ExternalExecutionCheckout 2026-08-09-22:43:
Operator-owned external checkouts must never be removed on archive.
*/
const externalExecutionRoute = await resolveExternalExecutionCheckoutRoute(task);
if (externalExecutionRoute.configured) return;
if (!task.worktree || await canonicalizeWorktreePath(task.worktree) === await canonicalizeWorktreePath(deps.rootDir)) return;
await deps.awaitAbortInFlightTaskWork(task.id, "task archived");
for (const path of activeSessionRegistry.pathsForTask(task.id)) activeSessionRegistry.unregisterPath(path);
await deps.removeOwnWorktreeWithReconcile({worktreePath: task.worktree, settings: await deps.store.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose});
task.worktree = undefined;
});
const unregisterArchiveWorkspaceWorktreeDisposer = registerArchiveWorkspaceWorktreeDisposer(deps.store, async (task, plan) => {
const removed: string[] = [];
const failed: {repoRel: string; error: unknown}[] = [];
await deps.awaitAbortInFlightTaskWork(task.id, "workspace task archived");
for (const entry of plan) {
try {
if (await canonicalizeWorktreePath(entry.worktreePath) === await canonicalizeWorktreePath(entry.repoRootDir)) throw new Error("Refusing to remove workspace repository root");
activeSessionRegistry.unregisterPath(entry.worktreePath);
await removeWorktree({worktreePath: entry.worktreePath, rootDir: entry.repoRootDir, settings: await deps.store.getSettings(), taskId: task.id, reason: RemovalReason.ExecutorDispose, force: true});
/* FNXC:WorkflowLifecycle 2026-07-16-16:00: Archive metadata can contain valid Git refs with shell metacharacters. Pass the ref as an argv value so cleanup never evaluates it as shell code. */
await execFileAsync("git", ["branch", "-D", entry.branch], {cwd: entry.repoRootDir, timeout: 120_000, maxBuffer: 10 * 1024 * 1024});
if (task.workspaceWorktrees) for (const repoRel of [entry.repoRel, ...entry.aliasRepoRels]) delete task.workspaceWorktrees[repoRel];
removed.push(entry.repoRel);
} catch (error) { failed.push({repoRel: entry.repoRel, error}); }
}
return {removed, failed};
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:20 (was FLAGGED AND LEFT COUNTED; RESOLVED below —
still do NOT convert with `resolveTaskWorkflowIrSync` / `resolvePlannerLanes`):
Four lifecycle literals live in this listener and they are genuinely wrong on a renamed board:
execution never starts on a move INTO the board's own wip lane, terminal session release never
runs on a move into its archive lane, and the two `from` guards never fire, so in-flight work is
not aborted when a card leaves implementation. Nothing errors; the engine simply stops reacting.
THE OBVIOUS FIX IS INERT, AND THAT IS NOW PROVED RATHER THAN ARGUED. `task:moved` is emitted
synchronously, so an await here reorders this handler against every other subscriber — which
points at the sync IR path. That path cannot answer for a renamed board, for TWO independent
reasons (`sync-workflow-ir-second-blocker.test.ts`):
1. `getTaskWorkflowSelectionImpl` returns `undefined` unconditionally under PostgreSQL, so
`resolveTaskWorkflowIrSync` always takes its `!workflowId` branch;
2. even with a selection, the CUSTOM-workflow branch loads its IR through `store.db`, whose
implementation is an unconditional throw — so it falls into the catch and returns the
DEFAULT IR anyway.
A renamed lane IS a custom workflow, so (2) alone is decisive: the sync path can never serve this
listener's case. `check-inert-sync-lane-conversions` already baselines twenty guards in exactly
that state in `scheduler.ts`; these four must not join them.
They stay literal and COUNTED, which is the honest state — an unconverted literal is visible to
the census, while an inert conversion leaves the backlog and takes the evidence with it.
THE CRITERION IS NARROWER THAN "THE LISTENER IS SYNC", and I got this wrong first time elsewhere:
what blocks a guard is whether ITS ANSWER IS CONSUMED SYNCHRONOUSLY, not whether it happens to sit
inside a synchronous function. In `self-healing.ts`'s fan-out, three of four guards only gated work
the listener already `void`s, so they were reachable by the async resolver all along and are now
converted. These four are NOT that case, for two independent reasons:
A. `trackTaskDisposal` writes `pendingTaskDisposals` in THIS tick, and the `to === wip` branch
above READS that map to serialise a fast bounce (in-progress -> todo -> in-progress; the
FN-5256 note it carries). Deferring the branch selection to a microtask lets the second
event's prologue read the map before the first event's write lands — which reopens exactly
the race that comment exists to close.
B. This is an if / else-if CHAIN, so the guards are entangled: converting one changes which
branch a move falls into. They convert together or not at all, and (A) blocks the set.
UNBLOCKING therefore needs the async resolver reachable from a SYNCHRONOUS consumer, which means
either a sync reader that answers for custom workflows AND survives a writer on another node, or
restructuring the disposal bookkeeping so nothing is read in-tick — the constraints are written up
in `sync-workflow-ir-second-blocker.test.ts`.
FNXC:WorkflowResolvedColumns 2026-07-31-23:55 — RESOLVED BY A THIRD ROUTE, and the analysis above
is kept because it is what rules the other two out.
The block reduces to "no resolver can be CALLED here". It never required that the answer be
unavailable — only that this listener cannot go and fetch it. So the lanes are resolved ONCE by
the emitter, which is already async, and ride along on the event payload (`moves.ts`). Every
objection above is about calling a resolver in-tick, so none of them survive the move:
- (2)/the PostgreSQL sync-IR dead end: no sync resolver is used, so neither blocker applies.
- (A) the in-tick `pendingTaskDisposals` race: NO await is introduced. Destructuring one more
field is as synchronous as reading `to`, so branch selection still happens in this tick and
the FN-5256 fast-bounce serialisation is untouched.
- (B) the entangled if / else-if chain: satisfied rather than dodged — all four convert in
this one commit, so no move can fall into a different branch than before.
THE RESIDUAL RISK MOVES TO THE EMITTER, AND IT IS NOT YET CLOSED — stated plainly because the
tempting version of this note is the false one. `lanes` is OPTIONAL on the payload
(`store.ts`: `lanes?: TaskMoveLanes`) and the fallback below is the LEGACY LITERAL, so a
`task:moved` published without it leaves these four guards exactly as inert as before, on a
renamed board, with nothing failing. The conversion is only as good as the emitters.
That is a strictly better position than the flagged state — the fallback is reached on one path
instead of every path, and `moves.ts` (the move path these branches actually serve) does pass
lanes — but it is NOT the compile-time guarantee it would be if the field were required.
Requiring it is the right end state and is deliberately NOT done here: it retypes every
`task:moved` emitter, which is its own change with its own blast radius, and bundling it would
put a mechanical retype in the same commit as this behavior change.
FOLLOW-UP, tracked with the emitter-side work: either make `lanes` required, or add a gate that
asserts every `task:moved` emit site supplies it. Until one of those lands, treat the fallback
as a live inertness path rather than defensive dead code.
*/
/* FNXC:WorkflowResolvedColumns 2026-08-22-00:13: This supersedes the prior residual-risk note: optional emitter payloads now consult TaskLaneCache before legacy ids; making lanes required and bridge forwarding remain separate follow-ups. */
deps.store.on("task:moved", ({ task, from, to, source, lanes }) => {
/*
FNXC:Diagnostics 2026-08-10-18:32:
Per-move tracing is DEBUG. This listener fires on every task:moved event — every dispatch,
rebound, requeue, archive and self-healing move across every task — so at `log` level it was the
single loudest line in engine output and buried the events an operator actually needs to see.
The information is still available at debug level; nothing here is an operator-actionable signal
on its own.
*/
executorLog.debug(`[event:task:moved] ${task.id}: ${from} → ${to}`);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-21:30 (fleet):
Lanes come from the EMITTER (see `moves.ts`), not from a resolver called here.
This listener is synchronous and its branches start execution, dispose worktrees and release
sessions, so its prologue is load-bearing — an await ahead of those branches would defer the
`execute()` dispatch itself. The sync IR resolver is not an option either: it answers with the
DEFAULT workflow under PostgreSQL, so a guard written through it is inert.
Fail-soft to the legacy ids when the emit path could not resolve, matching every other consumer
of this payload. `wipLane`/`archivedLane`/`holdLane` are read as SINGLE ids rather than sets
because each branch below is a lane-identity test on one column, which is what the literals were.
*/
/*
FNXC:WorkflowResolvedColumns 2026-08-22-00:13:
Optional payload lanes win, then the store's synchronous TTL cache preserves the last real
answer after an emitter resolution miss; literals are only the cold-cache compatibility tier.
Runtime/project bridges re-emit on their own EventEmitters, not TaskStore, so they cannot feed
this listener and intentionally remain outside this contract.
*/
const effectiveLanes = lanes ?? deps.store.laneCache?.get(task.id);
/* FNXC:WorkflowResolvedColumns 2026-08-22-00:28: fall back only when no lane answer exists; an answer with an absent role must not invent a legacy role-named column. */
const wipLane = effectiveLanes ? effectiveLanes.wip : "in-progress";
const archivedLane = effectiveLanes ? effectiveLanes.archived : "archived";
const holdLane = effectiveLanes ? effectiveLanes.hold : "todo";
if (to === wipLane) {
deps.userCanceledTaskIds.delete(task.id);
if (deps.recoveringCompleted.has(task.id)) {
executorLog.debug(`[event:task:moved] Skipping execute() for ${task.id} — completed-task recovery in progress`);
return;
}
deps.clearWorkflowRerunWatchdog(task.id);
executorLog.debug(`[event:task:moved] Initiating execute() for ${task.id}`);
void (async () => {
// FN-5256: if the prior session is still being torn down (because the
// task was just moved away from in-progress), wait for the worktree-
// bound shells to reap before we acquire/create a new worktree. Without
// this, a fast bounce (in-progress → todo → in-progress) races the
// executor's own conflict cleanup against a still-live shell.
const pending = deps.pendingTaskDisposals.get(task.id);
if (pending) {
executorLog.debug(`[event:task:moved] Awaiting pending disposal for ${task.id} before dispatch`);
await pending;
}
const taskForExecution = await deps.resetMergeStateIfNeeded(task, from);
await deps.execute(taskForExecution);
})().catch((err) =>
executorLog.error(`Failed to start ${task.id}:`, err),
);
} else if (to === archivedLane) {
/*
FNXC:WorkflowLifecycle 2026-07-09-00:05:
Archived is terminal, so it must release every active-session registry entry the
task holds. Plan Review / other workflow-step and step-session sessions run while
the task is in triage/planning/todo (not in-progress), so the old
`from === "in-progress"`-only disposal branch below never fired for them — the
registry entry (activeSessions / activeStepExecutors / activeWorkflowStepSessions,
keyed on the shared project browse root) leaked past archive and blocked a
successor task from acquiring the same session path with
ActiveSessionPathHeldByForeignTaskError (FN-7717 / NEXT-508 -> NEXT-433). We
deliberately do NOT do this for to === "done" / "in-review": those columns
legitimately hold ai-merge / workspace-repo-land merge leases that must survive
the transition (FN-6736 / Phase C/D merge-lease guarantees).
This branch is checked BEFORE `from === "in-progress"` (and handles it too — a
task can be archived directly from in-progress via fn_task_archive, a single
`task:moved` event with no intermediate todo hop). Ordering the plain
`from === "in-progress"`-only branch first would let that direct
in-progress → archived transition fall into the narrower branch and skip the
leaked-entry sweep below, re-opening the exact class of leak this fix closes for
that one origin column. `awaitAbortInFlightTaskWork` here is the same call the
in-progress branch makes (superset of its cleanup), so no case regresses.
*/
deps.trackTaskDisposal(
task.id,
deps.awaitAbortInFlightTaskWork(task.id, "task archived").then(() => {
// Belt-and-suspenders sweep: clear any registry entry that survived the
// abort above because its in-memory session map was already empty
// (a leaked entry with no live session to abort).
for (const path of activeSessionRegistry.pathsForTask(task.id)) {
activeSessionRegistry.unregisterPath(path);
}
}),
);
} else if (deps.isBackwardMoveOutOfPlanning(task.id, from, to, effectiveLanes)) {
/*
FNXC:PlanningEvacuation 2026-07-25-23:00:
A card pulled BACKWARD out of a planner lane (the reported case: todo → Ideas) must stop all
engine work on it, not just its planning session. Plan Review and other pre-execution graph
nodes run while the card sits in todo/triage, so without this branch the reviewer kept
streaming against a card the operator had withdrawn. Forward transitions are excluded — those
are the card advancing, and their own lanes own the handoff. Also release the pre-execution
worktree acquired at planning time so a withdrawn card leaves nothing behind on disk.
*/
deps.trackTaskDisposal(
task.id,
deps.awaitAbortInFlightTaskWork(task.id, `task moved out of planning to ${to}`, {
userCanceled: source === "user",
}).then(async () => { await deps.releasePreExecutionWorktree(task.id, `moved to ${to}`); }),
);
} else if (from === wipLane) {
if (deps.workflowLifecycleMovesInFlight.has(task.id) && deps.graphRouting.has(task.id)) {
executorLog.debug(
`[event:task:moved] Preserving graph run for ${task.id} across its own ${from} → ${to} boundary`,
);
return;
}
deps.trackTaskDisposal(
task.id,
deps.awaitAbortInFlightTaskWork(task.id, `parent moved from in-progress to ${to}`, {
userCanceled: source === "user" && to === holdLane,
}),
);
}
});
deps.store.on("task:deleted", (task) => {
deps.approvalSuspended.delete(task.id);
deps.approvalResumeAfterUnwind.delete(task.id);
deps.trackTaskDisposal(
task.id,
deps.awaitAbortInFlightTaskWork(task.id, "task soft-deleted", { userCanceled: true }),
);
});
// When a task is paused while executing, terminate the agent session.
// When steering comments are added during execution, inject them into the running session.
//
// Real-time steering comment injection mechanism:
// 1. When execution starts, we initialize seenSteeringIds with all existing comment IDs
// 2. On each task:updated event, we check if there are new comments not in seenSteeringIds
// 3. New comments are injected via session.steer() which queues them for delivery
// after the current assistant turn completes (before the next LLM call)
// 4. Comments are marked as seen BEFORE injection to prevent retry loops on failure
// 5. Each injection is logged to the task for user visibility
deps.store.on("task:updated", async (task) => {
try {
// FN-5256: handle pause by synchronously reaping every active session
// surface in one shot. Awaiting the abort ensures spawned shells are
// disposed before any re-dispatch can race the worktree.
if (
task.paused
&& (
deps.activeSessions.has(task.id)
|| deps.activeStepExecutors.has(task.id)
|| deps.activeWorkflowStepSessions.has(task.id)
|| deps.activeConfiguredCommandControllers.has(task.id)
)
) {
executorLog.log(`Pausing ${task.id} — awaiting in-flight session disposal`);
await deps.awaitAbortInFlightTaskWork(task.id, "task paused");
return;
}
// Handle unpause of an in-progress task with no active session.
// Approval can be decided while the old session is still unwinding;
// remember that edge instead of losing the only task:updated event.
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): both checks in this listener ask "is
this card still in the wip lane?"; one snapshot for the pair. With the literal neither fired on a
renamed board — an unpaused card with no active session was never resumed. */
const unpauseWipLane = (await deps.resolveResumeLanes(task.id)).wip;
if (!task.paused && task.column === unpauseWipLane && deps.approvalSuspended.has(task.id)) {
if (
deps.executing.has(task.id)
|| deps.activeSessions.has(task.id)
|| deps.activeStepExecutors.has(task.id)
|| deps.activeWorkflowStepSessions.has(task.id)
) {
deps.approvalResumeAfterUnwind.add(task.id);
executorLog.log(`${task.id}: approval decision received during session unwind — deferred one resume`);
return;
}
}
// Explicit unpause updates and non-failed orphan updates can resume here;
// startup failed-orphan recovery is owned by resumeOrphaned().
// dispatchUnpauseResume owns the terminal-failure and duplicate guards.
if (
!task.paused
&& task.column === unpauseWipLane
&& !deps.activeSessions.has(task.id)
&& !deps.activeStepExecutors.has(task.id)
&& !deps.activeWorkflowStepSessions.has(task.id)
) {
await deps.dispatchUnpauseResume(task);
return;
}
// Column-agent restart-invalidation (plan U5, R7/KTD-4). A workflow-
// definition edit (re-pointing a column's agent) or an agent runtimeConfig
// change mutates NOTHING the task-field diff below observes — the watcher
// would never see it. KTD-4's primary mechanism is event-driven invalidation,
// but no `workflow:updated`/`agent:updated` store event exists on TaskStore
// today (only task:/settings: events). Per the unit's documented fallback, we
// re-resolve the column-effective agent/model on each `task:updated` tick for
// GRAPH-MODE active entries ONLY (those whose session adopted a column agent —
// `lastEffectiveColumnAgentId != null`). This is bounded by the active session
// count, and only graph runs with a real column binding pay any cost. The
// weaker guarantee (vs an arbitrary-time diff) is that a stale session
// restarts on the next tick, not instantly — acceptable per the Risks note.
//
// agent-DELETED → fall back per R8 (no restart; the running session finishes
// on its current model). agent-CHANGED (different effective agent OR same
// agent with a new runtimeConfig model) → hot-swap, same path as a
// task.modelProvider change.
if (
deps.activeSessions.has(task.id)
&& !task.paused
&& (deps.activeSessions.get(task.id)!.lastEffectiveColumnAgentId ?? null) !== null
&& deps.graphSeamGoverningNodeId.has(task.id)
&& deps.graphColumnAgentResolver.has(task.id)
) {
const activeEntry = deps.activeSessions.get(task.id)!;
const governingNodeId = deps.graphSeamGoverningNodeId.get(task.id)!;
const resolveBinding = deps.graphColumnAgentResolver.get(task.id)!;
const binding = resolveBinding(governingNodeId);
const effective = binding
? resolveEffectiveAgent({ binding, ...extractOwnSettings(task) })
: undefined;
if (!effective || effective.source !== "column-agent") {
// Binding RELEASED (PR #1432 review): a workflow edit removed the
// binding, or `defer` now resolves to the task's own settings. Hand the
// session back to normal resolution: hot-swap to the assigned/task
// model (the same resolution the legacy block below owns), clear the
// column-agent tracking, and release the reverse heartbeat guard so
// isAgentEffectivelyExecuting() stops blocking the OLD agent.
executorLog.log(`${task.id}: column-agent binding released — reverting session to own-settings resolution`);
activeEntry.lastEffectiveColumnAgentId = null;
deps.effectiveColumnAgentByTask.delete(task.id);
// Fire-and-forget audit (matches the deletion-fallback posture above).
deps.store.logEntry(
task.id,
"Column-agent binding released — session reverts to its own model/agent resolution",
undefined,
deps.getRunContextFor(task.id),
).catch((err: unknown) => executorLog.warn(`${task.id}: failed to log column-agent release: ${err instanceof Error ? err.message : String(err)}`));
const settings = await deps.store.getSettings();
const assignedRuntimeConfig = await deps.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: ownProvider, modelId: ownModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
const providerChanged = ownProvider !== activeEntry.lastResolvedModelProvider;
const modelIdChanged = ownModelId !== activeEntry.lastResolvedModelId;
if ((providerChanged || modelIdChanged) && ownProvider && ownModelId) {
activeEntry.lastResolvedModelProvider = ownProvider;
activeEntry.lastResolvedModelId = ownModelId;
try {
const model = (await deps.getModelRegistry()).find(ownProvider, ownModelId);
if (model) {
await activeEntry.session.setModel(model);
executorLog.log(`${task.id}: binding released — model reverted to ${ownProvider}/${ownModelId}`);
}
} catch (err: unknown) {
executorLog.error(`${task.id}: failed to revert model after binding release: ${err instanceof Error ? err.message : String(err)}`);
}
}
} else {
{
// Fetch the (possibly changed) effective column agent, best-effort.
const newAgent = await deps.options.agentStore?.getAgent(effective.agentId).catch(() => null) ?? null;
if (!newAgent) {
// agent-DELETED (R8): fall back, NO restart. The running session
// keeps its current model; the NEXT resolution falls back. Update the
// tracked id so we stop probing for the missing agent every tick.
if (activeEntry.lastEffectiveColumnAgentId !== null) {
executorLog.log(`${task.id}: column agent '${effective.agentId}' deleted mid-session — falling back, no restart (R8)`);
// Fire-and-forget audit (matches the rework-log posture at ~3582):
// a logEntry failure must not abort this task:updated tick and skip
// the model-change detection below.
deps.store.logEntry(
task.id,
`Column agent '${effective.agentId}' deleted mid-session — falling back to current model, no restart (R8)`,
undefined,
deps.getRunContextFor(task.id),
).catch((err: unknown) => executorLog.warn(`${task.id}: failed to log column-agent deletion fallback: ${err instanceof Error ? err.message : String(err)}`));
activeEntry.lastEffectiveColumnAgentId = null;
// Release the reverse heartbeat guard for the deleted agent
// (PR #1432 review): isAgentEffectivelyExecuting() must not keep
// blocking an agent that no longer governs this session.
deps.effectiveColumnAgentByTask.delete(task.id);
}
} else {
const settings = await deps.store.getSettings();
/*
FNXC:ColumnAgentModel 2026-06-27-10:05:
Override column agents own the active session model even when a mid-flight task edit adds its own modelProvider/modelId; ignore task-level model fields during column-agent re-resolution so the watcher cannot clobber the governing agent's runtime model.
*/
const overrideColumnGoverns = binding!.mode === "override";
const { provider: newProvider, modelId: newModelId } = resolveExecutorSessionModel(
overrideColumnGoverns ? undefined : task.modelProvider,
overrideColumnGoverns ? undefined : task.modelId,
settings,
(newAgent.runtimeConfig ?? undefined) as Record<string, unknown> | undefined,
);
const agentChanged = (activeEntry.lastEffectiveColumnAgentId ?? null) !== newAgent.id;
const providerChanged = newProvider !== activeEntry.lastResolvedModelProvider;
const modelIdChanged = newModelId !== activeEntry.lastResolvedModelId;
if (agentChanged || providerChanged || modelIdChanged) {
activeEntry.lastEffectiveColumnAgentId = newAgent.id;
// Re-key the reverse heartbeat guard to the NEW agent (PR #1432
// review): the old agent stops being blocked, the new one starts.
deps.effectiveColumnAgentByTask.set(task.id, newAgent.id);
activeEntry.lastResolvedModelProvider = newProvider;
activeEntry.lastResolvedModelId = newModelId;
if (newProvider && newModelId) {
try {
const model = (await deps.getModelRegistry()).find(newProvider, newModelId);
if (model) {
await activeEntry.session.setModel(model);
executorLog.log(`${task.id}: column-agent hot-swap → agent '${newAgent.id}' model ${newProvider}/${newModelId}`);
await deps.store.logEntry(task.id, `Column agent changed — model now ${newProvider}/${newModelId} (agent ${newAgent.id})`, undefined, deps.getRunContextFor(task.id));
} else {
executorLog.log(`${task.id}: column-agent model ${newProvider}/${newModelId} not found in registry for hot-swap`);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.error(`${task.id}: failed to column-agent hot-swap: ${errorMessage}`);
// Fire-and-forget audit (see ~3582): a logEntry failure here must
// not abort the tick and skip later model-change detection.
deps.store.logEntry(task.id, `Column-agent change failed: ${errorMessage}`, undefined, deps.getRunContextFor(task.id))
.catch((logErr: unknown) => executorLog.warn(`${task.id}: failed to log column-agent change failure: ${logErr instanceof Error ? logErr.message : String(logErr)}`));
}
}
}
}
}
}
}
// Handle executor model hot-swap on active single-session executions
if (deps.activeSessions.has(task.id) && !task.paused) {
const activeEntry = deps.activeSessions.get(task.id)!;
// R3 guard: when an OVERRIDE column agent governs this running session, the
// column-agent watcher block above OWNS the model (override supersedes the
// task's own model/assigned-agent settings). The legacy task-model hot-swap
// would otherwise resolve a model from task.assignedAgentId's runtimeConfig
// and clobber the column agent's model on a mid-flight task edit. Skip it
// entirely when override governs; defer-resolved-to-own-settings (or no
// binding) keeps the legacy behavior identical.
let overrideColumnGoverns = false;
if ((activeEntry.lastEffectiveColumnAgentId ?? null) !== null) {
const governingNodeId = deps.graphSeamGoverningNodeId.get(task.id);
const resolveBinding = deps.graphColumnAgentResolver.get(task.id);
if (governingNodeId && resolveBinding) {
const binding = resolveBinding(governingNodeId);
if (binding?.mode === "override") overrideColumnGoverns = true;
}
}
const taskModelProviderChanged = task.modelProvider !== activeEntry.lastTaskModelProvider;
const taskModelIdChanged = task.modelId !== activeEntry.lastTaskModelId;
const assignedAgentChanged = (task.assignedAgentId ?? null) !== (activeEntry.lastAssignedAgentId ?? null);
if (!overrideColumnGoverns && (taskModelProviderChanged || taskModelIdChanged || assignedAgentChanged)) {
activeEntry.lastTaskModelProvider = task.modelProvider;
activeEntry.lastTaskModelId = task.modelId;
activeEntry.lastAssignedAgentId = task.assignedAgentId ?? null;
const settings = await deps.store.getSettings();
const assignedRuntimeConfig = await deps.getAssignedAgentRuntimeConfig(task.assignedAgentId);
const { provider: newProvider, modelId: newModelId } = resolveExecutorSessionModel(
task.modelProvider,
task.modelId,
settings,
assignedRuntimeConfig,
);
const providerChanged = newProvider !== activeEntry.lastResolvedModelProvider;
const modelIdChanged = newModelId !== activeEntry.lastResolvedModelId;
if (!providerChanged && !modelIdChanged) {
return;
}
activeEntry.lastResolvedModelProvider = newProvider;
activeEntry.lastResolvedModelId = newModelId;
if (newProvider && newModelId) {
try {
const model = (await deps.getModelRegistry()).find(newProvider, newModelId);
if (model) {
await activeEntry.session.setModel(model);
executorLog.log(`${task.id}: executor model hot-swapped to ${newProvider}/${newModelId}`);
await deps.store.logEntry(task.id, `Model changed to ${newProvider}/${newModelId}`, undefined, deps.getRunContextFor(task.id));
} else {
executorLog.log(`${task.id}: model ${newProvider}/${newModelId} not found in registry for hot-swap`);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
executorLog.error(`${task.id}: failed to hot-swap model: ${errorMessage}`);
await deps.store.logEntry(task.id, `Model change failed: ${errorMessage}`, undefined, deps.getRunContextFor(task.id));
}
}
}
}
// Handle steering comments - inject new ones into whichever execution
// surface currently owns the task: legacy single-session, step-session
// executor (including graph-pinned/workflow stepwise runs), or an
// individual workflow step AgentSession.
if (task.steeringComments) {
const injectionTargets: Array<{
kind: "legacy" | "step-session" | "workflow-step";
seenSteeringIds: Set<string>;
inject: (message: string, comment: import("@fusion/core").SteeringComment) => Promise<"injected" | "queued">;
legacySession?: AgentSession;
legacyState?: ActiveExecutorSessionState;
}> = [];
const activeSession = deps.activeSessions.get(task.id);
if (activeSession) {
injectionTargets.push({
kind: "legacy",
seenSteeringIds: activeSession.seenSteeringIds,
inject: async (message) => {
await activeSession.session.steer(message);
return "injected";
},
legacySession: activeSession.session,
legacyState: activeSession,
});
}
const stepExecutor = deps.activeStepExecutors.get(task.id);
if (stepExecutor) {
/*
FNXC:TaskDetailChat 2026-06-17-13:24:
Task-detail chat comments must reach the running LLM thread immediately across legacy, step-session, and workflow-step surfaces. Step-session runs can be between per-step AgentSessions when a comment arrives, so keep the executor's task snapshot current and treat zero-session fan-out as a next-prompt fallback while preserving seenSteeringIds exactly-once delivery.
*/
stepExecutor.updateSteeringComments?.(task.steeringComments);
const seenSteeringIds = deps.activeStepExecutorSeenSteeringIds.get(task.id) ?? createSeenSteeringIds(task);
deps.activeStepExecutorSeenSteeringIds.set(task.id, seenSteeringIds);
injectionTargets.push({
kind: "step-session",
seenSteeringIds,
inject: async (message, comment) => {
const steeredSessionCount = await stepExecutor.steerActiveSessions(message);
if (steeredSessionCount > 0) {
stepExecutor.markSteeringCommentsDelivered?.([comment.id]);
return "injected";
}
return "queued";
},
});
}
const workflowSession = deps.activeWorkflowStepSessions.get(task.id);
if (workflowSession) {
const seenSteeringIds = deps.activeWorkflowStepSessionSeenSteeringIds.get(task.id) ?? createSeenSteeringIds(task);
deps.activeWorkflowStepSessionSeenSteeringIds.set(task.id, seenSteeringIds);
injectionTargets.push({
kind: "workflow-step",
seenSteeringIds,
inject: async (message) => {
await workflowSession.steer(message);
return "injected";
},
});
}
const loggedCommentIds = new Set<string>();
let legacyReviewHandoff: {
comments: import("@fusion/core").SteeringComment[];
session: AgentSession;
state: ActiveExecutorSessionState;
} | undefined;
for (const target of injectionTargets) {
// Find new steering comments that haven't been seen by this running surface yet.
const newComments = task.steeringComments.filter(c => !target.seenSteeringIds.has(c.id));
if (newComments.length === 0) continue;
for (const comment of newComments) {
const summary = comment.text.length > 80
? comment.text.slice(0, 80) + "..."
: comment.text;
// Mark as seen BEFORE attempting injection to prevent retry loops on failure.
target.seenSteeringIds.add(comment.id);
const commentMessage = formatCommentForInjection(comment);
try {
executorLog.log(`Injecting comment into ${task.id} (${target.kind}): ${summary}`);
const delivery = await target.inject(commentMessage, comment);
if (delivery === "queued") {
executorLog.log(`Queued comment for next ${target.kind} prompt in ${task.id}`);
} else {
executorLog.log(`Successfully injected comment into ${task.id} (${target.kind})`);
}
// Log to the task once per comment/tick even if multiple active surfaces exist.
if (!loggedCommentIds.has(comment.id)) {
await deps.store.logEntry(
task.id,
`Comment received mid-execution: ${summary}`,
`by ${comment.author}`
);
loggedCommentIds.add(comment.id);
}
} catch (err) {
executorLog.error(`Failed to inject comment for ${task.id} (${target.kind}):`, err);
// Comment is already marked as seen - we won't retry to avoid spamming
// the agent with failed injections. The error is logged for debugging.
}
}
if (target.kind === "legacy" && target.legacySession && target.legacyState) {
legacyReviewHandoff = {
comments: newComments,
session: target.legacySession,
state: target.legacyState,
};
}
}
// After injecting comments, check for review handoff intent on the legacy
// session path. Step-session/workflow-step runs do not have the legacy
// review handoff state required by executeReviewHandoff.
if (legacyReviewHandoff) {
// Only detect handoff in agent-authored comments when policy is enabled.
// Merge per-task effective workflow settings (U3, KTD-3) so
// reviewHandoffPolicy resolves from the workflow. Behavior-inert by default.
const settings = await mergeEffectiveSettings(deps.store, task, await deps.store.getSettings());
if (settings.reviewHandoffPolicy === "comment-triggered") {
const agentComments = legacyReviewHandoff.comments.filter(c => c.author !== "user");
for (const comment of agentComments) {
if (detectReviewHandoffIntent(comment.text)) {
executorLog.log(`Review handoff detected in ${task.id}: ${comment.text.slice(0, 50)}...`);
await deps.executeReviewHandoff(task, legacyReviewHandoff.session, legacyReviewHandoff.state);
return; // Exit early - handoff handles session disposal
}
}
}
}
}
} catch (err) {
executorLog.error("Uncaught error in task:updated listener:", err);
}
});
// When globalPause transitions from false → true, terminate all active agent sessions.
deps.store.on("settings:updated", ({ settings, previous }) => {
if (settings.globalPause && !previous.globalPause) {
for (const [taskId, controllers] of deps.activeConfiguredCommandControllers) {
executorLog.log(`Global pause — aborting configured command(s) for ${taskId}`);
deps.markPausedAborted(taskId, "global-pause", "global-pause:configured-command");
deps.options.stuckTaskDetector?.untrackTask(taskId);
for (const controller of controllers) {
controller.abort();
}
deps.activeConfiguredCommandControllers.delete(taskId);
deps.loopRecoveryState.delete(taskId);
deps.spawnedAgents.delete(taskId);
deps.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.
for (const taskId of [...deps.activeSubagentSessions.keys()]) {
deps.disposeSubagentsForTask(taskId, "global pause");
}
for (const [taskId, { session }] of deps.activeSessions) {
executorLog.log(`Global pause — terminating agent session for ${taskId}`);
deps.markPausedAborted(taskId, "global-pause", "global-pause:agent-session");
deps.options.stuckTaskDetector?.untrackTask(taskId);
// abort() interrupts any in-flight LLM stream / tool call;
// dispose() then releases session resources.
const sessionWithAbort = session as unknown as { abort?: () => Promise<void> };
if (typeof sessionWithAbort.abort === "function") {
void sessionWithAbort.abort().catch((err) => {
executorLog.warn(`Failed to abort agent session for ${taskId}: ${err}`);
});
}
session.dispose();
// Clean up all in-memory state so nothing leaks when tasks are later unpaused
deps.loopRecoveryState.delete(taskId);
deps.spawnedAgents.delete(taskId);
deps.stuckAborted.delete(taskId);
}
for (const [taskId, stepExecutor] of deps.activeStepExecutors) {
executorLog.log(`Global pause — terminating step sessions for ${taskId}`);
deps.markPausedAborted(taskId, "global-pause", "global-pause:step-session");
deps.options.stuckTaskDetector?.untrackTask(taskId);
stepExecutor.terminateAllSessions().catch(err =>
executorLog.warn(`Failed to terminate step sessions for global pause ${taskId}: ${err}`)
);
// Clean up all in-memory state so nothing leaks when tasks are later unpaused
deps.loopRecoveryState.delete(taskId);
deps.spawnedAgents.delete(taskId);
deps.stuckAborted.delete(taskId);
}
for (const [taskId, workflowSession] of deps.activeWorkflowStepSessions) {
executorLog.log(`Global pause — terminating workflow step session for ${taskId}`);
deps.markPausedAborted(taskId, "global-pause", "global-pause:workflow-step-session");
deps.options.stuckTaskDetector?.untrackTask(taskId);
const sessionWithAbort = workflowSession as AgentSession & { abort?: () => Promise<void> };
if (typeof sessionWithAbort.abort === "function") {
void sessionWithAbort.abort().catch((err) => {
executorLog.warn(`Failed to abort workflow step session for ${taskId}: ${err}`);
});
}
workflowSession.dispose();
deps.deleteActiveWorkflowStepSession(taskId);
deps.loopRecoveryState.delete(taskId);
deps.spawnedAgents.delete(taskId);
deps.stuckAborted.delete(taskId);
}
for (const [taskId, controller] of deps.activeWorkflowGraphAbortControllers) {
executorLog.log(`Global pause — aborting workflow graph runner for ${taskId}`);
deps.markPausedAborted(taskId, "global-pause", "global-pause:workflow-graph");
deps.options.stuckTaskDetector?.untrackTask(taskId);
controller.abort();
deps.activeWorkflowGraphAbortControllers.delete(taskId);
deps.loopRecoveryState.delete(taskId);
deps.spawnedAgents.delete(taskId);
deps.stuckAborted.delete(taskId);
}
}
});
return {
unregisterTaskMoveDisposer,
unregisterArchiveWorktreeDisposer,
unregisterArchiveWorkspaceWorktreeDisposer,
};
}
/*
FNXC:CodeOrganization 2026-08-04-07:25:
Apply wireExecutorLifecycle disposer handles onto a TaskExecutor-shaped host so the
class constructor stays a two-line super()+apply wire-up (U4 densify). Host is object
because disposer fields are protected on TaskExecutorState.
*/
export function applyWireExecutorLifecycleDisposers(
host: object,
wired: WireExecutorLifecycleResult,
): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- protected TaskExecutorState disposer fields
const h = host as any;
h.unregisterTaskMoveDisposer = wired.unregisterTaskMoveDisposer;
h.unregisterArchiveWorktreeDisposer = wired.unregisterArchiveWorktreeDisposer;
h.unregisterArchiveWorkspaceWorktreeDisposer = wired.unregisterArchiveWorkspaceWorktreeDisposer;
}
/*
FNXC:CodeOrganization 2026-08-04-07:30:
One-shot constructor wire: build deps, register lifecycle listeners, apply disposer
handles. TaskExecutor constructor is then super()+wireTaskExecutorLifecycle(this).
*/
export function wireTaskExecutorLifecycle(host: object): void {
// FNXC:WorkflowAgentRouting 2026-08-07-03:38: init capacity before listeners so graph admission tests see the field post-construct.
const h = host as { options?: TaskExecutorOptions; workflowAgentCapacity?: WorkflowAgentCapacity };
h.workflowAgentCapacity = new WorkflowAgentCapacity(h.options?.agentStore ?? undefined);
applyWireExecutorLifecycleDisposers(host, wireExecutorLifecycle(buildWireExecutorLifecycleDeps(host)));
}