Files
fusion/packages/engine/src/executor/workflow-principal-before-node.ts
gsxdsm 1cf86baa1c refactor: package code organization wave 18 (executor pure peels) (#3317)
## Summary

Wave 18 continues the package code-organization program after wave 17
domain folders (U4 Slice A from
`docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md`).

### What changed
Peel **pure, behavior-preserving** helpers out of
`packages/engine/src/executor.ts` into domain modules under
`packages/engine/src/executor/`, with **stable re-exports** from
`executor.ts` so deep imports and `vi.mock("../executor.js")` keep
working.

| New module | Symbols |
|------------|---------|
| `executor/task-done-refusal.ts` | `evaluateTaskDoneRefusal`,
`determineRevisionResetStart`, skip-bypass refusal helper |
| `executor/workflow-feedback-paths.ts` |
`extractReferencedPathsFromWorkflowFeedback`,
`isAlwaysAllowedScopeLeakPath`, `workflowPathMatchesDeclaredScope` |
| `executor/workflow-step-verdict.ts` |
`FUSION_WORKFLOW_STEP_CONVENTIONS_PREAMBLE`, `parseWorkflowStepVerdict`
/ `parseWorkflowStepOutput`, step outcome types |
| `executor/await-input-parse.ts` | `parseAwaitInputSentinel`,
`parseAwaitInputQuestionToolCall` |
| `executor/no-commit-eligibility.ts` | `getNoCommitEligibilityReason`
(+ prompt heuristics) |

`executor.ts` live LOC ~**22817 → ~22427** (first pure-peel batch; more
peels needed to approach the 2k cap).

### Shims
- `old path` `executor.ts` public exports → `new path` `executor/*.ts` →
delete-when consumer deep-imports are re-pointed (not this PR)

### Test plan
- [x] `@fusion/engine` typecheck
- [x] Oracle: task-done refusal, skip-bypass, workflow malformed
verdict, scope-leak allowlist, executor-step-session, executor-prompt
- [x] `vitest --project=engine-core` (merge-gate curated suite)
- [ ] CI merge gate

**Stack:** wave17 (merged) → **this PR**

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved recognition of workflow outcomes from structured and
conversational responses.
* Added support for extracting questions from await-input responses and
tool calls.
* Improved workflow feedback handling for referenced files and declared
scope patterns.
* Added clearer guidance for task execution, approvals, verification,
and available tools.

* **Bug Fixes**
* Prevented completion when required review approvals are missing or
revisions remain pending.
* Improved handling of workflows that legitimately require no code
changes.
  * Added clearer refusal messages and more reliable revision restarts.
  * Sanitized repository paths in Git remediation instructions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-09 15:46:09 -10:00

404 lines
18 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-08-08-12:00:
* Graph beforeNodeExecution principal admission peeled for U4 (FN-8764 / FN-8821 / main tip).
*
* FNXC:WorkflowAgentRouting 2026-08-07-03:38 / 2026-08-07-23:50 / 2026-08-08-03:20:
* Graph execution resolves permanent workflow principals before handlers can create a model session.
* Fence writes use replaceActiveTaskWorkflowContinuation so resumed runs do not deadlock on the
* one-active-continuation index. Missing agent-store/IR is logged as a composition fault.
*/
import type {
Agent,
AgentStore,
Settings,
TaskDetail,
TaskStore,
WorkflowColumnAgent,
WorkflowIr,
WorkflowIrNode,
WorkflowWorkItem,
} from "@fusion/core";
import { classifyWorkflowAgentNode, isWorkflowAgentRole } from "@fusion/core";
import {
routeWorkflowPrincipal,
validateFencedWorkflowPrincipal,
} from "../agents/workflow-agent-router.js";
import type { WorkflowAgentCapacity } from "../agents/workflow-agent-capacity.js";
import type { WorkflowNodeResult } from "../workflows/workflow-graph-executor.js";
import { executorLog } from "../logger.js";
export type ActiveWorkflowAuthority = {
agentId: string;
taskId: string;
runId: string;
workItemId: string;
nodeInstanceId: string;
requiresDurableFence: boolean;
kind: "task-assignee" | "review-node-override";
};
export type WorkflowPrincipalBeforeNodeDeps = {
store: TaskStore;
options: { agentStore?: AgentStore | null; [k: string]: unknown };
workflowAgentCapacity: WorkflowAgentCapacity;
activeWorkflowAuthorities: Map<string, ActiveWorkflowAuthority>;
activeWorkflowPrincipals: Map<string, { agentId: string; nodeInstanceId: string; agent?: Agent }>;
workflowCapacityAttemptIds: Set<string>;
directWorkflowPrincipalWorkItemIds: Set<string>;
/** Holds written for principal unavailability so terminalization can skip re-closing them. */
directWorkflowPrincipalHeldWorkItemIds: Set<string>;
columnAgentIr: WorkflowIr | undefined;
resolveBindingForNode: (nodeId: string) => WorkflowColumnAgent | undefined;
resolvedRunId: string | undefined;
settings: Settings;
};
export async function admitWorkflowPrincipalBeforeNode(
deps: WorkflowPrincipalBeforeNodeDeps,
node: WorkflowIrNode,
nodeTask: TaskDetail,
context: Record<string, unknown>,
): Promise<WorkflowNodeResult | undefined> {
const classifiedRole = classifyWorkflowAgentNode(node);
if (!classifiedRole) return undefined;
/*
* A classified session without the authoritative IR/agent store must fail closed;
* running it as an ambient executor defeats role routing.
*
* FNXC:WorkflowAgentRouting 2026-08-07-23:05:
* Name WHICH dependency is missing and log it. Unlike every other refusal below,
* this one persists no held work item (the durable-hold helper needs the very IR
* that is missing), so it is the one routing outcome with no durable trace at all:
* the run suspends with a bare `capacity` marker and the card re-suspends at the
* same node every poll, indistinguishable from a dead engine. A missing agent-store
* wire deadlocked the whole board this way. Neither condition is transient — both
* are boot-time composition faults — so log at error, not warn.
*/
if (!deps.options.agentStore || !deps.columnAgentIr) {
const missing = !deps.options.agentStore ? "no-agent-store" : "no-workflow-ir";
executorLog.error(
`[workflow-graph] ${nodeTask.id}: cannot route node '${node.id}' to a '${classifiedRole}' principal — ${missing}. `
+ "This is a runtime composition fault, not a transient wait: the node will re-suspend every dispatch until it is repaired.",
);
return { outcome: "failure" as const, value: `workflow-principal-routing-unavailable:${missing}:${classifiedRole}` };
}
const agents = await deps.options.agentStore.listAgents({ includeEphemeral: true });
const activeSessions = new Map(agents.map((agent) => [agent.id, deps.workflowAgentCapacity.activeSessions(agent.id, deps.store.getRootDir())]));
const fencedPrincipalId = typeof context["workflow:principal-agent-id"] === "string"
? context["workflow:principal-agent-id"]
: undefined;
const fencedRole = context["workflow:principal-role"];
const fencedAuthority = context["workflow:principal-authority"];
const nodeInstanceId = typeof context["workflow:node-instance-id"] === "string"
? context["workflow:node-instance-id"]
: node.id;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-04:31:
* A work-item resume must consume its persisted principal fence. Do
* not call ordinary precedence routing for a row that already names
* an agent: that would turn the durable record into display-only
* metadata and could silently replace a reviewer or task owner.
*/
const hasFencedPrincipal = fencedPrincipalId
&& isWorkflowAgentRole(fencedRole)
&& (fencedAuthority === "task-assignee" || fencedAuthority === "review-node-override" || fencedAuthority === "column-binding" || fencedAuthority === "role-pool");
let routed = hasFencedPrincipal
&& (fencedAuthority === "task-assignee" || fencedAuthority === "review-node-override" || fencedAuthority === "column-binding" || fencedAuthority === "role-pool")
? validateFencedWorkflowPrincipal({
task: nodeTask,
ir: deps.columnAgentIr,
node,
principalAgentId: fencedPrincipalId,
role: fencedRole,
authority: fencedAuthority,
agents,
nodeInstanceId,
activeSessions,
})
: routeWorkflowPrincipal({
task: nodeTask,
ir: deps.columnAgentIr,
node,
agents,
activeSessions,
});
if (routed.status === "unclassified") return undefined;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-23:50:
* EVERY durable continuation write on this path goes through the atomic
* replace primitive, never a bare upsert.
*
* `idx_workflow_work_items_one_active_task_continuation` permits ONE active
* (`runnable`/`running`/`held`/`retrying`) `kind:"task"` row per task, and a
* plain upsert's ON CONFLICT target is a DIFFERENT constraint
* (run_id, task_id, node_id, kind). So a row this run has already left — the
* continuation it resumed on, or a previous foreach instance of the same
* template node, which shares `nodeId` and differs only by `runId` — does not
* upsert, it RAISES. That raise deadlocked the board: routing failed closed,
* the run re-suspended every dispatch, and only an operator bouncing the card
* cleared it.
*
* `replaceActiveTaskWorkflowContinuation` retires every active row that is not
* this exact (runId, nodeId, kind) and upserts the successor inside ONE
* transaction holding the task's advisory lock. That is what makes the handover
* atomic (no window with zero active rows), instance-aware (a sibling foreach
* instance has a different runId, so it is retired), and race-free against a
* concurrent engine (the lock serializes the read and the write). It is the
* repository's existing primitive for exactly this — `plan-review-continuation.ts`
* and `workflow-column-boundary-hooks.ts` already use it.
*
* Deliberately NOT an error-recovery path: an earlier revision reacted to a
* failed upsert by terminalizing other rows, which meant any transient database
* error destroyed a legitimate `held` continuation. Replacing unconditionally on
* the success path removes the need to classify errors at all.
*/
const writeContinuation = async (
input: Parameters<NonNullable<TaskStore["upsertWorkflowWorkItem"]>>[0] & { kind: "task" },
): Promise<WorkflowWorkItem | undefined> => {
if (typeof deps.store.replaceActiveTaskWorkflowContinuation === "function") {
return await deps.store.replaceActiveTaskWorkflowContinuation(input);
}
// Degradation for minimal/legacy stores without the atomic primitive:
// a bare upsert keeps the pre-primitive behavior rather than failing the run.
if (typeof deps.store.upsertWorkflowWorkItem === "function") {
return await deps.store.upsertWorkflowWorkItem(input);
}
return undefined;
};
/*
* FNXC:WorkflowAgentRouting 2026-08-07-23:50:
* A hold write must NEVER throw out of `beforeNodeExecution`. Only
* `WorkflowGraphSuspended` is rethrown by the interpreter, so any other throw
* here degrades a recoverable availability hold into a terminal graph failure —
* the card is parked failed instead of waiting for its principal. Failing to
* RECORD the hold is bad; failing the task because we could not record it is
* worse. Log and continue: the refusal value still fails the node closed.
*/
const holdDirectPrincipalWorkItem = async (
reason: string,
principalAgentId: string | null,
authorityKind: "task-assignee" | "review-node-override" | "column-binding" | "role-pool" | null,
): Promise<void> => {
try {
const item = await writeContinuation({
runId: `${deps.resolvedRunId ?? `${nodeTask.id}:workflow`}:${nodeInstanceId}`,
taskId: nodeTask.id,
nodeId: node.id,
nodeInstanceId,
kind: "task",
state: "held",
leaseOwner: null,
leaseExpiresAt: null,
blockedReason: reason,
lastError: reason,
principalAgentId,
workflowRole: classifiedRole,
authorityKind,
});
if (item) {
deps.directWorkflowPrincipalWorkItemIds.add(item.id);
// The run now owns the task's single active continuation at THIS node, so
// the caller must not also transition the row it resumed on (that row is
// already retired, and transitioning a terminal row throws).
deps.directWorkflowPrincipalHeldWorkItemIds.add(item.id);
}
} catch (holdErr) {
executorLog.error(
`[workflow-graph] ${nodeTask.id}: could not persist the availability hold for node '${node.id}' (${reason}): `
+ `${holdErr instanceof Error ? holdErr.message : String(holdErr)}`,
);
}
};
if (routed.status === "held") {
const reviewerOverride = classifiedRole === "reviewer" ? node.reviewerAgentId : undefined;
const columnBinding = deps.resolveBindingForNode(node.id);
const namedPrincipal = reviewerOverride ?? nodeTask.assignedAgentId ?? columnBinding?.agentId;
const authorityKind = reviewerOverride
? "review-node-override"
: nodeTask.assignedAgentId
? "task-assignee"
: columnBinding?.agentId
? "column-binding"
: null;
const reason = `workflow-principal-${routed.reason}:${routed.role}`;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-06:53:
* Direct graph dispatch must preserve an unavailable named principal
* or exhausted role pool as durable held work before suspending. A
* failure result would otherwise terminalize the task and erase the
* exact availability condition operators need to repair or await.
*/
await holdDirectPrincipalWorkItem(reason, namedPrincipal ?? null, authorityKind);
return { outcome: "failure" as const, value: reason };
}
/*
* FNXC:WorkflowAgentRouting 2026-08-08-03:20:
* Use the SAME run-id fallback the two durable writes below use. `deps.resolvedRunId` is
* optional by construction (a definition load failure leaves it undefined), and this
* interpolated it raw — producing the literal attempt id `undefined:<nodeInstance>`,
* shared by every task in the project that hit that failure. The capacity lease is
* keyed on `(projectId, attemptId)` and returns `acquired` for a pre-existing row
* REGARDLESS of agent, so colliding tasks bypass both the project and per-agent caps,
* and one task's release deletes another's live lease.
*/
const attemptId = `${deps.resolvedRunId ?? `${nodeTask.id}:workflow`}:${nodeInstanceId}`;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-05:29:
* Workflow-stage admission consumes the project workflow budget, while
* an agent's heartbeat retains its separate maxConcurrentRuns budget.
* Passing the project limit here closes the direct-graph path, which
* otherwise enforced only optional per-agent limits.
*/
let capacity = await deps.workflowAgentCapacity.acquire({
projectId: deps.options.agentStore.workflowProjectId ?? deps.store.getRootDir(),
agent: routed.route.agent,
attemptId,
maxProjectSessions: deps.settings.maxConcurrent,
});
/*
* FNXC:WorkflowAgentRouting 2026-08-07-07:32:
* A role-pool snapshot is process-local, while admission is durable
* across engines. If another engine filled the selected agent between
* selection and the atomic acquire, try the next eligible pool member.
* Fenced and named principals never take this fallback.
*/
if (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool" && !hasFencedPrincipal) {
const excludedPoolAgentIds = new Set<string>();
while (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool") {
excludedPoolAgentIds.add(routed.route.agent.id);
const retryRoute = routeWorkflowPrincipal({
task: nodeTask,
ir: deps.columnAgentIr,
node,
agents,
activeSessions,
excludedPoolAgentIds,
});
if (retryRoute.status !== "routed" || retryRoute.route.authority !== "role-pool") break;
routed = retryRoute;
capacity = await deps.workflowAgentCapacity.acquire({
projectId: deps.options.agentStore.workflowProjectId ?? deps.store.getRootDir(),
agent: routed.route.agent,
attemptId,
maxProjectSessions: deps.settings.maxConcurrent,
});
}
}
if (capacity.status === "held") {
const reason = `workflow-principal-${capacity.reason}:${routed.route.role}`;
await holdDirectPrincipalWorkItem(reason, routed.route.agent.id, routed.route.authority);
return { outcome: "failure" as const, value: reason };
}
let durableWorkItemId = typeof context["workflow:work-item-id"] === "string"
? context["workflow:work-item-id"]
: undefined;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-05:06:
* Graph dispatch normally reaches handlers without a scheduler work
* item. Persist the exact selected identity before constructing that
* handler session, so policy gates and recovery have the same durable
* fence as a claimed continuation. A persistence failure releases the
* just-acquired capacity and fails closed rather than running ambient.
*/
if (!durableWorkItemId) {
/*
* FNXC:WorkflowAgentRouting 2026-08-07-23:50:
* The fence is written through the atomic replace primitive (see
* `writeContinuation` above), so the row this run already left — the resumed
* continuation, or a sibling foreach instance sharing this template `nodeId` —
* is retired in the SAME locked transaction that installs this fence. There is
* therefore no conflict to react to and no window in which the task has zero
* active continuations.
*
* A failure here still fails CLOSED: no session may start without its durable
* principal record. Release the just-acquired capacity and surface the store
* error, whose actionable text (constraint name, NOT NULL column) the store
* layer puts on `cause` rather than `message`.
*/
try {
const item = await writeContinuation({
runId: `${deps.resolvedRunId ?? `${nodeTask.id}:workflow`}:${nodeInstanceId}`,
taskId: nodeTask.id,
nodeId: node.id,
kind: "task",
state: "running",
leaseOwner: `executor:${nodeTask.id}`,
leaseExpiresAt: null,
principalAgentId: routed.route.agent.id,
workflowRole: routed.route.role,
authorityKind: routed.route.authority,
nodeInstanceId,
});
if (item) {
durableWorkItemId = item.id;
deps.directWorkflowPrincipalWorkItemIds.add(item.id);
}
} catch (fenceErr) {
const detail = fenceErr instanceof Error ? fenceErr.message : String(fenceErr);
const cause = fenceErr instanceof Error && fenceErr.cause instanceof Error
? ` [cause: ${fenceErr.cause.message}]`
: "";
executorLog.error(
`[workflow-graph] ${nodeTask.id}: durable principal fence write failed for node '${node.id}' `
+ `(role=${routed.route.role}, authority=${routed.route.authority}, agent=${routed.route.agent.id}): ${detail}${cause}`,
);
await deps.store.logEntry(
nodeTask.id,
`Workflow principal fence write failed at node '${node.id}' — ${detail.slice(0, 300)}${cause}`,
).catch(() => undefined);
void deps.workflowAgentCapacity.release(attemptId, deps.options.agentStore.workflowProjectId ?? deps.store.getRootDir());
return { outcome: "failure" as const, value: `workflow-principal-fence-unavailable:${routed.route.role}` };
}
}
deps.workflowCapacityAttemptIds.add(attemptId);
deps.activeWorkflowPrincipals.set(nodeTask.id, {
agentId: routed.route.agent.id,
nodeInstanceId,
});
if (durableWorkItemId) context["workflow:work-item-id"] = durableWorkItemId;
context["workflow:principal-agent-id"] = routed.route.agent.id;
context["workflow:principal-role"] = routed.route.role;
context["workflow:principal-authority"] = routed.route.authority;
if (routed.route.authority === "task-assignee" || routed.route.authority === "review-node-override") {
deps.activeWorkflowAuthorities.set(nodeTask.id, {
agentId: routed.route.agent.id,
taskId: nodeTask.id,
runId: deps.resolvedRunId ?? `${nodeTask.id}:${node.id}`,
workItemId: durableWorkItemId ?? attemptId,
nodeInstanceId,
requiresDurableFence: durableWorkItemId !== undefined,
kind: routed.route.authority,
});
} else {
deps.activeWorkflowAuthorities.delete(nodeTask.id);
}
context["workflow:release-principal"] = () => {
void deps.workflowAgentCapacity.release(attemptId, deps.options.agentStore?.workflowProjectId ?? deps.store.getRootDir());
deps.workflowCapacityAttemptIds.delete(attemptId);
const principal = deps.activeWorkflowPrincipals.get(nodeTask.id);
if (principal?.nodeInstanceId === nodeInstanceId) {
deps.activeWorkflowPrincipals.delete(nodeTask.id);
deps.activeWorkflowAuthorities.delete(nodeTask.id);
}
/*
* FNXC:WorkflowAgentRouting 2026-08-07-05:37:
* A principal fence ends with its handler attempt. Leaving these
* fields in shared graph context made the next classified node reuse
* the prior role/node fence and fail closed (or worse, inherit it).
* Template wrappers restore only their parent node identity after
* this release; no principal context crosses a node boundary.
*/
if (context["workflow:principal-agent-id"] === routed.route.agent.id) {
delete context["workflow:principal-agent-id"];
delete context["workflow:principal-role"];
delete context["workflow:principal-authority"];
delete context["workflow:work-item-id"];
}
};
return undefined;
}