diff --git a/.changeset/fn-8764-workflow-role-agents.md b/.changeset/fn-8764-workflow-role-agents.md new file mode 100644 index 0000000000..e8be739832 --- /dev/null +++ b/.changeset/fn-8764-workflow-role-agents.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Route workflow stages through durable multi-role agents instead of ephemeral workers. +category: feature +dev: Removes ephemeral workflow-worker lifecycle dispatch; existing singular role input remains migration-compatible. diff --git a/CONCEPTS.md b/CONCEPTS.md index 7f30b4214c..154705a2a2 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -362,3 +362,6 @@ A second quarantine in the same subsystem is a product-race smell: the flake may ## Flagged ambiguities - "Merging" a shared-branch-group Task had been used for both member integration and group promotion — these are distinct steps with independent gating and must not be conflated. + +- **Workflow principal:** the durable agent fenced on one agent-executed workflow node. It is distinct from task ownership and exists only while a live work item/session lease is active. +- **Role tag:** a normalized permanent-agent capability label used for workflow pool routing; agents may have multiple tags. diff --git a/docs/agents.md b/docs/agents.md index 0985a3e09e..effa723b4c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1716,3 +1716,9 @@ Per-agent overrides via `runtimeConfig`: - **Heartbeat**: `heartbeatIntervalMs`, `heartbeatTimeoutMs`, `maxConcurrentRuns`. Triggered by timer, task assignment, or on-demand (`POST /api/agents/:id/runs`). - **Budgets**: per-agent token budget tracking; `HeartbeatMonitor.executeHeartbeat()` skips when `isOverBudget` or `isOverThreshold` (timer triggers). Hard caps pause the agent. - **Performance ratings**: 1–5 scale with trend analysis, injected into system prompts. + +## Workflow role principals + +Permanent agents carry one or more normalized role tags: `triage`, `executor`, `reviewer`, `merger`, `scheduler`, `engineer`, and `custom`. Upgrades preserve legacy singular roles, and every project receives four distinct heartbeat-disabled built-ins for planning, execution, review, and merge. Heartbeat enablement and `maxConcurrentRuns` are independent from `runtimeConfig.maxWorkflowSessions`. + +Workflow routing never changes `assignedAgentId`. An explicit task owner runs classified stages regardless of its tags, except for an exact reviewer-node override. Otherwise a column binding is considered, then an available role-tag pool is selected by fewest active workflow sessions, oldest creation time, and ID. A named unavailable principal holds work rather than falling back. diff --git a/docs/architecture.md b/docs/architecture.md index f0a89f07a8..4bffd86e16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2359,3 +2359,9 @@ FN-8685 adds per-project, per-consumer registration, cursor/lease, receipt, and Consumers replay within the 30-day retention window. A stale cursor or retained gap uses snapshot-bounded reconciliation: capture the outbox head before reading live tasks, emit observed deletes for missing cached tasks, and CAS-advance only to that captured head so later rows remain available for normal polling. Poison parking atomically inserts the unique `(project_id, consumer_id, event_id)` dead letter, advances the fenced cursor, resets retries, and writes its audit record. `SelfHealingManager` invokes bounded `pruneTaskLifecycleEvents` no more than once per project every six hours. It uses durable registration liveness rather than cursor existence, retains unacknowledged rows, and uses an age-only 30-day prune when no consumer is live. Run-audit mutation types are `task-deleted-outbox:catch-up`, `task-deleted-outbox:reconciliation-fallback`, `task-deleted-outbox:lease-fenced`, `task-deleted-outbox:dead-letter`, and `task-deleted-outbox:retention-pruned`. + +## Durable workflow principals + +Workflow work items fence agent-executed stages with `principalAgentId`, `workflowRole`, `authorityKind`, and `nodeInstanceId`. A fence is persisted before the session handler runs and retry/resume retains it. Task ownership is stable metadata; active identity is derived from live, leased work items rather than stored on the task row. + +Workflow session capacity is acquired separately from heartbeat capacity and released idempotently on terminal, cancellation, pause, and recovery paths. Task-assignee and review-node-override authority is a live task/run/work-item/node capability checked for every gated tool call; it does not broaden heartbeat, chat, other tasks, or other review nodes. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 46c9019dc7..23c8f079b1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1383,3 +1383,5 @@ For configuration details used by these commands, see [Settings Reference](./set - `fn org-import [--project ] [--dry-run] [--collision-mode skip|suffix]` materializes a bundle. `--dry-run` reports the plan without modifying stores or files; collision mode defaults to `skip` and `suffix` creates deterministically named copies. + +Agent create/update payloads accept `roles` (a non-empty role-tag array) and optional `runtimeConfig.maxWorkflowSessions`. The legacy singular `role` input remains accepted for migration compatibility. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0ed37eaac2..0105851d31 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2410,3 +2410,7 @@ Todo Lists is an optional first-party plugin. Enable `fusion-plugin-todos` for a ## Workflow direct-review items The Review tab shows a custom workflow result only when its selected workflow declares the exact top-level node and result source, the result explicitly snapshots `reviewKind: "plan"` or `"code"`, and it is current, terminal, and not bypassed or superseded. Each persisted structured finding becomes one independently selectable reviewer-agent item with its server-owned identity, optional location, and severity. Selecting a subset sends only those canonical items for revision; client-supplied text and metadata are ignored. A current result without findings retains one prose/notes fallback item. Pending, skipped, historical prior attempts, blank results, and records without that declared top-level identity (including template instances) are not selectable or addressable. Node-ID punctuation alone does not identify a template instance. Existing historical `plan-review` and `code-review` results retain narrow compatibility; Fusion does not infer or backfill custom review meaning from names, verdicts, prose, or gate settings. + +### Workflow agent routing + +Agent creation and detail settings support a primary role plus additional role tags. Workflow review prompts expose a node-local reviewer override and retain a missing configured ID visibly rather than clearing it. Task workflow-stage identity is distinct from assigned ownership: a stage badge identifies the currently fenced principal while active, then clears when its work item terminates. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index cd9cbd67fe..4ab283e1f2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -622,7 +622,7 @@ Default notes: | `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). | | `verificationCommandTimeoutMs` | `number` | `undefined` | Optional project-scoped default timeout in milliseconds for executor `fn_run_verification` and configured deterministic test/build verification commands. When unset, `fn_run_verification` keeps its scope defaults (300s package, 900s workspace); when set to a positive value, it overrides both scope defaults while all verification still respects the 1800s hard cap. Set `0` or leave unset to use the legacy scope defaults. Marathon command shapes (`pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and repeat loops) are soft-capped unless the agent explicitly passes `allowFullSuite: true`; opt-in full-suite runs still emit progress heartbeats and obey the hard cap. Project settings override global/default settings via the normal project settings precedence. | | `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. | -| `ephemeralAgentsEnabled` | `boolean` | `true` | Defaults to `true` for new projects and upgrades from pre-FN-4153 projects (falls back to `true` whenever the persisted PostgreSQL project settings omit the key). Users who explicitly set `false` keep that choice. When enabled, Fusion spawns short-lived `executor-FN-XXXX` workers for task execution. When disabled, only permanent executor agents run tasks; the scheduler auto-assigns dispatchable tasks using reporting-chain-aware load balancing, and tasks stay queued until an eligible permanent executor is available. | +| `ephemeralAgentsEnabled` | `boolean` | legacy compatibility | Legacy input is accepted for existing settings records but no longer appears in Settings or controls workflow-stage routing. Classified workflow sessions always route through durable multi-role principals; operators configure workflow-session capacity on agents instead. | | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | | `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. | | `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. | @@ -1899,3 +1899,7 @@ already in progress continues on its existing session. ### Authentication credential instances Settings → Authentication can hold multiple named credential accounts for each non-CLI provider. Select **Add another account** to create a client-generated account id, optionally label it, and complete OAuth or save an API key; an abandoned pending account is not stored. The first credential becomes the provider default; later accounts do not change it. Authentication actions without a selected named account target that provider default, while actions on a named account retain its instance id through login, cancellation, logout, save, and clear. Operators can rename, remove, or make an existing account default. Labels are display-only, optional, and need not be unique. CLI-backed provider cards retain their own credential handling and do not support Fusion credential instances. + +### Workflow principal limits + +`runtimeConfig.maxWorkflowSessions` is an optional per-agent cap for durable workflow sessions. It is independent of heartbeat `maxConcurrentRuns`: enabling a built-in agent heartbeat neither consumes nor changes workflow-session capacity. The former `ephemeralAgentsEnabled` value is accepted only as legacy configuration compatibility and no longer controls workflow-stage routing. diff --git a/docs/storage.md b/docs/storage.md index 32da4e1521..38454c8f41 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -820,3 +820,5 @@ FN-8685 adds `task_lifecycle_consumer_registrations`, `task_lifecycle_consumer_c `project.mission_validator_runs.input_fingerprint` stores a nullable SHA-256 content address for eligible automatic validator runs. The address hashes UTF-8 bytes of `JSON.stringify(["mission-validation-input-v1", landedSha, provider, modelId, systemPrompt, userPrompt])`; the versioned array avoids delimiter ambiguity and ordinary prompt-template changes invalidate naturally. The `(project_id, feature_id, input_fingerprint)` index scopes lookup and admission, so history never crosses projects or features. Automatic admission locks the project-scoped feature row, records a running row only for an admitted dispatch, and writes one `validation memoized` mission activity event for each suppressed running/pass/budget decision. Matching static passes are reused without fabricating a run. Matching failed rows consume the per-fingerprint budget; exhaustion records `loop_state = blocked` plus fingerprint/run/timestamp provenance and emits exactly one additional `validation-stuck` event for that feature/fingerprint. Repeated unchanged suppressions remain individually auditable but do not repeat the stuck event. Reaped `error` and `blocked` runs are transient and do not seed reuse or the failure count. + +`project.agents.roles` is a normalized JSONB role-tag array. Migration `0045_fn_8764_multi_role_workflow_agents.sql` backfills it from legacy singular roles. `workflow_work_items` also persist the routed principal fence fields used for recovery and audit. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 8db240b704..6f9b9d2583 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -960,3 +960,9 @@ A top-level `prompt`, `gate`, `script`, or `optional-group` node may set `config When a marked supported node runs, its pending and terminal workflow-step result snapshots the declared value. Review-kind prompt and script output may end with one JSON object containing `verdict`, `notes`, and `findings`. Each finding has a stable `id`, actionable `title` and `body`, plus optional `filePath`, positive `line`, and `low`/`medium`/`high`/`critical` severity. Fusion trims and bounds strings, drops malformed entries, and suffixes duplicate IDs; it never splits Markdown prose into findings. Findings persist through both ordinary-node and optional-group result writers in the existing JSONB result. A retry moves the replaced result (including its findings) into bounded single-level `priorAttempts`; only current findings are actionable. Findings are advisory metadata: they do not alter verdict parsing, gate status, merge blocking, recovery, or retry routing. A row without findings keeps its one prose/notes fallback item. Omission means the node is **not** a direct review, regardless of its ID, label, verdict, output prose, phase, or gate mode. Markers are rejected on foreach and loop templates and optional-group source/template nodes: those executions do not yet have an instance-safe current-result or Review-tab address contract. + +## Durable workflow principals + +Only session-launching nodes acquire a workflow principal: planning prompts use `triage`; implementation, remediation, script/CLI-agent and completion prompts use `executor`; `step-review` and review prompts use `reviewer`; merge prompts use `merger`. Control and lifecycle nodes, including start/end, hold, split/join, containers, parse-steps, notifications, asks, gates, and `review-handoff`, have no principal. + +A review node may persist `reviewerAgentId` in its IR. It is exact-node scoped and may name any permanent agent. Resolution is reviewer override (review only), task owner, column binding, then a matching role pool. Claimed `workflow_work_items` persist principal, role, authority kind, and node-instance fence. Named unavailable principals hold closed; pool exhaustion is reported separately. diff --git a/packages/cli/src/__tests__/extension-agent-update.test.ts b/packages/cli/src/__tests__/extension-agent-update.test.ts index 6894a9b0fe..4efbb38cc2 100644 --- a/packages/cli/src/__tests__/extension-agent-update.test.ts +++ b/packages/cli/src/__tests__/extension-agent-update.test.ts @@ -104,13 +104,14 @@ pgDescribe("fn_agent_update", () => { "call-1", { agent_id: ids.middle, - role: "reviewer", + roles: ["reviewer", "executor"], instructions_text: "Review thoroughly.", instructions_path: "docs/reviewer.md", reportsTo: ids.peer, heartbeat_interval_ms: 2000, heartbeat_timeout_ms: 6000, max_concurrent_runs: 2, + max_workflow_sessions: 3, message_response_mode: "on-heartbeat", }, undefined, @@ -121,7 +122,7 @@ pgDescribe("fn_agent_update", () => { expect(result.isError).not.toBe(true); expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.middle }); expect(result.details.updatedFields).toEqual([ - "role", + "roles", "instructionsText", "instructionsPath", "reportsTo", @@ -129,7 +130,8 @@ pgDescribe("fn_agent_update", () => { ]); expect(updateSpy).toHaveBeenCalledTimes(1); await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ - role: "reviewer", + role: "executor", + roles: ["executor", "reviewer"], instructionsText: "Review thoroughly.", instructionsPath: "docs/reviewer.md", reportsTo: ids.peer, @@ -140,9 +142,10 @@ pgDescribe("fn_agent_update", () => { heartbeatIntervalMs: 2000, heartbeatTimeoutMs: 6000, maxConcurrentRuns: 2, + maxWorkflowSessions: 3, messageResponseMode: "on-heartbeat", }); - expect(result.details.agent).toMatchObject({ id: ids.middle, role: "reviewer" }); + expect(result.details.agent).toMatchObject({ id: ids.middle, role: "executor", roles: ["executor", "reviewer"] }); updateSpy.mockRestore(); }); }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 9c026ed25e..68b9665fa3 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -4190,12 +4190,15 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { expect(text).not.toMatch(new RegExp(`Current Task: ${triageTask.id}(?! \\()`)); }); - it("returns empty list message when no agents", async () => { + it("lists the four mandatory built-in workflow owners on a fresh project", async () => { const tool = api.tools.get("fn_list_agents")!; const result = await tool.execute("la-5", {}, undefined, undefined, makeCtx(tmpDir)); - expect(result.content[0].text).toContain("No agents found"); - expect(result.details.count).toBe(0); + expect(result.content[0].text).toContain("Workflow Planner"); + expect(result.content[0].text).toContain("Workflow Executor"); + expect(result.content[0].text).toContain("Workflow Reviewer"); + expect(result.content[0].text).toContain("Workflow Merger"); + expect(result.details.count).toBe(4); }); }); @@ -4972,12 +4975,15 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { expect(result.content[0].text).toContain("org-report"); }); - it("returns empty message when no agents", async () => { + it("shows the mandatory built-in workflow owners in a fresh project", async () => { const tool = api.tools.get("fn_agent_org_chart")!; const result = await tool.execute("oc-3", {}, undefined, undefined, makeCtx(tmpDir)); - expect(result.content[0].text).toContain("No agents found"); - expect(result.details.count).toBe(0); + expect(result.content[0].text).toContain("Workflow Planner"); + expect(result.content[0].text).toContain("Workflow Executor"); + expect(result.content[0].text).toContain("Workflow Reviewer"); + expect(result.content[0].text).toContain("Workflow Merger"); + expect(result.details.count).toBe(4); }); it("returns single agent for lone agent", async () => { diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index a69ea73f78..c2f21bf11d 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -5294,14 +5294,19 @@ export default function kbExtension(pi: ExtensionAPI) { description: "Create a new non-ephemeral agent.", parameters: Type.Object({ name: Type.String({ description: "Agent name" }), - role: Type.Union([ + role: Type.Optional(Type.Union([ Type.Literal("triage"), Type.Literal("executor"), Type.Literal("reviewer"), Type.Literal("merger"), + Type.Literal("scheduler"), Type.Literal("engineer"), Type.Literal("custom"), - ], { description: "Agent role/capability" }), + ], { description: "Deprecated singular role; use roles for multi-role agents." })), + roles: Type.Optional(Type.Array(Type.Union([ + Type.Literal("triage"), Type.Literal("executor"), Type.Literal("reviewer"), + Type.Literal("merger"), Type.Literal("scheduler"), Type.Literal("engineer"), Type.Literal("custom"), + ]), { minItems: 1, description: "Canonical permanent-agent role tags." })), soul: Type.Optional(Type.String({ description: "Agent personality/identity text" })), instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions" })), instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown" })), @@ -5309,6 +5314,7 @@ export default function kbExtension(pi: ExtensionAPI) { heartbeat_interval_ms: Type.Optional(Type.Number({ minimum: 1000 })), heartbeat_timeout_ms: Type.Optional(Type.Number({ minimum: 5000 })), max_concurrent_runs: Type.Optional(Type.Number({ minimum: 1 })), + max_workflow_sessions: Type.Optional(Type.Number({ minimum: 1, description: "Max concurrent workflow sessions, independent of heartbeat runs" })), message_response_mode: Type.Optional(Type.Union([Type.Literal("immediate"), Type.Literal("on-heartbeat")])), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -5382,11 +5388,18 @@ export default function kbExtension(pi: ExtensionAPI) { ...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}), ...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}), ...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}), + ...(params.max_workflow_sessions !== undefined ? { maxWorkflowSessions: params.max_workflow_sessions } : {}), ...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}), }; const created = await agentStore.createAgent({ name: params.name, - role: params.role as never, + /* + FNXC:WorkflowAgentRouting 2026-08-07-07:56: + FN-8764 exposes canonical multi-role creation through the public CLI. + Singular `role` remains an input-only compatibility seam in AgentStore. + */ + ...(params.roles !== undefined ? { roles: params.roles as AgentCapability[] } : {}), + ...(params.role !== undefined ? { role: params.role as AgentCapability } : {}), ...(params.soul !== undefined ? { soul: params.soul } : {}), ...(params.instructions_text !== undefined ? { instructionsText: params.instructions_text } : {}), ...(params.instructions_path !== undefined ? { instructionsPath: params.instructions_path } : {}), @@ -5428,9 +5441,14 @@ export default function kbExtension(pi: ExtensionAPI) { Type.Literal("executor"), Type.Literal("reviewer"), Type.Literal("merger"), + Type.Literal("scheduler"), Type.Literal("engineer"), Type.Literal("custom"), - ], { description: "Agent role/capability" })), + ], { description: "Deprecated singular role; replaces roles for compatibility." })), + roles: Type.Optional(Type.Array(Type.Union([ + Type.Literal("triage"), Type.Literal("executor"), Type.Literal("reviewer"), + Type.Literal("merger"), Type.Literal("scheduler"), Type.Literal("engineer"), Type.Literal("custom"), + ]), { minItems: 1, description: "Canonical permanent-agent role tags." })), title: Type.Optional(Type.String({ description: "Optional title shown for the agent" })), icon: Type.Optional(Type.String({ description: "Optional compact icon/emoji" })), soul: Type.Optional(Type.String({ description: "Agent personality/identity text", maxLength: 10000 })), @@ -5441,6 +5459,7 @@ export default function kbExtension(pi: ExtensionAPI) { heartbeat_interval_ms: Type.Optional(Type.Number({ minimum: 1000, description: "Heartbeat polling interval in ms" })), heartbeat_timeout_ms: Type.Optional(Type.Number({ minimum: 5000, description: "Heartbeat timeout in ms" })), max_concurrent_runs: Type.Optional(Type.Number({ minimum: 1, description: "Max concurrent heartbeat runs" })), + max_workflow_sessions: Type.Optional(Type.Number({ minimum: 1, description: "Max concurrent workflow sessions, independent of heartbeat runs" })), message_response_mode: Type.Optional(Type.Union([ Type.Literal("immediate"), Type.Literal("on-heartbeat"), @@ -5457,6 +5476,7 @@ export default function kbExtension(pi: ExtensionAPI) { const updateParamKeys = [ "name", "role", + "roles", "title", "icon", "soul", @@ -5467,6 +5487,7 @@ export default function kbExtension(pi: ExtensionAPI) { "heartbeat_interval_ms", "heartbeat_timeout_ms", "max_concurrent_runs", + "max_workflow_sessions", "message_response_mode", ] as const; const providedKeys = updateParamKeys.filter((key) => params[key] !== undefined); @@ -5505,6 +5526,9 @@ export default function kbExtension(pi: ExtensionAPI) { if (params.max_concurrent_runs !== undefined && params.max_concurrent_runs < 1) { return invalid("max_concurrent_runs", "max_concurrent_runs must be at least 1"); } + if (params.max_workflow_sessions !== undefined && params.max_workflow_sessions < 1) { + return invalid("max_workflow_sessions", "max_workflow_sessions must be at least 1"); + } const target = (await agentStore.getAgent(params.agent_id)) ?? (await agentStore.resolveAgent(params.agent_id)); if (!target) { @@ -5586,6 +5610,7 @@ export default function kbExtension(pi: ExtensionAPI) { params.heartbeat_interval_ms, params.heartbeat_timeout_ms, params.max_concurrent_runs, + params.max_workflow_sessions, params.message_response_mode, ].some((value) => value !== undefined); const updateInput: AgentUpdateInput = {}; @@ -5596,7 +5621,8 @@ export default function kbExtension(pi: ExtensionAPI) { }; if (params.name !== undefined) setField("name", params.name); - if (params.role !== undefined) setField("role", params.role as AgentCapability); + if (params.roles !== undefined) setField("roles", params.roles as AgentCapability[]); + else if (params.role !== undefined) setField("role", params.role as AgentCapability); if (params.title !== undefined) setField("title", params.title); if (params.icon !== undefined) setField("icon", params.icon); if (params.soul !== undefined) setField("soul", params.soul); @@ -5612,6 +5638,7 @@ export default function kbExtension(pi: ExtensionAPI) { ...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}), ...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}), ...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}), + ...(params.max_workflow_sessions !== undefined ? { maxWorkflowSessions: params.max_workflow_sessions } : {}), ...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}), }); } @@ -5854,7 +5881,7 @@ export default function kbExtension(pi: ExtensionAPI) { const parts: string[] = [ `ID: ${agent.id}`, `Name: ${agent.name}`, - `Role: ${agent.role}`, + `Roles: ${(agent.roles?.length ? agent.roles : [agent.role]).join(", ")}`, `State: ${agent.state}`, ]; @@ -6261,7 +6288,7 @@ export default function kbExtension(pi: ExtensionAPI) { const parts: string[] = [ `ID: ${agent.id}`, `Name: ${agent.name}`, - `Role: ${agent.role}`, + `Roles: ${(agent.roles?.length ? agent.roles : [agent.role]).join(", ")}`, `State: ${agent.state}`, ]; diff --git a/packages/core/src/__tests__/agent-permissions.test.ts b/packages/core/src/__tests__/agent-permissions.test.ts index b65a6471c0..83493fccdf 100644 --- a/packages/core/src/__tests__/agent-permissions.test.ts +++ b/packages/core/src/__tests__/agent-permissions.test.ts @@ -81,6 +81,18 @@ describe("computeAccessState", () => { expect(state.resolvedPermissions.has("tasks:execute")).toBe(true); }); + it("unions defaults across canonical multi-role tags", () => { + const state = computeAccessState({ + ...makeAgent("reviewer"), + role: "reviewer", + roles: ["executor", "reviewer", "merger"], + }); + + expect(state.canExecuteTasks).toBe(true); + expect(state.canReviewTasks).toBe(true); + expect(state.canMergeTasks).toBe(true); + }); + it("scheduler role gets assign by default", () => { const state = computeAccessState(makeAgent("scheduler")); diff --git a/packages/core/src/__tests__/agent-role-policy.test.ts b/packages/core/src/__tests__/agent-role-policy.test.ts index bac53a8c92..116e415642 100644 --- a/packages/core/src/__tests__/agent-role-policy.test.ts +++ b/packages/core/src/__tests__/agent-role-policy.test.ts @@ -44,6 +44,13 @@ describe("agent-role-policy", () => { ).toBe(true); }); + it("accepts a canonical multi-role executor regardless of legacy role projection", () => { + const multiRoleAgent = { roles: ["reviewer", "executor"] as const, role: "reviewer" as const }; + expect(isExecutorRoleAgent(multiRoleAgent)).toBe(true); + expect(canAgentTakeImplementationTaskForExplicitRouting(multiRoleAgent, { column: "todo" })).toBe(true); + expect(canAgentTakeImplementationTaskForBacklogPickup(multiRoleAgent, { column: "todo" })).toBe(true); + }); + it("allows durable engineer for explicit routing and opt-in backlog pickup only", () => { expect(isEngineerRoleAgent({ role: "engineer" })).toBe(true); // Explicit routing is independent from engineerBacklogAutoClaim: callers diff --git a/packages/core/src/__tests__/agent-roles.test.ts b/packages/core/src/__tests__/agent-roles.test.ts new file mode 100644 index 0000000000..6672292a5f --- /dev/null +++ b/packages/core/src/__tests__/agent-roles.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAgentRoles } from "../types/agents/agents.js"; + +describe("normalizeAgentRoles", () => { + it("deduplicates tags into the canonical capability order", () => { + expect(normalizeAgentRoles(["custom", "executor", "executor", "triage"])).toEqual([ + "triage", + "executor", + "custom", + ]); + }); + + it("accepts a singular compatibility role once", () => { + expect(normalizeAgentRoles(undefined, "reviewer")).toEqual(["reviewer"]); + }); + + it("rejects absent and unknown role tags", () => { + expect(() => normalizeAgentRoles([], undefined)).toThrow("requires at least one role"); + expect(() => normalizeAgentRoles(["not-a-role"])).toThrow("unknown capability"); + }); +}); diff --git a/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts b/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts index 3238cbc08c..e04c652b2a 100644 --- a/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts +++ b/packages/core/src/__tests__/legacy-column-collection-gating-ledger.test.ts @@ -29,9 +29,6 @@ sites; all three were fine: agent-role-policy.ts:32 a documented FLAGGED-NOT-FIXED deferral with the reasoning recorded DocumentsView.tsx:88 already converted — flags-first, threaded as an object rather than called, so a scan for resolver CALLS cannot see the conversion - agent-assignment.ts:118 a `DELIBERATE-LITERAL` fallback behind an injected - `countsAsAssignmentLoad` callback, reviewed 2026-07-31-05:40 - That is the same failure `--triage`'s pick-work list had before #3194 fixed it, from the same cause: inferring "unexamined" from the absence of a pattern rather than from evidence. A detector that cannot tell "not yet looked at" from "looked at and settled" must not be pointed at a work queue. It @@ -67,12 +64,12 @@ const LEGACY_IDS = ["triage", "todo", "in-progress", "in-review", "done", "archi * lane vocabulary at all. */ const RECORDED_GATING_SITES: ReadonlySet = new Set([ - "packages/core/src/agent-role-policy.ts :: IMPLEMENTATION_TASK_COLUMNS", + "packages/core/src/agents/agent-role-policy.ts :: IMPLEMENTATION_TASK_COLUMNS", "packages/core/src/column-roles.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS", - "packages/core/src/live-agent-count.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS", + "packages/core/src/agents/live-agent-count.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS", "packages/core/src/task-store/branch-group-ops.ts :: satisfiedColumns", "packages/core/src/task-store/update-task-deps.ts :: refineFrom", - "packages/core/src/workflow-analytics.ts :: LEGACY_ACTIVE_LANES", + "packages/core/src/board/workflow-analytics.ts :: LEGACY_ACTIVE_LANES", "packages/dashboard/app/components/DocumentsView.tsx :: LEGACY_PRE_IMPLEMENTATION_COLUMNS", "packages/dashboard/app/components/TaskCard.tsx :: TIME_INDICATOR_COLUMNS", "packages/dashboard/app/components/TaskDetailModal.tsx :: GITHUB_TRACKING_EDITABLE_COLUMNS", @@ -80,14 +77,12 @@ const RECORDED_GATING_SITES: ReadonlySet = new Set([ "packages/dashboard/app/hooks/useTasks.ts :: PLANNER_ACTIVITY_COLUMN_IDS", "packages/dashboard/app/utils/columnRoles.ts :: LEGACY_FIELD_EDITABLE_COLUMN_IDS", "packages/dashboard/app/utils/columnRoles.ts :: LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS", - "packages/engine/src/agent-assignment.ts :: LEGACY_ACTIVE_COLUMNS", - "packages/engine/src/agent-reflection.ts :: completedColumns", - "packages/engine/src/ephemeral-worker-manager.ts :: TERMINAL_TASK_COLUMNS", + "packages/engine/src/agents/agent-reflection.ts :: completedColumns", "packages/engine/src/executor.ts :: activeColumns", "packages/engine/src/merger.ts :: finalizedColumns", "packages/engine/src/merger.ts :: sourceTerminal", - "packages/engine/src/mission-execution-loop.ts :: fixTaskTerminalColumns", - "packages/engine/src/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS", + "packages/engine/src/missions/mission-execution-loop.ts :: fixTaskTerminalColumns", + "packages/engine/src/missions/mission-feature-sync.ts :: LEGACY_PLANNER_COLUMNS", "packages/engine/src/triage.ts :: LEGACY_PLANNER_COLUMN_IDS", /* FNXC:WorkflowEvents 2026-08-03-02:01: @@ -96,7 +91,7 @@ const RECORDED_GATING_SITES: ReadonlySet = new Set([ so the ledger does not re-flag a RESOLVED site with a deliberate legacy arm. */ "packages/engine/src/triage.ts :: LEGACY_PLANNER_WAKE_COLUMNS", - "packages/engine/src/worktree-pool.ts :: managed", + "packages/engine/src/worktree/worktree-pool.ts :: managed", ]); function* walk(dir: string): Generator { diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index e9a2696c09..8cc87f92ce 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -87,6 +87,8 @@ import { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -107,7 +109,9 @@ describe("schema-applier: immutable migration identities", () => { expect(VALIDATOR_INPUT_FINGERPRINT_VERSION).toBe("0042"); expect(UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION).toBe("0043"); expect(QUEUED_EPISODE_SIGNATURE_VERSION).toBe("0044"); - expect(SCHEMA_BASELINE_VERSION).toBe("0044"); + expect(MULTI_ROLE_WORKFLOW_AGENTS_VERSION).toBe("0045"); + expect(WORKFLOW_PRINCIPAL_FENCE_VERSION).toBe("0046"); + expect(SCHEMA_BASELINE_VERSION).toBe("0046"); }); it("keeps monitor and approval isolation assigned to version 0003", () => { @@ -1764,6 +1768,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1834,6 +1840,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, ]); }); @@ -2037,6 +2045,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, ]); }); @@ -2121,6 +2131,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, ]); }); @@ -2205,6 +2217,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { VALIDATOR_INPUT_FINGERPRINT_VERSION, UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION, QUEUED_EPISODE_SIGNATURE_VERSION, + MULTI_ROLE_WORKFLOW_AGENTS_VERSION, + WORKFLOW_PRINCIPAL_FENCE_VERSION, ]); }); }); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 9a73e8aa50..7cf2dc3dd1 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -11,6 +11,7 @@ import { isProjectSettingsKey, } from "../types.js"; import { NON_DEFAULT_PROJECT_SETTINGS_KEYS } from "../config/settings-schema.js"; +import { canonicalizeSettings } from "../task-store/settings-helpers.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "../workflows/builtin-workflow-settings.js"; function assertExactKeyCoverage(scopeName: string, actual: readonly string[], expected: readonly string[]): void { @@ -285,10 +286,12 @@ describe("settings key parity", () => { expect(isGlobalSettingsKey("executorAllowSiblingBranchRename")).toBe(false); }); - it("defaults ephemeralAgentsEnabled to true and keeps it project-scoped", () => { - expect(DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled).toBe(true); - expect(isProjectSettingsKey("ephemeralAgentsEnabled")).toBe(true); + it("retires the ephemeral workflow-stage setting while accepting stale input", () => { + expect(DEFAULT_PROJECT_SETTINGS).not.toHaveProperty("ephemeralAgentsEnabled"); + expect(isProjectSettingsKey("ephemeralAgentsEnabled")).toBe(false); expect(isGlobalSettingsKey("ephemeralAgentsEnabled")).toBe(false); + expect(canonicalizeSettings({ ephemeralAgentsEnabled: false } as import("../types.js").Settings)) + .not.toHaveProperty("ephemeralAgentsEnabled"); }); it("defaults ephemeralAgentsCanCreateTasks to true and keeps it project-scoped", () => { diff --git a/packages/core/src/__tests__/workflow-agent-node-classification.test.ts b/packages/core/src/__tests__/workflow-agent-node-classification.test.ts new file mode 100644 index 0000000000..1d1fb0ec8d --- /dev/null +++ b/packages/core/src/__tests__/workflow-agent-node-classification.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkflowIr } from "../workflows/workflow-ir.js"; +import { classifyWorkflowAgentNode } from "../workflows/workflow-ir-types.js"; + +describe("workflow agent node classification", () => { + it("classifies only production session seams", () => { + expect(classifyWorkflowAgentNode({ id: "plan", kind: "prompt", config: { seam: "planning" } })).toBe("triage"); + expect(classifyWorkflowAgentNode({ id: "exec", kind: "prompt", config: { seam: "execute" } })).toBe("executor"); + expect(classifyWorkflowAgentNode({ id: "review", kind: "prompt", config: { seam: "review" } })).toBe("reviewer"); + expect(classifyWorkflowAgentNode({ id: "merge", kind: "prompt", config: { seam: "merge" } })).toBe("merger"); + expect(classifyWorkflowAgentNode({ id: "reviewer-session", kind: "prompt", config: { workflowRole: "reviewer" } })).toBe("reviewer"); + expect(classifyWorkflowAgentNode({ id: "handoff", kind: "prompt", config: { seam: "review-handoff" } })).toBeUndefined(); + expect(classifyWorkflowAgentNode({ id: "hold", kind: "hold" })).toBeUndefined(); + }); + + it("permits reviewer override only at reviewer session nodes", () => { + const valid = parseWorkflowIr({ version: "v2", name: "review", columns: [{ id: "todo", name: "Todo", traits: [] }], nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "review", kind: "prompt", column: "todo", reviewerAgentId: "agent-any", config: { workflowRole: "reviewer" } }, + { id: "end", kind: "end", column: "todo" }, + ], edges: [{ from: "start", to: "review" }, { from: "review", to: "end" }] }); + expect(valid.nodes[1].reviewerAgentId).toBe("agent-any"); + expect(() => parseWorkflowIr({ ...valid, nodes: valid.nodes.map((node) => node.id === "review" ? { ...node, config: { seam: "execute" } } : node) })).toThrow("only legal on reviewer-session nodes"); + }); +}); diff --git a/packages/core/src/__tests__/workflow-work-item-cas.test.ts b/packages/core/src/__tests__/workflow-work-item-cas.test.ts index c3f1042731..55822a6a8f 100644 --- a/packages/core/src/__tests__/workflow-work-item-cas.test.ts +++ b/packages/core/src/__tests__/workflow-work-item-cas.test.ts @@ -92,6 +92,44 @@ pgDescribe("workflow work-item transition compare-and-set", () => { expect(persisted?.retryAfter).not.toBe(LATER); }); + it("reclaims only a durable principal availability hold after it becomes due", async () => { + const store = h.store(); + const task = await store.createTask({ description: "availability hold recovery", column: "todo" }); + const held = await store.upsertWorkflowWorkItem({ + ...continuation(task.id), + state: "held", + blockedReason: "workflow-principal-named-principal-unavailable:executor", + }); + + const claimed = await store.acquireWorkflowWorkItemLease(held.id, "recovery-worker", { + leaseDurationMs: 60_000, + now: "2026-08-07T07:02:00.000Z", + }); + + expect(claimed).toMatchObject({ + id: held.id, + state: "running", + leaseOwner: "recovery-worker", + blockedReason: "workflow-principal-named-principal-unavailable:executor", + }); + }); + + it("does not claim a generic held item through workflow recovery", async () => { + const store = h.store(); + const task = await store.createTask({ description: "manual hold remains inert", column: "todo" }); + const held = await store.upsertWorkflowWorkItem({ + ...continuation(task.id), + state: "held", + blockedReason: "operator-approval-required", + }); + + await expect(store.acquireWorkflowWorkItemLease(held.id, "recovery-worker", { + leaseDurationMs: 60_000, + now: "2026-08-07T07:02:00.000Z", + })).resolves.toBeNull(); + expect((await store.getWorkflowWorkItem(held.id))?.state).toBe("held"); + }); + it("is a NO-OP rather than a throw for a terminalized item", async () => { const store = h.store(); const task = await store.createTask({ description: "cas terminal race", column: "todo" }); diff --git a/packages/core/src/agents/agent-permissions.ts b/packages/core/src/agents/agent-permissions.ts index f96176e2ca..2d634d76cd 100644 --- a/packages/core/src/agents/agent-permissions.ts +++ b/packages/core/src/agents/agent-permissions.ts @@ -39,9 +39,16 @@ export function normalizePermissions(raw: Record): Set(ROLE_DEFAULT_PERMISSIONS[agent.role] ?? []); + const roles = agent.roles?.length ? agent.roles : [agent.role]; + const roleDefaultPermissions = new Set(roles.flatMap((role) => ROLE_DEFAULT_PERMISSIONS[role] ?? [])); const explicitPermissions = normalizePermissions(agent.permissions ?? {}); const resolvedPermissions = new Set(roleDefaultPermissions); diff --git a/packages/core/src/agents/agent-role-policy.ts b/packages/core/src/agents/agent-role-policy.ts index 20c7cb0d5f..f0c516fbc1 100644 --- a/packages/core/src/agents/agent-role-policy.ts +++ b/packages/core/src/agents/agent-role-policy.ts @@ -1,4 +1,4 @@ -import type { Agent, Task } from "../types.js"; +import type { Agent, AgentCapability, Task } from "../types.js"; /* FNXC:WorkflowResolvedColumns 2026-07-30-15:20 (FLAGGED, NOT FIXED — found by a #2739 review thread): @@ -47,15 +47,27 @@ The per-agent assignment policy (agent.runtimeConfig.assignmentPolicy) closes th */ export type AgentAssignmentPolicy = "auto" | "explicit-only" | "none"; -export type AgentAssignmentPolicyInput = Pick & Partial>; +/* +FNXC:WorkflowAgentRouting 2026-08-07-07:56: +FN-8764 makes `roles` canonical. Assignment admission must inspect every +normalized tag while still accepting the deprecated singular projection from +legacy callers, so a multi-role executor is never rejected as its first tag. +*/ +type RoleTaggedAgent = Partial>; -export function getAgentAssignmentPolicy(agent: Partial>): AgentAssignmentPolicy { +function agentRoles(agent: RoleTaggedAgent): readonly AgentCapability[] { + return agent.roles?.length ? agent.roles : agent.role ? [agent.role] : []; +} + +export type AgentAssignmentPolicyInput = RoleTaggedAgent; + +export function getAgentAssignmentPolicy(agent: RoleTaggedAgent): AgentAssignmentPolicy { const raw = (agent.runtimeConfig ?? {})["assignmentPolicy"]; return raw === "explicit-only" || raw === "none" ? raw : "auto"; } /** Eligible for automatic routing (scheduler auto-assign, no-task backlog auto-claim). */ -export function isAgentAutoAssignable(agent: Partial>): boolean { +export function isAgentAutoAssignable(agent: RoleTaggedAgent): boolean { return getAgentAssignmentPolicy(agent) === "auto"; } @@ -63,7 +75,7 @@ export function isAgentAutoAssignable(agent: Partial>): boolean { +export function canAgentReceiveImplementationTasks(agent: RoleTaggedAgent): boolean { return getAgentAssignmentPolicy(agent) !== "none"; } @@ -71,12 +83,12 @@ export function isImplementationTask(task: Pick): boolean { return IMPLEMENTATION_TASK_COLUMNS.has(task.column); } -export function isExecutorRoleAgent(agent: Pick): boolean { - return agent.role === "executor"; +export function isExecutorRoleAgent(agent: RoleTaggedAgent): boolean { + return agentRoles(agent).includes("executor"); } -export function isEngineerRoleAgent(agent: Pick): boolean { - return agent.role === "engineer"; +export function isEngineerRoleAgent(agent: RoleTaggedAgent): boolean { + return agentRoles(agent).includes("engineer"); } export function canAgentTakeImplementationTaskForExplicitRouting( @@ -140,7 +152,7 @@ export interface ImplementationTaskBindContext { export type ImplementationTaskBindVerdict = { allowed: true } | { allowed: false; reason: string }; export function evaluateImplementationTaskBind( - agent: Pick & Partial>, + agent: RoleTaggedAgent & Pick, task: Pick, context: ImplementationTaskBindContext = {}, ): ImplementationTaskBindVerdict { @@ -174,7 +186,7 @@ export class AgentTaskRoutingPolicyError extends Error { } export function assertImplementationTaskBindAllowed( - agent: Pick & Partial>, + agent: RoleTaggedAgent & Pick, task: Pick, context: ImplementationTaskBindContext = {}, ): void { @@ -185,12 +197,13 @@ export function assertImplementationTaskBindAllowed( } export function formatRoleMismatchReason( - agent: Pick & Partial>, + agent: RoleTaggedAgent & Pick, task: Pick, ): string { const policy = getAgentAssignmentPolicy(agent); if (policy !== "auto") { return `Agent ${agent.id} has assignmentPolicy "${policy}"; implementation task ${task.id} cannot be routed to it${policy === "none" ? " by any path (no override supported)" : " automatically — explicit routing only"}.`; } - return `Agent ${agent.id} has role "${agent.role}"; implementation task ${task.id} requires an "executor"-role agent by default, with durable "engineer" supported only for explicit routing. Pass override=true to bypass.`; + const roles = agentRoles(agent); + return `Agent ${agent.id} has roles "${roles.join(", ") || "none"}"; implementation task ${task.id} requires an "executor"-role agent by default, with durable "engineer" supported only for explicit routing. Pass override=true to bypass.`; } diff --git a/packages/core/src/agents/agent-store.ts b/packages/core/src/agents/agent-store.ts index a5733fdd53..4b10bb2c0a 100644 --- a/packages/core/src/agents/agent-store.ts +++ b/packages/core/src/agents/agent-store.ts @@ -59,8 +59,11 @@ import {resolveTaskLifecycleColumns} from "../workflows/workflow-lifecycle-trait import { computeAccessState, normalizePermissions } from "./agent-permissions.js"; import { assertImplementationTaskBindAllowed, evaluateImplementationTaskBind } from "./agent-role-policy.js"; import { normalizeAgentPermissionPolicy } from "./agent-permission-policy.js"; +import { normalizeAgentRoles } from "../types/agents/agents.js"; import { Database } from "../db/db.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; +import * as postgresSchema from "../postgres/schema/index.js"; +import { and, eq, gt, lte, sql } from "drizzle-orm"; /* * FNXC:SqliteFinalRemoval 2026-06-25-23:30: * Async Drizzle helpers for backend-mode (PostgreSQL) AgentStore operations. @@ -167,6 +170,8 @@ export interface AgentStoreOptions { interface AgentData { id: string; name: string; + roles?: AgentCapability[]; + /** Deprecated compatibility projection persisted for legacy consumers. */ role: AgentCapability; state: AgentState; taskId?: string; @@ -408,6 +413,94 @@ export class AgentStore extends EventEmitter { return projectId; } + /** The durable project partition used by workflow routing and capacity leases. */ + public get workflowProjectId(): string | undefined { + return this.asyncLayer?.projectId; + } + + /** + * FNXC:WorkflowAgentRouting 2026-08-07-05:52: + * Workflow capacity is a PostgreSQL lease rather than a process-local count. + * The project advisory lock serializes project then agent admission across + * engine processes; model work starts only after this short transaction ends. + */ + public async acquireWorkflowSessionCapacity(input: { + agentId: string; + attemptId: string; + maxProjectSessions?: number; + maxAgentSessions?: number; + leaseDurationMs?: number; + }): Promise<"acquired" | "project-capacity" | "agent-capacity"> { + if (!this.asyncLayer) return "acquired"; + const projectId = this.backendProjectId; + const now = new Date(); + const expiresAt = new Date(now.getTime() + (input.leaseDurationMs ?? 10 * 60_000)).toISOString(); + return this.asyncLayer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${projectId}), hashtext('workflow-agent-capacity'))`); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:16: + * Capacity is a crash-safe lease, not a permanent counter. Reclaim only + * expired rows while holding the same project admission lock used for the + * count and insert, so a dead engine frees slots without oversubscription. + */ + await tx.delete(postgresSchema.project.workflowAgentCapacityLeases).where(and( + eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, projectId), + lte(postgresSchema.project.workflowAgentCapacityLeases.expiresAt, now.toISOString()), + )); + const existing = await tx.select({ attemptId: postgresSchema.project.workflowAgentCapacityLeases.attemptId }) + .from(postgresSchema.project.workflowAgentCapacityLeases) + .where(and( + eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, projectId), + eq(postgresSchema.project.workflowAgentCapacityLeases.attemptId, input.attemptId), + )).limit(1); + if (existing[0]) return "acquired"; + const projectCount = await tx.select({ count: sql`count(*)::int` }) + .from(postgresSchema.project.workflowAgentCapacityLeases) + .where(eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, projectId)); + if (input.maxProjectSessions !== undefined && (projectCount[0]?.count ?? 0) >= input.maxProjectSessions) return "project-capacity"; + const agentCount = await tx.select({ count: sql`count(*)::int` }) + .from(postgresSchema.project.workflowAgentCapacityLeases) + .where(and( + eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, projectId), + eq(postgresSchema.project.workflowAgentCapacityLeases.agentId, input.agentId), + )); + if (input.maxAgentSessions !== undefined && (agentCount[0]?.count ?? 0) >= input.maxAgentSessions) return "agent-capacity"; + await tx.insert(postgresSchema.project.workflowAgentCapacityLeases).values({ + projectId, agentId: input.agentId, attemptId: input.attemptId, createdAt: now.toISOString(), expiresAt, + }); + return "acquired"; + }); + } + + /** Renew only the caller's project-scoped attempt; a reclaimed lease never resurrects. */ + public async renewWorkflowSessionCapacity(attemptId: string, leaseDurationMs = 10 * 60_000): Promise { + if (!this.asyncLayer) return true; + const now = new Date(); + const result = await this.asyncLayer.db.update(postgresSchema.project.workflowAgentCapacityLeases) + .set({ expiresAt: new Date(now.getTime() + leaseDurationMs).toISOString() }) + .where(and( + eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, this.backendProjectId), + eq(postgresSchema.project.workflowAgentCapacityLeases.attemptId, attemptId), + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:30: + * Renew a currently live lease only. An expired row may already have + * been reclaimed by another engine's admission transaction. + */ + gt(postgresSchema.project.workflowAgentCapacityLeases.expiresAt, now.toISOString()), + )) + .returning({ attemptId: postgresSchema.project.workflowAgentCapacityLeases.attemptId }); + return result.length > 0; + } + + /** Release is project-scoped and idempotent so cancellation and recovery may race safely. */ + public async releaseWorkflowSessionCapacity(attemptId: string): Promise { + if (!this.asyncLayer) return; + await this.asyncLayer.db.delete(postgresSchema.project.workflowAgentCapacityLeases).where(and( + eq(postgresSchema.project.workflowAgentCapacityLeases.projectId, this.backendProjectId), + eq(postgresSchema.project.workflowAgentCapacityLeases.attemptId, attemptId), + )); + } + private get db(): Database { throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); } @@ -422,9 +515,15 @@ export class AgentStore extends EventEmitter { * covers these migrations. Only create the agents directory. */ async init(): Promise { - await mkdir(this.agentsDir, { recursive: true }); - return; -} + await mkdir(this.agentsDir, { recursive: true }); + /* + FNXC:WorkflowAgentRouting 2026-08-07-03:12: + Every upgraded or new project must have the four durable workflow owners + once storage is ready. Their heartbeat remains disabled; graph routing may + still invoke them independently of the heartbeat scheduler. + */ + await this.provisionBuiltinWorkflowRoleAgents(); + } /** * One-shot migration that re-points every non-ephemeral agent off the @@ -677,13 +776,11 @@ export class AgentStore extends EventEmitter { if (!input.name?.trim()) { throw new Error("Agent name is required"); } - if (!input.role) { - throw new Error("Agent role is required"); - } + const roles = normalizeAgentRoles(input.roles, input.role); const normalizedName = input.name.trim(); const metadata = input.metadata ?? {}; - const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo }); + const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: roles[0], reportsTo: input.reportsTo }); if (!ephemeral) { const existing = await this.findAgentByName(normalizedName); @@ -717,7 +814,8 @@ export class AgentStore extends EventEmitter { const agent: Agent = { id: agentId, name: normalizedName, - role: input.role, + roles, + role: roles[0], // Non-ephemeral agents start active so they immediately participate in // heartbeat scheduling; ephemeral/task-worker agents start idle and are // activated by the engine when work is assigned. @@ -1164,10 +1262,14 @@ export class AgentStore extends EventEmitter { ? normalizeAgentPermissionPolicy(updates.permissionPolicy) : updates.permissionPolicy; + const roles = updates.roles !== undefined || updates.role !== undefined + ? normalizeAgentRoles(updates.roles, updates.role) + : agent.roles; const updated: Agent = { ...agent, name: nextName ?? agent.name, - role: updates.role ?? agent.role, + roles, + role: roles[0], metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata, updatedAt, ...("title" in updates && { title: updates.title }), @@ -1859,16 +1961,68 @@ export class AgentStore extends EventEmitter { * @returns Array of agents */ async listAgents(filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }): Promise { - // FNXC:SqliteFinalRemoval 2026-06-25-23:50: - // Backend mode: read via async Drizzle helper, apply ephemeral filter in-memory. - const agents = await listAgentRowsAsync(this.asyncLayer!.db, { - state: filter?.state, - role: filter?.role, - }); + // FNXC:WorkflowAgentRouting 2026-08-07-03:12: + // Role-pool membership is canonical multi-tag state, so SQL must not use the + // deprecated singular projection to exclude a matching durable principal. + const agents = await listAgentRowsAsync(this.asyncLayer!.db, { state: filter?.state }); return agents .map((a) => this.parseAgent(a as unknown as AgentData)) + .filter((agent) => !filter?.role || agent.roles.includes(filter.role)) .filter((agent) => filter?.includeEphemeral === true || !isEphemeralAgent(agent)); -} + } + + /** + * Idempotently seed the permanent owners that route built-in workflow stages. + * Matching operator-created agents are intentionally not reused: provenance + * makes upgrade repair deterministic while operators remain free to add pool + * members with the same tag. + */ + async provisionBuiltinWorkflowRoleAgents(): Promise { + const provision = async (): Promise => { + const definitions: ReadonlyArray<{ role: AgentCapability; name: string; title: string }> = [ + { role: "triage", name: "Workflow Planner", title: "Built-in workflow planning owner" }, + { role: "executor", name: "Workflow Executor", title: "Built-in workflow execution owner" }, + { role: "reviewer", name: "Workflow Reviewer", title: "Built-in workflow review owner" }, + { role: "merger", name: "Workflow Merger", title: "Built-in workflow merge owner" }, + ]; + const existing = await this.listAgents({ includeEphemeral: true }); + const builtins = new Map( + existing + .filter((agent) => agent.metadata?.builtInWorkflowRole === true) + .map((agent) => [agent.metadata.workflowRole as AgentCapability, agent]), + ); + const result: Agent[] = []; + for (const definition of definitions) { + const present = builtins.get(definition.role); + if (present) { + result.push(present); + continue; + } + result.push(await this.createAgent({ + name: definition.name, + roles: [definition.role], + title: definition.title, + metadata: { builtInWorkflowRole: true, workflowRole: definition.role }, + // Disabled scheduling does not make the agent unavailable for graph sessions. + runtimeConfig: { enabled: false }, + })); + } + return result; + }; + if (!this.asyncLayer) return provision(); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:16: + * Startup and onboarding can run in separate engine processes. Serialize the + * read/repair/create sequence with a project-scoped advisory lock so both + * callers observe the first four durable built-ins instead of racing to add + * duplicate owners. User-created same-role agents are intentionally outside + * this provenance lock and remain valid pool members. + */ + return this.asyncLayer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${this.backendProjectId}), hashtext('builtin-workflow-role-provisioning'))`); + return provision(); + }); + } /** * Create an API key for an agent. @@ -2821,6 +2975,7 @@ export class AgentStore extends EventEmitter { ...data, id: row.id, name: row.name, + roles: (data.roles?.length ? data.roles : [row.role]), role: row.role, state: row.state, taskId: row.taskId ?? undefined, @@ -2864,7 +3019,8 @@ export class AgentStore extends EventEmitter { return { id: data.id, name: data.name, - role: data.role, + roles: normalizeAgentRoles(data.roles, data.role), + role: normalizeAgentRoles(data.roles, data.role)[0], state: data.state, taskId: data.taskId, createdAt: data.createdAt, diff --git a/packages/core/src/async-stores/async-agent-store.ts b/packages/core/src/async-stores/async-agent-store.ts index ec8af5dfae..eef3b0fbe6 100644 --- a/packages/core/src/async-stores/async-agent-store.ts +++ b/packages/core/src/async-stores/async-agent-store.ts @@ -70,6 +70,7 @@ interface AgentRow { id: string; name: string; role: string; + roles: string[]; state: string; taskId: string | null; createdAt: string; @@ -105,6 +106,7 @@ const agentColumns = { id: schema.project.agents.id, name: schema.project.agents.name, role: schema.project.agents.role, + roles: schema.project.agents.roles, state: schema.project.agents.state, taskId: schema.project.agents.taskId, createdAt: schema.project.agents.createdAt, @@ -145,6 +147,7 @@ export function agentToData(agent: Agent): Record { return { id: agent.id, name: agent.name, + roles: agent.roles, role: agent.role, state: agent.state, taskId: agent.taskId, @@ -195,6 +198,7 @@ export async function writeAgent(handle: QueryHandle, agent: Agent, projectId?: id: agent.id, name: agent.name, role: agent.role, + roles: agent.roles, state: agent.state, taskId: agent.taskId ?? null, createdAt: agent.createdAt, @@ -208,6 +212,7 @@ export async function writeAgent(handle: QueryHandle, agent: Agent, projectId?: set: { name: agent.name, role: agent.role, + roles: agent.roles, state: agent.state, taskId: agent.taskId ?? null, updatedAt: agent.updatedAt, @@ -244,6 +249,7 @@ export function mergeAgentRow(row: AgentRow): Agent { ...(data as object), id: row.id, name: row.name, + roles: (row.roles?.length ? row.roles : [row.role]) as AgentCapability[], role: row.role as AgentCapability, state: row.state as AgentState, taskId: row.taskId ?? undefined, diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index aeae058d05..22a7c2059f 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -66,7 +66,9 @@ type MovedProjectSettingsKey = | "validatorFallbackThinkingLevel"; type NonDefaultProjectSettingsKey = "ephemeralAgentTaskCreationPolicy" | "selectedWorkflowModelLanes"; -type ProjectSettingsSchema = Omit; +/** Legacy inputs that remain typed only long enough for read/write compatibility stripping. */ +type RetiredProjectSettingsKey = "ephemeralAgentsEnabled"; +type ProjectSettingsSchema = Omit; /** * Settings schema source of truth. @@ -629,7 +631,6 @@ export const DEFAULT_PROJECT_SETTINGS = { // proportional to the change; the thin merge gate carries cross-cutting // coverage. Falls back to package/explicit command when no tests resolve. scopeVerificationToChangedFiles: true, - ephemeralAgentsEnabled: true, /* FNXC:EphemeralAgentTaskCreation 2026-07-01-00:00: Default-on so ephemeral task-worker agents keep the ability to open follow-up tasks via fn_task_create. Operators who want to confine task creation to humans/permanent agents flip this off. diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 4d19b824c1..fde870d831 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -44,7 +44,7 @@ etc. pulled in by production modules, not test files). export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumnId, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, ANTHROPIC_AUTH_PREFERENCES, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, PLANNER_OVERSIGHT_LEVELS, DEFAULT_PLANNER_OVERSIGHT_LEVEL, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, resolveEphemeralTaskCreationPolicy, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, DEFAULT_GITLAB_API_BASE_URL, DEFAULT_GITLAB_INSTANCE_URL, resolveGitlabConfig, resolveGitlabEnabled, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef, OVERSEER_INTERVENTION_MUTATION } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, TaskGitLabTracking, TaskGitLabTrackedItem, GitLabTrackedItemKind, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, ArchivedTaskDocumentAdditionInput, ArchivedTaskDocumentAdditionResult, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, NativeStructureRef, NativeStructureOpenTarget, NativeStructurePreviewPayload, NativeStructureUnavailablePayload, NativeStructurePreviewResult, TaskCreateInput, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, ReportMode, ReportActionType, ReportTarget, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, AnthropicAuthPreference, ThemeMode, ColorTheme, Locale, ExecutionMode, PlannerOversightLevel, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowReviewKind, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyToolRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, ProposedTaskMetadata, EphemeralTaskCreationPolicy, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings, GitlabConfigSettingsSource, ResolvedGitlabConfig, ResolveGitlabConfigInput, GitlabAuthTokenType, PlannerOversightStage, PlannerInterventionAction, PlannerInterventionOutcome, PlannerInterventionSourceLink, PlannerInterventionEntry, BackupSettingsMigrationCandidate, BackupSettingsMigrationConflict } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError, normalizeAgentRoles } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 183dffab09..98628754ee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,7 +39,7 @@ export type { MissionLineageSnapshot, } from "./tasks/symbol-lock-lineage-approval.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, REPORT_ATTACHMENT_SOURCE, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError, PLANNER_AGENT_ROLE} from "./types.js"; -export { WEDGE_RENOTIFY_COOLDOWN_MS } from "./types.js"; +export { WEDGE_RENOTIFY_COOLDOWN_MS, normalizeAgentRoles } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, @@ -273,12 +273,17 @@ export type { // CLI Agent Executor (U7): node-config executor typing. WorkflowNodeExecutorKind, WorkflowNodeExecutorConfig, + WorkflowAgentRole, } from "./workflows/workflow-ir-types.js"; export { DEFAULT_MAX_REWORK_CYCLES, MAX_REWORK_CYCLES_CAP, resolveMaxReworkCycles, resolveOptionalStepRevisionBudget, + classifyWorkflowAgentNode, + isWorkflowAgentNodeForRole, + isWorkflowAgentRole, + isWorkflowReviewerNode, } from "./workflows/workflow-ir-types.js"; export { instanceNodeId, diff --git a/packages/core/src/postgres/migrations/0045_fn_8764_multi_role_workflow_agents.sql b/packages/core/src/postgres/migrations/0045_fn_8764_multi_role_workflow_agents.sql new file mode 100644 index 0000000000..be706c9c84 --- /dev/null +++ b/packages/core/src/postgres/migrations/0045_fn_8764_multi_role_workflow_agents.sql @@ -0,0 +1,20 @@ +-- FNXC:WorkflowAgentRouting 2026-08-07-03:12: +-- FN-8764 changes permanent-agent capability from one role to normalized tags. +-- Keep `role` during the compatibility window, but backfill canonical `roles` +-- idempotently so upgrades preserve every legacy agent identity. +DO $$ +BEGIN + -- Partial historic schemas in the upgrade harness did not yet have agents; + -- a later normal startup applies this idempotent migration after the baseline. + IF to_regclass('project.agents') IS NOT NULL THEN + ALTER TABLE project.agents + ADD COLUMN IF NOT EXISTS roles jsonb NOT NULL DEFAULT '[]'::jsonb; + + UPDATE project.agents + SET roles = jsonb_build_array(role) + WHERE jsonb_typeof(roles) <> 'array' + OR jsonb_array_length(roles) = 0; + + CREATE INDEX IF NOT EXISTS idx_agents_roles ON project.agents USING gin (roles); + END IF; +END $$; diff --git a/packages/core/src/postgres/migrations/0046_fn_8764_workflow_principal_fence.sql b/packages/core/src/postgres/migrations/0046_fn_8764_workflow_principal_fence.sql new file mode 100644 index 0000000000..6f68078429 --- /dev/null +++ b/packages/core/src/postgres/migrations/0046_fn_8764_workflow_principal_fence.sql @@ -0,0 +1,49 @@ +-- FNXC:WorkflowAgentRouting 2026-08-07-03:25: +-- FN-8764 requires routing identity to live on the project-scoped work item, +-- not mutable task ownership. These nullable fields preserve legacy items while +-- fencing new classified session attempts and their reviewer-node boundaries. +DO $$ +BEGIN + -- Very old upgrade fixtures can predate workflow work items entirely. + CREATE TABLE IF NOT EXISTS project.workflow_agent_capacity_leases ( + project_id text NOT NULL DEFAULT current_setting('fusion.project_id', true), + attempt_id text NOT NULL, + agent_id text NOT NULL, + created_at text NOT NULL, + expires_at text NOT NULL, + PRIMARY KEY (project_id, attempt_id) + ); + -- FNXC:WorkflowAgentRouting 2026-08-07-07:16: Existing leases gain a finite expiry so a process crash cannot consume workflow capacity indefinitely. + ALTER TABLE project.workflow_agent_capacity_leases + ADD COLUMN IF NOT EXISTS expires_at text; + UPDATE project.workflow_agent_capacity_leases + SET expires_at = created_at + WHERE expires_at IS NULL; + ALTER TABLE project.workflow_agent_capacity_leases + ALTER COLUMN expires_at SET NOT NULL; + CREATE INDEX IF NOT EXISTS idx_workflow_agent_capacity_leases_agent + ON project.workflow_agent_capacity_leases (project_id, agent_id); + CREATE INDEX IF NOT EXISTS idx_workflow_agent_capacity_leases_expiry + ON project.workflow_agent_capacity_leases (project_id, expires_at); + ALTER TABLE project.workflow_agent_capacity_leases ENABLE ROW LEVEL SECURITY; + ALTER TABLE project.workflow_agent_capacity_leases FORCE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS fusion_project_isolation ON project.workflow_agent_capacity_leases; + CREATE POLICY fusion_project_isolation ON project.workflow_agent_capacity_leases + USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)) + WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true)); + DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.workflow_agent_capacity_leases; + CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id ON project.workflow_agent_capacity_leases + FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id(); + + IF to_regclass('project.workflow_work_items') IS NOT NULL THEN + ALTER TABLE project.workflow_work_items + ADD COLUMN IF NOT EXISTS principal_agent_id text, + ADD COLUMN IF NOT EXISTS workflow_role text, + ADD COLUMN IF NOT EXISTS authority_kind text, + ADD COLUMN IF NOT EXISTS node_instance_id text; + + CREATE INDEX IF NOT EXISTS idx_workflow_work_items_active_principal + ON project.workflow_work_items (project_id, principal_agent_id, state) + WHERE principal_agent_id IS NOT NULL; + END IF; +END $$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 4c6e2a1651..e7f9c4c078 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -56,7 +56,7 @@ capacity-model table drop that landed while this PR was open. */ /* FNXC:CrossProcessDeleteObservation 2026-08-01-11:39: advance the schema ceiling so durable consumer state exists before observers begin polling FN-8684's outbox. */ /* FNXC:MissionValidation 2026-08-01-16:21: advance the schema ceiling before validator admission reads durable content fingerprints. */ -export const SCHEMA_BASELINE_VERSION = "0044"; +export const SCHEMA_BASELINE_VERSION = "0046"; /** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -187,6 +187,10 @@ export const VALIDATOR_INPUT_FINGERPRINT_VERSION = "0042"; export const UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION = "0043"; /** FNXC:QueuedTaskLogging 2026-08-04-18:03: upgraded databases need the durable full queue-episode signature before concurrent producers can suppress repeats safely. */ export const QUEUED_EPISODE_SIGNATURE_VERSION = "0044"; +/** FNXC:WorkflowAgentRouting 2026-08-07-03:12: explicit registration keeps role-tag upgrades from being skipped. */ +export const MULTI_ROLE_WORKFLOW_AGENTS_VERSION = "0045"; +/** FNXC:WorkflowAgentRouting 2026-08-07-03:25: upgraded projects need durable principal fencing before graph dispatch can route permanent agents. */ +export const WORKFLOW_PRINCIPAL_FENCE_VERSION = "0046"; /** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */ export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained"; @@ -405,6 +409,8 @@ const TASK_LIFECYCLE_CONSUMERS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0041_fn_86 const VALIDATOR_INPUT_FINGERPRINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0042_fn_8694_validator_input_fingerprint.sql"); const UNPLANNED_EXECUTION_BLOCK_DEDUPE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0043_fn8768_dispatch_dedupe.sql"); const QUEUED_EPISODE_SIGNATURE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0044_fn_8785_queued_episode_signature.sql"); +const MULTI_ROLE_WORKFLOW_AGENTS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0045_fn_8764_multi_role_workflow_agents.sql"); +const WORKFLOW_PRINCIPAL_FENCE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0046_fn_8764_workflow_principal_fence.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -519,6 +525,8 @@ export async function applySchemaBaseline( const validatorInputFingerprintAlreadyApplied = applied.includes(VALIDATOR_INPUT_FINGERPRINT_VERSION); const unplannedExecutionBlockDedupeAlreadyApplied = applied.includes(UNPLANNED_EXECUTION_BLOCK_DEDUPE_VERSION); const queuedEpisodeSignatureAlreadyApplied = applied.includes(QUEUED_EPISODE_SIGNATURE_VERSION); + const multiRoleWorkflowAgentsAlreadyApplied = applied.includes(MULTI_ROLE_WORKFLOW_AGENTS_VERSION); + const workflowPrincipalFenceAlreadyApplied = applied.includes(WORKFLOW_PRINCIPAL_FENCE_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -1110,6 +1118,18 @@ export async function applySchemaBaseline( await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${QUEUED_EPISODE_SIGNATURE_VERSION}) ON CONFLICT (version) DO NOTHING`); schemaChanged = true; } + if (!multiRoleWorkflowAgentsAlreadyApplied) { + const migrationSql = await readFile(MULTI_ROLE_WORKFLOW_AGENTS_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MULTI_ROLE_WORKFLOW_AGENTS_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + if (!workflowPrincipalFenceAlreadyApplied) { + const migrationSql = await readFile(WORKFLOW_PRINCIPAL_FENCE_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${WORKFLOW_PRINCIPAL_FENCE_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 0b2fcf977e..e70d7e262d 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -802,7 +802,9 @@ export const agents = projectSchema.table("agents", { projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), id: text("id").notNull(), name: text("name").notNull(), + /** Deprecated singular compatibility column; canonical roles live in roles JSONB. */ role: text("role").notNull(), + roles: jsonb("roles").notNull().default([]), state: text("state").notNull().default("idle"), taskId: text("task_id"), createdAt: text("created_at").notNull(), @@ -938,6 +940,18 @@ export const completionHandoffMarkers = projectSchema.table("completion_handoff_ ]); // ── Workflow work items ────────────────────────────────────────────── +export const workflowAgentCapacityLeases = projectSchema.table("workflow_agent_capacity_leases", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + attemptId: text("attempt_id").notNull(), + agentId: text("agent_id").notNull(), + createdAt: text("created_at").notNull(), + // FNXC:WorkflowAgentRouting 2026-08-07-07:16: A crashed engine cannot run its finally cleanup, so durable workflow capacity leases expire and are reclaimed on the next admission. + expiresAt: text("expires_at").notNull(), +}, (t) => [ + primaryKey({ columns: [t.projectId, t.attemptId] }), + index("idx_workflow_agent_capacity_leases_agent").on(t.projectId, t.agentId), +]); + export const workflowWorkItems = projectSchema.table("workflow_work_items", { projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), id: text("id").notNull(), @@ -958,6 +972,13 @@ export const workflowWorkItems = projectSchema.table("workflow_work_items", { sourceColumn: text("source_column"), targetColumn: text("target_column"), irHash: text("ir_hash"), + // FNXC:WorkflowAgentRouting 2026-08-07-03:25: + // A workflow claim fences its durable principal and exact template instance. + // Session retries must reuse this identity rather than re-routing a task. + principalAgentId: text("principal_agent_id"), + workflowRole: text("workflow_role"), + authorityKind: text("authority_kind"), + nodeInstanceId: text("node_instance_id"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 78fb932003..5f18e8e622 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1963,7 +1963,7 @@ export class TaskStore extends EventEmitter { return cancelActiveWorkflowWorkItemsForTaskImpl(this, taskId, opts, tx); } async listDueWorkflowWorkItems(filter: WorkflowWorkItemDueFilter = {}): Promise { - return listDueWorkflowWorkItemsImpl(this, filter); + return listDueWorkflowWorkItemsImpl(this, { ...filter, projectId: this.asyncLayer?.projectId }); } async acquireWorkflowWorkItemLease( id: string, leaseOwner: string, opts: { leaseDurationMs: number; now?: string }, ): Promise { return acquireWorkflowWorkItemLeaseImpl(this, id, leaseOwner, opts); diff --git a/packages/core/src/task-store/async/async-workflow-workitems.ts b/packages/core/src/task-store/async/async-workflow-workitems.ts index a80d4c1759..09e854952b 100644 --- a/packages/core/src/task-store/async/async-workflow-workitems.ts +++ b/packages/core/src/task-store/async/async-workflow-workitems.ts @@ -112,6 +112,10 @@ export function rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkIte sourceColumn: row.sourceColumn, targetColumn: row.targetColumn, irHash: row.irHash, + principalAgentId: row.principalAgentId, + workflowRole: row.workflowRole as WorkflowWorkItem["workflowRole"], + authorityKind: row.authorityKind as WorkflowWorkItem["authorityKind"], + nodeInstanceId: row.nodeInstanceId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -123,11 +127,15 @@ export function rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkIte export async function getWorkflowWorkItem( db: AsyncDataLayer["db"] | DbTransaction, id: string, + projectId?: string, ): Promise { const rows = await db .select() .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.id, id)) + .where(and( + eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, projectId), + )) .limit(1); const row = rows[0] as WorkflowWorkItemRow | undefined; return row ? rowToWorkflowWorkItem(row) : null; @@ -163,6 +171,7 @@ export async function upsertWorkflowWorkItem( .from(schema.project.workflowWorkItems) .where( and( + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), eq(schema.project.workflowWorkItems.runId, input.runId), eq(schema.project.workflowWorkItems.taskId, input.taskId), eq(schema.project.workflowWorkItems.nodeId, input.nodeId), @@ -208,6 +217,13 @@ export async function upsertWorkflowWorkItem( sourceColumn: input.sourceColumn === undefined ? existing?.sourceColumn ?? null : input.sourceColumn, targetColumn: input.targetColumn === undefined ? existing?.targetColumn ?? null : input.targetColumn, irHash: input.irHash === undefined ? existing?.irHash ?? null : input.irHash, + // FNXC:WorkflowAgentRouting 2026-08-07-03:25: + // Preserve a claimed principal on resume; a new route can only be + // fenced by explicitly providing these fields before session creation. + principalAgentId: input.principalAgentId === undefined ? existing?.principalAgentId ?? null : input.principalAgentId, + workflowRole: input.workflowRole === undefined ? existing?.workflowRole ?? null : input.workflowRole, + authorityKind: input.authorityKind === undefined ? existing?.authorityKind ?? null : input.authorityKind, + nodeInstanceId: input.nodeInstanceId === undefined ? existing?.nodeInstanceId ?? null : input.nodeInstanceId, createdAt: existing?.createdAt ?? now, updatedAt: now, }) @@ -236,11 +252,15 @@ export async function upsertWorkflowWorkItem( sourceColumn: input.sourceColumn === undefined ? existing?.sourceColumn ?? null : input.sourceColumn, targetColumn: input.targetColumn === undefined ? existing?.targetColumn ?? null : input.targetColumn, irHash: input.irHash === undefined ? existing?.irHash ?? null : input.irHash, + principalAgentId: input.principalAgentId === undefined ? existing?.principalAgentId ?? null : input.principalAgentId, + workflowRole: input.workflowRole === undefined ? existing?.workflowRole ?? null : input.workflowRole, + authorityKind: input.authorityKind === undefined ? existing?.authorityKind ?? null : input.authorityKind, + nodeInstanceId: input.nodeInstanceId === undefined ? existing?.nodeInstanceId ?? null : input.nodeInstanceId, updatedAt: now, }, }); - const row = await getWorkflowWorkItem(tx, id); + const row = await getWorkflowWorkItem(tx, id, layer.projectId); if (!row) throw new Error(`Failed to upsert workflow work item ${id}`); // Run-audit event inside the same transaction (commits/rolls back together). @@ -357,7 +377,10 @@ export async function transitionWorkflowWorkItem( const existingRows = await tx .select() .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.id, id)) + .where(and( + eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), + )) .limit(1); const existing = existingRows[0] as WorkflowWorkItemRow | undefined; if (!existing) throw new Error(`Workflow work item ${id} not found`); @@ -392,14 +415,24 @@ export async function transitionWorkflowWorkItem( patch.leaseExpiresAt === undefined ? existing.leaseExpiresAt : patch.leaseExpiresAt, lastError: patch.lastError === undefined ? existing.lastError : patch.lastError, blockedReason: patch.blockedReason === undefined ? existing.blockedReason : patch.blockedReason, + principalAgentId: patch.principalAgentId === undefined ? existing.principalAgentId : patch.principalAgentId, + workflowRole: patch.workflowRole === undefined ? existing.workflowRole : patch.workflowRole, + authorityKind: patch.authorityKind === undefined ? existing.authorityKind : patch.authorityKind, + nodeInstanceId: patch.nodeInstanceId === undefined ? existing.nodeInstanceId : patch.nodeInstanceId, updatedAt: now, }) - .where(eq(schema.project.workflowWorkItems.id, id)); + .where(and( + eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), + )); const updatedRows = await tx .select() .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.id, id)) + .where(and( + eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), + )) .limit(1); const updated = updatedRows[0] as WorkflowWorkItemRow | undefined; if (!updated) throw new Error(`Workflow work item ${id} disappeared`); @@ -428,7 +461,10 @@ export async function transitionWorkflowWorkItem( const serialized = async (tx: DbTransaction): Promise => { const owner = await tx.select({ taskId: schema.project.workflowWorkItems.taskId }) .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.id, id)) + .where(and( + eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), + )) .limit(1); const taskId = owner[0]?.taskId; if (!taskId) return doWork(tx); @@ -450,6 +486,7 @@ export async function listDueWorkflowWorkItems( ): Promise { const now = filter.now ?? new Date().toISOString(); const conditions = [ + projectScopeFor(schema.project.workflowWorkItems.projectId, filter.projectId), // retryAfter is null OR retryAfter <= now. or( sql`${schema.project.workflowWorkItems.retryAfter} IS NULL`, diff --git a/packages/core/src/task-store/row-types.ts b/packages/core/src/task-store/row-types.ts index 5d94f2a4a5..05d787cd69 100644 --- a/packages/core/src/task-store/row-types.ts +++ b/packages/core/src/task-store/row-types.ts @@ -195,6 +195,10 @@ export interface WorkflowWorkItemRow { sourceColumn: string | null; targetColumn: string | null; irHash: string | null; + principalAgentId: string | null; + workflowRole: string | null; + authorityKind: string | null; + nodeInstanceId: string | null; createdAt: string; updatedAt: string; } diff --git a/packages/core/src/task-store/settings-helpers.ts b/packages/core/src/task-store/settings-helpers.ts index bad1d2bbab..98e6e39950 100644 --- a/packages/core/src/task-store/settings-helpers.ts +++ b/packages/core/src/task-store/settings-helpers.ts @@ -14,10 +14,18 @@ import { validateWorktrunkSettings } from "../config/worktrunk-settings.js"; * and rewriting legacy path values left over from the kb → fn rename. */ export function canonicalizeSettings(settings: Settings): Settings { - // Strip legacy globalMaxConcurrent from project settings - this field was - // deprecated in favor of the global-level maxConcurrent in concurrency settings. - const { globalMaxConcurrent, ...rest } = settings as Settings & { globalMaxConcurrent?: number }; - const base = globalMaxConcurrent !== undefined ? (rest as Settings) : settings; + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:08: + FN-8764 retires the ephemeral workflow-stage switch. Existing settings and stale clients + may still send it, but it must be discarded rather than influence durable principal routing. + */ + const { globalMaxConcurrent, ephemeralAgentsEnabled: _retiredEphemeralAgentsEnabled, ...rest } = settings as Settings & { + globalMaxConcurrent?: number; + ephemeralAgentsEnabled?: boolean; + }; + const base = globalMaxConcurrent !== undefined || _retiredEphemeralAgentsEnabled !== undefined + ? (rest as Settings) + : settings; const canonicalWorktrunk = (() => { try { diff --git a/packages/core/src/task-store/settings-ops-2.ts b/packages/core/src/task-store/settings-ops-2.ts index 630dca42e9..fcb9ac0cc1 100644 --- a/packages/core/src/task-store/settings-ops-2.ts +++ b/packages/core/src/task-store/settings-ops-2.ts @@ -9,7 +9,6 @@ import {TaskStore} from "../store.js"; import type {Settings, GlobalSettings, ProjectSettings} from "../types.js"; import {DEFAULT_SETTINGS, isGlobalOnlySettingsKey} from "../types.js"; -import {DEFAULT_PROJECT_SETTINGS} from "../config/settings-schema.js"; import "../builtin-traits.js"; import {resolveWorktrunkSettings} from "../config/worktrunk-settings.js"; import {hasSyncPassphraseConfigured} from "../secrets/secrets-sync-passphrase.js"; @@ -117,11 +116,7 @@ export async function getSettingsByScopeImpl(store: TaskStore): Promise<{ global } } } - const canonicalizedProject = canonicalizeSettings(projectSettings as Settings); - if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { - canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; - } - return { global, project: canonicalizedProject }; + return { global, project: canonicalizeSettings(projectSettings as Settings) }; } export async function getSettingsByScopeFastImpl(store: TaskStore): Promise<{ global: GlobalSettings; project: Partial }> { @@ -152,10 +147,6 @@ export async function getSettingsByScopeFastImpl(store: TaskStore): Promise<{ gl } } } - const canonicalizedProject = canonicalizeSettings(projectScoped as Settings); - if (canonicalizedProject.ephemeralAgentsEnabled === undefined) { - canonicalizedProject.ephemeralAgentsEnabled = DEFAULT_PROJECT_SETTINGS.ephemeralAgentsEnabled; - } - return { global, project: canonicalizedProject }; + return { global, project: canonicalizeSettings(projectScoped as Settings) }; } diff --git a/packages/core/src/task-store/settings-ops.ts b/packages/core/src/task-store/settings-ops.ts index aa54b4338c..21d7af6229 100644 --- a/packages/core/src/task-store/settings-ops.ts +++ b/packages/core/src/task-store/settings-ops.ts @@ -15,7 +15,7 @@ import {validateLocale, assertWorktreeNamingRecycleExclusive} from "../config/se import {hasSyncPassphraseConfigured} from "../secrets/secrets-sync-passphrase.js"; import {ensureMemoryFileWithBackend} from "../memory/project-memory.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; -import {isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js"; +import {canonicalizeSettings, isPlainObject, deepMergeWithNullDelete} from "../task-store/settings-helpers.js"; import {readProjectConfig as readProjectConfigAsync, writeProjectConfig as writeProjectConfigAsync} from "../task-store/async/async-settings.js"; import {appendConfigurationRevision, createConfigurationRevision} from "../async-stores/async-configuration-revision-store.js"; import {isValidProviderInstanceId} from "../provider-instance.js"; @@ -88,10 +88,16 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial) as Partial; })() : patch; + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:08: + The removed ephemeral-stage setting remains accepted at this boundary for stale clients, + then is stripped before the atomic settings snapshot and revision are persisted. + */ + const { ephemeralAgentsEnabled: _retiredEphemeralAgentsEnabled, ...workflowPrincipalPatch } = guardedPatch; // Filter out global-only fields — they should go through updateGlobalSettings() const projectPatch: Partial = {}; - for (const [key, value] of Object.entries(guardedPatch)) { + for (const [key, value] of Object.entries(workflowPrincipalPatch)) { if (!isGlobalOnlySettingsKey(key)) { (projectPatch as Record)[key] = value; } @@ -154,8 +160,8 @@ export async function updateSettingsImpl(store: TaskStore, patch: Partial { const layer = store.asyncLayer!; @@ -434,15 +435,16 @@ export async function listWorkflowPromptOverridesForProjectImpl(store: TaskStore export async function listWorkflowWorkItemsForTaskImpl(store: TaskStore, taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): Promise { // No dedicated async helper; use a raw Drizzle query in backend mode. const layer = store.asyncLayer!; + const scope = projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId); const q = layer.db .select() .from(schema.project.workflowWorkItems) - .where(eq(schema.project.workflowWorkItems.taskId, taskId)); + .where(and(scope, eq(schema.project.workflowWorkItems.taskId, taskId))); const rows = opts.kinds?.length ? await layer.db .select() .from(schema.project.workflowWorkItems) - .where(and(eq(schema.project.workflowWorkItems.taskId, taskId), inArray(schema.project.workflowWorkItems.kind, opts.kinds))) + .where(and(scope, eq(schema.project.workflowWorkItems.taskId, taskId), inArray(schema.project.workflowWorkItems.kind, opts.kinds))) : await q; return (rows as WorkflowWorkItemRow[]).map((row) => store.rowToWorkflowWorkItem(row)); } diff --git a/packages/core/src/task-store/workflow-workitems-ops-2.ts b/packages/core/src/task-store/workflow-workitems-ops-2.ts index 66bc4a8f5e..4d79d84f6c 100644 --- a/packages/core/src/task-store/workflow-workitems-ops-2.ts +++ b/packages/core/src/task-store/workflow-workitems-ops-2.ts @@ -8,12 +8,12 @@ */ import {TaskStore} from "../store.js"; import * as schema from "../postgres/schema/index.js"; -import {and, eq, inArray, isNull, lt, or} from "drizzle-orm"; +import {and, eq, inArray, isNull, like, lt, or} from "drizzle-orm"; import type {WorkflowWorkItem, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput} from "../types.js"; import "../builtin-traits.js"; import {__setTaskActivityLogLimitsForTesting} from "../task-store/comments.js"; import {replaceActiveTaskWorkflowContinuation as replaceActiveTaskWorkflowContinuationAsync, seedStrandedPlanReviewContinuation as seedStrandedPlanReviewContinuationAsync, upsertWorkflowWorkItem as upsertWorkflowWorkItemAsync, transitionWorkflowWorkItem as transitionWorkflowWorkItemAsync, getWorkflowWorkItem as getWorkflowWorkItemAsync, withTaskWorkflowSerialization} from "../task-store/async/async-workflow-workitems.js"; -import type {DbTransaction} from "../postgres/data-layer.js"; +import { projectScopeFor, type DbTransaction } from "../postgres/data-layer.js"; export async function upsertWorkflowWorkItemImpl(store: TaskStore, input: WorkflowWorkItemUpsertInput, tx?: DbTransaction): Promise { return upsertWorkflowWorkItemAsync(store.asyncLayer!, input, tx); @@ -59,20 +59,33 @@ export async function acquireWorkflowWorkItemLeaseImpl(store: TaskStore, id: str conditional repair's idle check and insert. */ const updated = await layer.transactionImmediate(async (tx) => { - const owner = await getWorkflowWorkItemAsync(tx, id); + const owner = await getWorkflowWorkItemAsync(tx, id, layer.projectId); if (!owner) return null; return withTaskWorkflowSerialization(tx, layer.projectId, owner.taskId, async () => { /* - FNXC:SqliteDualPathCleanup 2026-07-26-15:00: - Atomic lease claim: only transition runnable/retrying, or running rows whose lease has already expired — never steal a live lease. + FNXC:WorkflowAgentRouting 2026-08-07-07:02: + Availability holds must be claimable after an operator restores the named + principal or pool capacity. Only workflow-principal holds re-enter the + scheduler; generic/manual holds remain inert until their own lifecycle + releases them. Every claim predicate carries project_id because work-item + IDs are only unique within a project. */ await tx .update(schema.project.workflowWorkItems) .set({ state: "running", leaseOwner, leaseExpiresAt, updatedAt: now }) .where(and( eq(schema.project.workflowWorkItems.id, id), + projectScopeFor(schema.project.workflowWorkItems.projectId, layer.projectId), or( inArray(schema.project.workflowWorkItems.state, ["runnable", "retrying"]), + and( + eq(schema.project.workflowWorkItems.state, "held"), + or( + like(schema.project.workflowWorkItems.blockedReason, "workflow-principal-%"), + like(schema.project.workflowWorkItems.blockedReason, "workflow-named-principal-%"), + like(schema.project.workflowWorkItems.blockedReason, "workflow-role-pool-%"), + ), + ), and( eq(schema.project.workflowWorkItems.state, "running"), or( @@ -82,7 +95,7 @@ export async function acquireWorkflowWorkItemLeaseImpl(store: TaskStore, id: str ), ), )); - const claimed = await getWorkflowWorkItemAsync(tx, id); + const claimed = await getWorkflowWorkItemAsync(tx, id, layer.projectId); return claimed?.leaseOwner === leaseOwner ? claimed : null; }); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 646dd0c23a..3877c65773 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1201,6 +1201,7 @@ import { isApprovalRequestExpired, isValidApprovalRequestTransition, normalizeApprovalRequestActionCategory, + normalizeAgentRoles, } from "./types/agents/agents.js"; export { AGENT_PERMISSIONS, @@ -1231,6 +1232,7 @@ export { isApprovalRequestExpired, isValidApprovalRequestTransition, normalizeApprovalRequestActionCategory, + normalizeAgentRoles, }; import type { Agent, diff --git a/packages/core/src/types/agents/agents.ts b/packages/core/src/types/agents/agents.ts index d11e5a5618..b861bba4bd 100644 --- a/packages/core/src/types/agents/agents.ts +++ b/packages/core/src/types/agents/agents.ts @@ -8,6 +8,30 @@ import type { AgentState } from "./agent-state.js"; export type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "engineer" | "custom"; +/* +FNXC:WorkflowAgentRouting 2026-08-07-03:12: +FN-8764 requires durable permanent agents to carry normalized multi-role tags. +The deprecated singular value remains only at migration/API boundaries and must +always be derived from this stable canonical order. +*/ +/** Stable serialized order for normalized permanent-agent role tags. */ +export const AGENT_CAPABILITIES: readonly AgentCapability[] = [ + "triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom", +] as const; + +/** Normalize role tags at every legacy/API boundary; unknown tags never persist. */ +export function normalizeAgentRoles( + roles: readonly string[] | undefined, + legacyRole?: string, +): AgentCapability[] { + const values = roles?.length ? roles : legacyRole ? [legacyRole] : []; + const unique = new Set(values); + const normalized = AGENT_CAPABILITIES.filter((role) => unique.has(role)); + if (normalized.length !== unique.size) throw new Error("Agent roles contain an unknown capability"); + if (normalized.length === 0) throw new Error("Agent requires at least one role"); + return normalized; +} + /** Single heartbeat event recorded for an agent */ export interface AgentHeartbeatEvent { @@ -581,7 +605,12 @@ export interface Agent { id: string; /** Display name */ name: string; - /** Role/capability of the agent */ + /** Canonical normalized role tags. */ + roles: AgentCapability[]; + /** + * @deprecated Migration/API compatibility projection. New routing must use + * `roles`; this is always the first normalized role. + */ role: AgentCapability; /** Current lifecycle state */ state: AgentState; @@ -603,7 +632,7 @@ export interface Agent { imageUrl?: string; /** Agent ID this agent reports to (org hierarchy) */ reportsTo?: string; - /** Runtime configuration. Supports: AgentHeartbeatConfig keys (heartbeatIntervalMs, heartbeatTimeoutMs, maxConcurrentRuns) */ + /** Runtime configuration. Supports heartbeat and independent workflow-session limits. */ runtimeConfig?: Record; /** Why the agent was paused (error, manual, etc.) */ pauseReason?: string; @@ -672,6 +701,8 @@ export interface AgentHeartbeatConfig { heartbeatTimeoutMs?: number; /** Max concurrent heartbeat runs per agent (default: 1). Min: 1 */ maxConcurrentRuns?: number; + /** Optional cap for workflow sessions; independent from heartbeat runs. */ + maxWorkflowSessions?: number; /** Whether periodic self-improvement is enabled (default: true) */ selfImproveEnabled?: boolean; /** Interval between self-improvement cycles in ms (default: 14400000 = 4h). Min: 3600000 (1h) */ @@ -777,7 +808,10 @@ export interface AgentDetail extends Agent { /** Input for creating a new agent */ export interface AgentCreateInput { name: string; - role: AgentCapability; + /** Canonical multi-tag input. */ + roles?: AgentCapability[]; + /** @deprecated compatibility input; normalized into `roles` once. */ + role?: AgentCapability; metadata?: Record; title?: string; icon?: string; @@ -797,6 +831,8 @@ export interface AgentCreateInput { /** Input for updating an existing agent */ export interface AgentUpdateInput { name?: string; + roles?: AgentCapability[]; + /** @deprecated compatibility input; normalized into `roles` once. */ role?: AgentCapability; metadata?: Record; title?: string; @@ -897,6 +933,8 @@ export interface AgentRatingInput { * Excludes budget-related items, state, taskId, token counts, and timestamps. */ export interface AgentConfigSnapshot { name: string; + roles: AgentCapability[]; + /** @deprecated compatibility projection; derived from roles. */ role: AgentCapability; title?: string; icon?: string; @@ -1063,6 +1101,7 @@ export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: st export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot { return { name: agent.name, + roles: [...agent.roles], role: agent.role, title: agent.title, icon: agent.icon, diff --git a/packages/core/src/types/merge/merge-queue.ts b/packages/core/src/types/merge/merge-queue.ts index 97e6ab4e42..0c9b77f037 100644 --- a/packages/core/src/types/merge/merge-queue.ts +++ b/packages/core/src/types/merge/merge-queue.ts @@ -71,6 +71,11 @@ export interface WorkflowWorkItem { sourceColumn: string | null; targetColumn: string | null; irHash: string | null; + /** Fenced durable principal for this claimed workflow attempt. */ + principalAgentId: string | null; + workflowRole: "triage" | "executor" | "reviewer" | "merger" | null; + authorityKind: "task-assignee" | "review-node-override" | "column-binding" | "role-pool" | null; + nodeInstanceId: string | null; createdAt: string; updatedAt: string; } @@ -94,6 +99,11 @@ export interface WorkflowWorkItemUpsertInput { sourceColumn?: string | null; targetColumn?: string | null; irHash?: string | null; + /** Set once when a classified node is claimed; retries retain the fence. */ + principalAgentId?: string | null; + workflowRole?: "triage" | "executor" | "reviewer" | "merger" | null; + authorityKind?: "task-assignee" | "review-node-override" | "column-binding" | "role-pool" | null; + nodeInstanceId?: string | null; now?: string; } @@ -104,6 +114,11 @@ export interface WorkflowWorkItemTransitionPatch { leaseExpiresAt?: string | null; lastError?: string | null; blockedReason?: string | null; + /** Principal fencing may be set only before the session handler starts. */ + principalAgentId?: string | null; + workflowRole?: "triage" | "executor" | "reviewer" | "merger" | null; + authorityKind?: "task-assignee" | "review-node-override" | "column-binding" | "role-pool" | null; + nodeInstanceId?: string | null; now?: string; /* FNXC:WorkflowWorkItemCas 2026-07-27-22:10 (U7, PR #2491 review — greptile P1): @@ -122,6 +137,8 @@ export interface WorkflowWorkItemTransitionPatch { } export interface WorkflowWorkItemDueFilter { + /** Required by project-bound callers so due work cannot cross a tenant boundary. */ + projectId?: string; now?: string; limit?: number; kinds?: WorkflowWorkItemKind[]; diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index 28f6235cc7..78d9cc03bb 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -1745,11 +1745,10 @@ export interface ProjectSettings { * FN-7557: default is now "auto-approve-all" (previously deferred to workflow via "workflow"). Unset/new projects bypass the manual awaiting-approval gate by default; projects with an explicit stored value are unaffected. */ planApprovalMode?: "workflow" | "auto-approve-all" | "require-all"; - /** Controls task-worker execution mode. - * - true (default): spawn short-lived `executor-FN-XXXX` ephemeral workers per task - * - false: disable ephemeral workers; scheduler auto-assigns dispatchable tasks - * to permanent executor agents using the reporting chain heuristic. - * Tasks without an eligible permanent executor remain queued. */ + /** + * @deprecated FN-8764 accepts this legacy input only so upgrades can discard it. + * Workflow stages always route through durable multi-role principals; this flag has no effect. + */ ephemeralAgentsEnabled?: boolean; /* FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: diff --git a/packages/core/src/workflows/workflow-ir-types.ts b/packages/core/src/workflows/workflow-ir-types.ts index 489dd951fc..dc3c96b09b 100644 --- a/packages/core/src/workflows/workflow-ir-types.ts +++ b/packages/core/src/workflows/workflow-ir-types.ts @@ -49,6 +49,21 @@ export type WorkflowIrNodeKind = import type { WorkflowReviewKind } from "../types/workflow/workflow-steps.js"; +/** Roles that may launch a durable workflow principal. */ +export type WorkflowAgentRole = "triage" | "executor" | "reviewer" | "merger"; + +/* + * FNXC:WorkflowAgentRouting 2026-08-07-08:30: + * Workflow principal roles are authority labels, not lifecycle columns. Keep + * role validation centralized so routing does not duplicate column-like string + * comparisons that could be mistaken for board transitions. + */ +export const WORKFLOW_AGENT_ROLES = ["triage", "executor", "reviewer", "merger"] as const satisfies readonly WorkflowAgentRole[]; + +export function isWorkflowAgentRole(value: unknown): value is WorkflowAgentRole { + return typeof value === "string" && (WORKFLOW_AGENT_ROLES as readonly string[]).includes(value); +} + export interface WorkflowIrNode { id: string; kind: WorkflowIrNodeKind; @@ -58,6 +73,8 @@ export interface WorkflowIrNode { extensions?: Record>; /** Open node config; supported top-level review producers may set `reviewKind`. */ config?: Record; + /** Exact reviewer-session override, durable only on classifier-approved nodes. */ + reviewerAgentId?: string; } /** Default bounded-rework budget when a rework region omits `maxReworkCycles` @@ -470,3 +487,44 @@ export interface WorkflowIrV2 { /** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2; + + +/** + * FNXC:WorkflowAgentRouting 2026-08-07-03:12: + * Principal acquisition follows the production session seam, never a board + * column or container kind. This classifier is shared by IR validation, routing, + * and editor affordances so a reviewer override cannot leak onto lifecycle work. + */ +export function classifyWorkflowAgentNode(node: WorkflowIrNode): WorkflowAgentRole | undefined { + if (node.kind === "step-review") return "reviewer"; + if (node.kind !== "prompt" && node.kind !== "script") return undefined; + switch (node.config?.seam) { + /* + * FNXC:WorkflowAgentRouting 2026-08-07-05:52: + * Planning, independent review, and promotion seams each launch work under + * their permanent stage owner. Classify those production seams here rather + * than treating their handoff worker as an unowned lifecycle operation. + * Only pure handoff/scheduling seams remain principal-free. + */ + case "planning": return "triage"; + case "execute": + case "step-execute": return "executor"; + case "review": return "reviewer"; + case "merge": return "merger"; + case "review-handoff": + case "schedule": return undefined; + default: { + const explicit = node.config?.workflowRole; + return isWorkflowAgentRole(explicit) ? explicit : undefined; + } + } +} + +/** Whether this node is owned by the requested permanent workflow role. */ +export function isWorkflowAgentNodeForRole(node: WorkflowIrNode, role: WorkflowAgentRole): boolean { + return classifyWorkflowAgentNode(node) === role; +} + +export function isWorkflowReviewerNode(node: WorkflowIrNode): boolean { + return classifyWorkflowAgentNode(node) === "reviewer"; +} diff --git a/packages/core/src/workflows/workflow-ir.ts b/packages/core/src/workflows/workflow-ir.ts index 434dff99e6..5aa6457ad9 100644 --- a/packages/core/src/workflows/workflow-ir.ts +++ b/packages/core/src/workflows/workflow-ir.ts @@ -15,6 +15,7 @@ import type { WorkflowSettingDefinition, WorkflowSettingType, } from "./workflow-ir-types.js"; +import { classifyWorkflowAgentNode } from "./workflow-ir-types.js"; import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js"; import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js"; import { THINKING_LEVELS } from "../types.js"; @@ -960,6 +961,23 @@ function validateCredentialInstanceIdConfig(nodes: WorkflowIrNode[]): void { } } + +/** Validate reviewer overrides recursively against the shared session classifier. */ +function validateReviewerAgentOverrides(nodes: WorkflowIrNode[]): void { + for (const node of nodes) { + if (node.reviewerAgentId !== undefined) { + if (typeof node.reviewerAgentId !== "string" || !node.reviewerAgentId.trim()) { + throw new WorkflowIrError(`Workflow node '${node.id}' reviewerAgentId must be a non-empty string`); + } + if (classifyWorkflowAgentNode(node) !== "reviewer") { + throw new WorkflowIrError(`Workflow node '${node.id}' reviewerAgentId is only legal on reviewer-session nodes`); + } + } + const templateNodes = (node.config as { template?: { nodes?: unknown } } | undefined)?.template?.nodes; + if (Array.isArray(templateNodes)) validateReviewerAgentOverrides(templateNodes as WorkflowIrNode[]); + } +} + function validateThinkingLevelConfig(nodes: WorkflowIrNode[]): void { for (const node of nodes) { const value = node.config?.thinkingLevel; @@ -1743,6 +1761,7 @@ function validateV2(ir: WorkflowIrV2): void { validateStepExecutePlacement(ir.nodes); validateThinkingLevelConfig(ir.nodes); validateCredentialInstanceIdConfig(ir.nodes); + validateReviewerAgentOverrides(ir.nodes); for (const node of ir.nodes) { if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds); diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 3efe2bb458..975bc4dee2 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -1253,6 +1253,20 @@ Agent Settings composes native inputs, buttons, editors, and portaled controls. font-weight: 500; } +.config-role-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin: 0; + padding: var(--space-sm); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-md); +} + +.config-role-tags legend { + padding-inline: var(--space-xs); +} + .agent-avatar-editor { display: flex; align-items: center; diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 56da9a8c2b..cca7109600 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -1526,7 +1526,7 @@ function DashboardTab({ {t("agents.pendingApprovalsCount", "{{count}} pending approvals", { count: agent.pendingApprovalCount })} ) : null} - {t("agents.roleLabel", "Role: {{role}}", { role: agent.role })} + {t("agents.roleLabel", "Roles: {{role}}", { role: (agent.roles ?? [agent.role]).join(", ") })} {runtimeHint ? t("agents.runtime", "Runtime") : t("agents.model", "Model")} {modelDisplay ?? t("agents.auto", "Auto")} @@ -4045,6 +4045,7 @@ function ConfigTab({ // Identity field state const [nameValue, setNameValue] = useState(agent.name); const [roleValue, setRoleValue] = useState(agent.role); + const [additionalRoleValues, setAdditionalRoleValues] = useState(() => (agent.roles ?? [agent.role]).filter((role) => role !== agent.role)); const [titleValue, setTitleValue] = useState(agent.title ?? ""); const [iconValue, setIconValue] = useState(agent.icon ?? ""); const [reportsToValue, setReportsToValue] = useState(agent.reportsTo ?? ""); @@ -4754,7 +4755,7 @@ function ConfigTab({ return { name: nameValue.trim() || undefined, - role: roleValue, + roles: [roleValue, ...additionalRoleValues], title: titleValue.trim() || undefined, icon: iconValue.trim() || undefined, reportsTo: reportsToValue.trim() || undefined, @@ -4762,7 +4763,7 @@ function ConfigTab({ runtimeConfig: newRuntimeConfig, bundleConfig: newBundleConfig, }; - }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, assignmentPolicy, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); + }, [additionalRoleValues, agent.metadata, agent.runtimeConfig, allowParallelExecution, assignmentPolicy, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => { const payload = buildSavePayload(); @@ -4915,13 +4916,15 @@ function ConfigTab({
- + +
+ {t("agents.additionalRoles", "Additional workflow roles")} + {(["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"] as AgentCapability[]) + .filter((candidate) => candidate !== roleValue) + .map((candidate) => ( + + ))} +
diff --git a/packages/dashboard/app/components/NewAgentDialog.tsx b/packages/dashboard/app/components/NewAgentDialog.tsx index a96b09c360..d149b84d52 100644 --- a/packages/dashboard/app/components/NewAgentDialog.tsx +++ b/packages/dashboard/app/components/NewAgentDialog.tsx @@ -83,6 +83,7 @@ export function NewAgentDialog({ const [title, setTitle] = useState(""); const [icon, setIcon] = useState(""); const [role, setRole] = useState("custom"); + const [additionalRoles, setAdditionalRoles] = useState([]); const [reportsTo, setReportsTo] = useState(""); const [instructionsPath, setInstructionsPath] = useState(""); const [instructionsText, setInstructionsText] = useState(""); @@ -187,6 +188,7 @@ export function NewAgentDialog({ setTitle(spec.description); setIcon(spec.icon); setRole(mappedRole); + setAdditionalRoles([]); // Map generated systemPrompt to instructionsText setInstructionsText(spec.systemPrompt); setRuntimeConfig(c => ({ @@ -219,6 +221,7 @@ export function NewAgentDialog({ setIcon(draft.icon ?? ""); setTitle(draft.title ?? ""); setRole(draft.role); + setAdditionalRoles([]); setSoul(draft.soul ?? ""); setInstructionsText(draft.instructionsText ?? ""); // Advance to Step 1 so user can review model selection @@ -234,6 +237,7 @@ export function NewAgentDialog({ setTitle(values.title ?? ""); setIcon(values.icon ?? ""); setRole(values.role); + setAdditionalRoles([]); setReportsTo(values.reportsTo ?? ""); // FNXC:StandingInstructionsTemplate 2026-07-14-00:12: // Prefill/onboarding can set custom tab programmatically with empty instructionsText. @@ -283,6 +287,7 @@ export function NewAgentDialog({ setSelectedRuntimeId(""); setSelectedPresetId(null); setSelectedSkills([]); + setAdditionalRoles([]); setError(null); setIsGenerationModalOpen(false); setIsInterviewOpen(false); @@ -297,6 +302,7 @@ export function NewAgentDialog({ await createAgent(buildAgentCreatePayload({ name, role, + roles: [role, ...additionalRoles], title, icon, reportsTo, @@ -563,14 +569,17 @@ export function NewAgentDialog({ />
- +
{AGENT_ROLES.map(r => ( ))}
+
+ {t("agents.additionalRoles", "Additional workflow roles")} + {AGENT_ROLES.filter((candidate) => candidate.value !== role).map((candidate) => ( + + ))} +
diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 5a2d5c2302..0ca2395156 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -4434,6 +4434,25 @@ function InnerEditor({ ); })()} + {selectedNode.data.config?.seam === "review" && (() => { + const reviewerAgentId = typeof selectedNode.data.reviewerAgentId === "string" ? selectedNode.data.reviewerAgentId : ""; + const missingReviewer = reviewerAgentId && !agents.some((agent) => agent.id === reviewerAgentId); + return ( + + ); + })()} + {currentExecutor === "skill" && (() => { // The stored skillName may be namespaced (e.g. // "compound-engineering:ce-work") while the
-
- {/* FNXC:SettingsHelp 2026-07-16-12:45: Inline help moved behind the shared "?" affordance — operator requirement: no inline description paragraphs in Settings. The tip is a SIBLING of the checkbox label (a button inside a label breaks click-to-toggle). */} -
- - {t("settings.general.whenEnabledDefaultFusionSpawnsShortLived", " When enabled (default), Fusion spawns short-lived ")}executor-FN-XXXX{t("settings.general.agentsToRunEachTaskWhenDisabledOnly", " agents to run each task. When disabled, only permanent agents execute tasks and the scheduler auto-assigns work using the agent reporting chain. Tasks with no eligible permanent agent stay queued. ")} -
-
{/* FNXC:EphemeralAgentTaskCreation 2026-07-30-12:00: Operators choose free creation, an operator-mailbox proposal, or denial for ephemeral worker follow-ups. diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 5c1958e565..1f0f7c9298 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -262,7 +262,6 @@ const SETTING_DESCRIPTION_KEYS: Record = { completionDocumentationMode: "general.workflowsOrChangelogModeWhenContributorsShouldUpdate", reviewArtifacts: "general.reviewArtifactsHint", ephemeralAgentTaskCreationPolicy: "general.ephemeralAgentTaskCreationPolicyHint", - ephemeralAgentsEnabled: "general.whenEnabledDefaultFusionSpawnsShortLived", githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource", // FNXC:GitHubImportTranslate 2026-07-15-09:30: surfaced as plain rows in // GeneralSection beside the other import-scoped GitHub settings. diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index cb8978bf8b..b54087ea01 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -655,6 +655,7 @@ export function irToFlow(def: WorkflowDefinition): { ...dataIrKind(inner, innerKind), label: nodeLabel(inner), config: { ...(inner.config ?? {}) }, + ...(inner.reviewerAgentId ? { reviewerAgentId: inner.reviewerAgentId } : {}), ...(templateBoundary ? { templateBoundary } : {}), ...(optionalGroupBoundary ? { optionalGroupBoundary } : {}), }, @@ -695,6 +696,7 @@ export function irToFlow(def: WorkflowDefinition): { ...dataIrKind(node, kind), label: nodeLabel(node), config: { ...(node.config ?? {}) }, + ...(node.reviewerAgentId ? { reviewerAgentId: node.reviewerAgentId } : {}), column, }, deletable: node.kind !== "start" && node.kind !== "end", @@ -821,10 +823,14 @@ export function flowToIr( config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } }, }; } + const reviewerAgentId = typeof data.reviewerAgentId === "string" && data.reviewerAgentId.trim() + ? data.reviewerAgentId + : undefined; return { id: localId, kind: originalKind ?? (data.kind as WorkflowIrNode["kind"]), config: config && Object.keys(config).length ? config : undefined, + ...(reviewerAgentId ? { reviewerAgentId } : {}), }; } @@ -1506,6 +1512,7 @@ export function insertFragment( ...dataIrKind(inner, innerKind), label: nodeLabel(inner), config: { ...(inner.config ?? {}) }, + ...(inner.reviewerAgentId ? { reviewerAgentId: inner.reviewerAgentId } : {}), ...(templateBoundary ? { templateBoundary } : {}), ...(optionalGroupBoundary ? { optionalGroupBoundary } : {}), }, diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index bf61f1db06..7a69ba7ec0 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -18,13 +18,12 @@ import { TaskStore, createLogger, resolvePlanningSettingsModel, - AgentStore, THINKING_LEVELS, MissionResumeConflictError, TerminalTaskReconciliationError, } from "@fusion/core"; import type { Goal, Settings, ThinkingLevel } from "@fusion/core"; -import { listEligibleExecutorAgents, resolvePlanningThinkingLevel } from "@fusion/engine"; +import { resolvePlanningThinkingLevel } from "@fusion/engine"; import { getScopedStore as resolveScopedRequestStore, getProjectContext as resolveSharedProjectContext, @@ -3108,29 +3107,6 @@ export function createMissionRouter( throw badRequest("No pending slices found"); } - // Preflight: when ephemeral agents are disabled, mission tasks can only be - // run by a permanent executor agent. Catalog-imported "company" agents land - // with role "custom" and are never auto-assigned, so without an executor the - // mission's tasks silently queue forever with no error surfaced (issue #1261). - // Block the start with an actionable message instead of stalling invisibly. - // Mirrors the scheduler's dispatch gate (ephemeralAgentsEnabled===false + - // selectPermanentAgentForTask returns null → task queued); both go through - // listEligibleExecutorAgents so the preflight can't drift from dispatch. - const scopedStore = getScopedStore(); - const startSettings = await scopedStore.getSettings(); - if (startSettings.ephemeralAgentsEnabled === false) { - const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined }); - await agentStore.init(); - const executors = await listEligibleExecutorAgents(agentStore); - if (executors.length === 0) { - throw badRequest( - "Cannot start mission: ephemeral agents are disabled and no executor agent is available to run its tasks. " - + "Imported catalog (\"company\") agents have role \"custom\" and are not auto-assigned mission work. " - + "Assign at least one agent the \"executor\" role, or re-enable ephemeral agents in settings.", - ); - } - } - // Enable autopilot (and autoAdvance for backward compat) so the mission // will auto-advance slices when autopilot is watching await missionStore.updateMission(missionId, { diff --git a/packages/dashboard/src/routes/__tests__/agent-core-routes.test.ts b/packages/dashboard/src/routes/__tests__/agent-core-routes.test.ts index 8cf61e976b..dfb49c75b0 100644 --- a/packages/dashboard/src/routes/__tests__/agent-core-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/agent-core-routes.test.ts @@ -14,7 +14,16 @@ vi.mock("@fusion/core", async () => { async init() {} updateAgent = updateAgent; } - return { ...actual, AgentStore: MockAgentStore }; + return { + ...actual, + AgentStore: MockAgentStore, + normalizeAgentRoles: (roles: readonly string[] | undefined, role?: string) => { + const values = roles?.length ? roles : role ? [role] : []; + const order = ["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"]; + if (values.length === 0 || values.some((value) => !order.includes(value))) throw new Error("Agent roles contain an unknown capability"); + return order.filter((value) => values.includes(value)); + }, + }; }); function createStore(): TaskStore { @@ -43,6 +52,18 @@ function createStore(): TaskStore { } describe("agent core PATCH route", () => { + it("normalizes multi-role PATCH payloads before persisting them", async () => { + updateAgent.mockResolvedValue({ id: "agent-roles", roles: ["executor", "reviewer"] }); + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createStore())); + + const response = await request(app, "PATCH", "/api/agents/agent-roles", JSON.stringify({ roles: ["reviewer", "executor", "reviewer"] }), { "Content-Type": "application/json" }); + + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(updateAgent).toHaveBeenCalledWith("agent-roles", { roles: ["executor", "reviewer"] }); + }); + it("passes a complete heartbeat runtime config to the project-scoped agent store without lifecycle transitions", async () => { updateAgent.mockResolvedValue({ id: "agent-1" }); const store = createStore(); diff --git a/packages/dashboard/src/routes/register-agent-core-routes.ts b/packages/dashboard/src/routes/register-agent-core-routes.ts index 3d8520d063..39ef27653c 100644 --- a/packages/dashboard/src/routes/register-agent-core-routes.ts +++ b/packages/dashboard/src/routes/register-agent-core-routes.ts @@ -13,6 +13,7 @@ import { isAgentPermissionPolicyPresetId, isEphemeralAgent, normalizeAgentPermissionPolicy, + normalizeAgentRoles, } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRoutesContext } from "./types.js"; @@ -226,6 +227,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A const { name, role, + roles, metadata, title, icon, @@ -244,8 +246,17 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A if (!name || typeof name !== "string") { throw badRequest("name is required"); } - if (!role || typeof role !== "string") { - throw badRequest("role is required"); + if (role !== undefined && typeof role !== "string") { + throw badRequest("role must be a string"); + } + if (roles !== undefined && (!Array.isArray(roles) || roles.some((value) => typeof value !== "string"))) { + throw badRequest("roles must be an array of role tags"); + } + let normalizedRoles: AgentCapability[]; + try { + normalizedRoles = normalizeAgentRoles(roles as string[] | undefined, role); + } catch (err) { + throw badRequest(err instanceof Error ? err.message : String(err)); } if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) { throw badRequest("metadata must be an object"); @@ -315,7 +326,14 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A try { agent = await agentStore.createAgent({ name, - role: role as AgentCapability, + roles: normalizedRoles, + /* + FNXC:WorkflowAgentRouting 2026-08-07-07:56: + FN-8764 keeps singular input only for legacy clients. Canonical role + tags are normalized before persistence so public API writes cannot + create role-only permanent agents. + */ + ...(role !== undefined ? { role: role as AgentCapability } : {}), metadata, title: title ?? undefined, icon: icon ?? undefined, @@ -656,11 +674,27 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo updates.name = body.name ?? undefined; } + if ("roles" in body) { + if (!Array.isArray(body.roles) || body.roles.some((value: unknown) => typeof value !== "string")) { + throw badRequest("roles must be an array of role tags"); + } + try { + updates.roles = normalizeAgentRoles(body.roles as string[]); + } catch (err) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + } + if ("role" in body) { if (body.role !== null && typeof body.role !== "string") { throw badRequest("role must be a string"); } - updates.role = body.role ?? undefined; + /* + FNXC:WorkflowAgentRouting 2026-08-07-07:56: + Singular PATCH input retains documented replacement semantics only when + canonical roles are absent, preventing ambiguous dual-role writes. + */ + if (!("roles" in body)) updates.role = body.role ?? undefined; } if ("metadata" in body) { diff --git a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts index 1694cbc207..277eb1a223 100644 --- a/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts +++ b/packages/dashboard/src/routes/register-agent-import-export-generation-routes.ts @@ -5,7 +5,6 @@ import { join, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline as streamPipeline } from "node:stream/promises"; import { applyTestModeOverrides, resolvePlanningSettingsModel } from "@fusion/core"; -import { listEligibleExecutorAgents } from "@fusion/engine"; import { ApiError, badRequest, notFound, rateLimited } from "../api-error.js"; import { createSessionDiagnostics } from "../ai-session-diagnostics.js"; import { writeSSEEvent } from "../sse-buffer.js"; @@ -734,24 +733,6 @@ async function persistImportedSkills( throw badRequest("No agents or skills found in manifest"); } - // Warn when the imported agents can't actually be assigned mission/queue - // work: catalog ("company") agents land with role "custom", which is never - // auto-assigned. If none of the imported agents are executors and no - // executor already exists, missions run by these agents would stall or - // fail invisibly (issue #1261). Surface this up front, not after the fact. - const importWarnings: string[] = []; - const customRoleCount = importItems.filter((item) => item.input.role === "custom").length; - const importsAnExecutor = importItems.some((item) => item.input.role === "executor"); - if (customRoleCount > 0 && !importsAnExecutor) { - const existingExecutors = await listEligibleExecutorAgents(agentStore).catch(() => []); - if (existingExecutors.length === 0) { - importWarnings.push( - `${customRoleCount} imported agent(s) have role "custom" and won't be auto-assigned mission or queue work. ` - + `Assign at least one agent the "executor" role, or keep ephemeral agents enabled, before starting a mission.`, - ); - } - } - if (dryRun) { const agentPreview = importItems.map((item) => ({ name: item.input.name, @@ -786,7 +767,6 @@ async function persistImportedSkills( created: result.created, skipped: result.skipped, errors: result.errors, - ...(importWarnings.length > 0 ? { warnings: importWarnings } : {}), }); return; } @@ -855,7 +835,6 @@ async function persistImportedSkills( errors, skillsCount: (pkg.skills ?? []).length, skills: skillImportResult, - ...(importWarnings.length > 0 ? { warnings: importWarnings } : {}), }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index b28a8d430e..6d0336630d 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -4,6 +4,7 @@ import { computeApprovalDedupeKey, evaluateAgentActionGate, getExemptToolNames, + hasLiveWorkflowAuthority, reloadExemptTools, resolveGateOutcome, } from "../agents/agent-action-gate.js"; @@ -80,6 +81,38 @@ describe("agent-action-gate", () => { beforeEach(() => { reloadExemptTools(); }); + it("limits live workflow authority to its fenced task, run, and principal", async () => { + const context: any = { + agentId: "owner", taskId: "FN-1", runId: "run-1", + workflowAuthority: { + projectId: "project-a", taskId: "FN-1", runId: "run-1", workItemId: "work-1", + nodeInstanceId: "review-1", principalAgentId: "owner", kind: "task-assignee", + isLive: () => true, + }, + }; + await expect(hasLiveWorkflowAuthority(context, { id: "FN-1" })).resolves.toBe(true); + await expect(hasLiveWorkflowAuthority(context, { id: "FN-2" })).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority({ ...context, runId: "run-2" }, {})).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority({ ...context, agentId: "other" }, {})).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority({ ...context, workflowAuthority: { ...context.workflowAuthority, isLive: () => false } }, {})).resolves.toBe(false); + }); + + it("does not elevate board or agent mutations from a task-scoped workflow grant", async () => { + const context: any = { + agentId: "owner", taskId: "FN-1", runId: "run-1", + workflowAuthority: { + projectId: "project-a", taskId: "FN-1", runId: "run-1", workItemId: "work-1", + nodeInstanceId: "plan-1", principalAgentId: "owner", kind: "task-assignee", + isLive: () => true, + }, + }; + await expect(hasLiveWorkflowAuthority(context, {}, "fn_task_create")).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority(context, { id: "FN-1" }, "fn_task_update")).resolves.toBe(true); + await expect(hasLiveWorkflowAuthority(context, { id: "FN-2" }, "fn_task_update")).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority(context, {}, "fn_workflow_update")).resolves.toBe(false); + await expect(hasLiveWorkflowAuthority(context, {}, "fn_agent_create")).resolves.toBe(false); + }); + it("classifies write/edit as file_write_delete", () => { const write = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy }); const edit = evaluateAgentActionGate({ agentId: "a1", toolName: "edit", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy }); diff --git a/packages/engine/src/__tests__/agent-assignment.test.ts b/packages/engine/src/__tests__/agent-assignment.test.ts deleted file mode 100644 index 5fcc372615..0000000000 --- a/packages/engine/src/__tests__/agent-assignment.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import type { Agent, Task } from "@fusion/core"; -import { describe, expect, it } from "vitest"; -import { listEligibleExecutorAgents, selectPermanentAgentForTask } from "../agents/agent-assignment.js"; - -function makeAgent(overrides: Partial & Pick): Agent { - return { - name: overrides.name ?? overrides.id, - role: overrides.role ?? "executor", - state: overrides.state ?? "idle", - createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", - updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z", - metadata: overrides.metadata ?? {}, - ...overrides, - }; -} - -function makeTask(overrides: Partial & Pick): Task { - return { - title: overrides.title ?? overrides.id, - description: overrides.description ?? "", - column: overrides.column ?? "todo", - priority: overrides.priority ?? "normal", - dependencies: overrides.dependencies ?? [], - createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", - updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z", - log: overrides.log ?? [], - ...overrides, - } as Task; -} - -describe("selectPermanentAgentForTask", () => { - it("returns null when no eligible permanent executor exists", async () => { - const agent = makeAgent({ id: "ephemeral-1", metadata: { agentKind: "task-worker" } }); - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-1" }), - agentStore: { - listAgents: async () => [agent], - getChainOfCommand: async () => [], - } as never, - taskStore: { listTasks: async () => [] } as never, - }); - - expect(selected).toBeNull(); - }); - - it("filters out ephemeral, disabled, errored, and non-executor agents", async () => { - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-2" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "ephemeral", metadata: { agentKind: "task-worker" } }), - makeAgent({ id: "disabled", runtimeConfig: { enabled: false } }), - makeAgent({ id: "errored", state: "error" }), - makeAgent({ id: "reviewer", role: "reviewer" }), - makeAgent({ id: "ok", createdAt: "2026-01-01T00:00:01.000Z" }), - ], - getChainOfCommand: async () => [], - } as never, - taskStore: { listTasks: async () => [] } as never, - }); - - expect(selected?.id).toBe("ok"); - }); - - it("selects least-loaded agent", async () => { - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-3" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "b", createdAt: "2026-01-01T00:00:01.000Z" }), - ], - getChainOfCommand: async () => [], - } as never, - taskStore: { - listTasks: async () => [ - makeTask({ id: "T1", column: "in-progress", assignedAgentId: "a" }), - makeTask({ id: "T2", column: "todo", assignedAgentId: "a" }), - makeTask({ id: "T3", column: "in-review", assignedAgentId: "b" }), - makeTask({ id: "T4", column: "done", assignedAgentId: "b" }), - ], - } as never, - }); - - expect(selected?.id).toBe("b"); - }); - - it("uses createdAt then id for deterministic tie-break", async () => { - const selectedByCreatedAt = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-4" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "b", createdAt: "2026-01-02T00:00:00.000Z" }), - makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }), - ], - getChainOfCommand: async () => [], - } as never, - taskStore: { listTasks: async () => [] } as never, - }); - expect(selectedByCreatedAt?.id).toBe("a"); - - const selectedById = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-5" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "b", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }), - ], - getChainOfCommand: async () => [], - } as never, - taskStore: { listTasks: async () => [] } as never, - }); - expect(selectedById?.id).toBe("a"); - }); - - it("prefers agents in reporting chain of mission/slice-linked assignees", async () => { - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-6", missionId: "M-1", sliceId: "SL-1" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "agent-a", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "agent-b", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "agent-c", createdAt: "2026-01-01T00:00:00.000Z" }), - ], - getChainOfCommand: async (agentId: string) => (agentId === "agent-c" ? [makeAgent({ id: "agent-b" })] : []), - } as never, - taskStore: { - listTasks: async () => [ - makeTask({ id: "FN-linked", missionId: "M-1", sliceId: "SL-1", assignedAgentId: "agent-c", column: "todo" }), - makeTask({ id: "FN-other", missionId: "M-2", assignedAgentId: "agent-a", column: "todo" }), - ], - } as never, - }); - - expect(["agent-b", "agent-c"]).toContain(selected?.id); - expect(selected?.id).toBe("agent-b"); - }); -}); - -describe("listEligibleExecutorAgents", () => { - it("returns empty when only custom-role (catalog-imported) agents exist", async () => { - const eligible = await listEligibleExecutorAgents({ - listAgents: async () => [ - makeAgent({ id: "gstack-1", role: "custom" }), - makeAgent({ id: "gstack-2", role: "custom" }), - ], - } as never); - - expect(eligible).toEqual([]); - }); - - it("excludes ephemeral, disabled, and errored executors but keeps healthy ones", async () => { - const eligible = await listEligibleExecutorAgents({ - listAgents: async () => [ - makeAgent({ id: "ephemeral", metadata: { agentKind: "task-worker" } }), - makeAgent({ id: "disabled", runtimeConfig: { enabled: false } }), - makeAgent({ id: "errored", state: "error" }), - makeAgent({ id: "reviewer", role: "reviewer" }), - makeAgent({ id: "ok" }), - ], - } as never); - - expect(eligible.map((agent) => agent.id)).toEqual(["ok"]); - }); - - /* - FNXC:AgentRouting 2026-07-12-13:20: - Issue #2015 regression: an executor-ROLE liaison agent must be excludable from the scheduler's auto-assign - pool via runtimeConfig.assignmentPolicy — this pool was the routing path that bound NEXT-871 to the liaison. - */ - it("excludes executors with assignmentPolicy 'explicit-only' or 'none' from the auto-assign pool", async () => { - const eligible = await listEligibleExecutorAgents({ - listAgents: async () => [ - makeAgent({ id: "liaison-none", runtimeConfig: { assignmentPolicy: "none" } }), - makeAgent({ id: "explicit-only", runtimeConfig: { assignmentPolicy: "explicit-only" } }), - makeAgent({ id: "auto-explicitly", runtimeConfig: { assignmentPolicy: "auto" } }), - makeAgent({ id: "auto-default" }), - ], - } as never); - - expect(eligible.map((agent) => agent.id)).toEqual(["auto-explicitly", "auto-default"]); - }); - - it("never auto-assigns a task to a policy-excluded executor even when it is the only agent", async () => { - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "NEXT-871" }), - agentStore: { - listAgents: async () => [ - makeAgent({ id: "liaison", runtimeConfig: { assignmentPolicy: "none" } }), - ], - getChainOfCommand: async () => [], - } as never, - taskStore: { listTasks: async () => [] } as never, - }); - - expect(selected).toBeNull(); - }); -}); - -/* -FNXC:WorkflowLifecycleColumns 2026-07-31-05:40 (batch-engine feed): - -THE INVARIANT: assignment load counts the cards a board's OWN lanes call active. - -CENSUS-INVISIBLE. The gate was a `Set` literal — a definition, not a comparison — so no lifecycle -backlog entry ever pointed at this file. Found by grepping for lane-shaped list literals after the -same shape turned up in `duplicate-intake` and `blocker-fanout`. - -The failure is a silent DEGRADATION rather than an error, and it is invisible in exactly the way that -matters: on a renamed board no column matched, so `assignmentLoad` stayed empty, every candidate -compared as load 0, and the sort fell through to its stable `createdAt` tiebreak. The SAME agent then -wins every assignment while the rest sit idle. Nothing logs and nothing fails — the board simply -distributes badly, which reads as an agent being "busy" rather than as a bug. - -REVERT PROOF, measured: restore the hard-coded Set and the renamed case fails — the loaded agent is -picked instead of the idle one, because its load reads as 0. -*/ -describe("assignment load resolves the board's own active lanes", () => { - const agents = [ - makeAgent({ id: "AG-BUSY", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "AG-IDLE", createdAt: "2026-01-02T00:00:00.000Z" }), - ]; - - const store = (columnOfBusyWork: string) => ({ - listTasks: async () => [ - makeTask({ id: "FN-EXISTING", assignedAgentId: "AG-BUSY", column: columnOfBusyWork } as never), - ], - }) as never; - - const select = (columnOfBusyWork: string, activeColumns?: ReadonlySet) => - selectPermanentAgentForTask({ - task: makeTask({ id: "FN-NEW" }), - agentStore: { listAgents: async () => agents, getChainOfCommand: async () => [] } as never, - taskStore: store(columnOfBusyWork), - /* - #2787 review, third round: the option is now a PER-TASK predicate rather than a board-wide set, - because a project runs several workflows and a column id means something only relative to its - own. The tests keep expressing intent as a set and adapt it here. - */ - ...(activeColumns ? { countsAsAssignmentLoad: (t: { column: string }) => activeColumns.has(t.column) } : {}), - }); - - it("prefers the idle agent when the busy one's work sits in a RENAMED wip lane", async () => { - // Pre-fix: `building` matched no literal, AG-BUSY read as load 0, and its earlier createdAt won. - const selected = await select("building", new Set(["backlog", "building", "signoff"])); - - expect(selected?.id).toBe("AG-IDLE"); - }); - - it("keeps the legacy trio when no lanes are supplied", async () => { - const selected = await select("in-progress"); - - expect(selected?.id).toBe("AG-IDLE"); - }); - - it("counts work parked in a RENAMED hold lane, as the legacy set counted todo", async () => { - /* - #2787 review, second round (greptile P1). The legacy set is `{todo, in-progress, in-review}` and - `todo` is the HOLD lane, so a resolved set covering only wip and review DROPS assigned backlog - work from the tally — a regression against legacy introduced by the argument meant to fix the - renamed case. The resolved answer must cover every role the literal covered. - */ - const selected = await select("backlog", new Set(["backlog", "building", "signoff"])); - - expect(selected?.id).toBe("AG-IDLE"); - }); - - it("does not count work parked outside the supplied lanes", async () => { - // A finished card must not hold load against its agent, or the agent looks busy forever. - const selected = await select("shipped", new Set(["backlog", "building", "signoff"])); - - expect(selected?.id).toBe("AG-BUSY"); - }); -}); - -/* -FNXC:WorkflowLifecycleColumns 2026-07-31-11:40 (#2787 review — greptile P1, third round): - -THE INVARIANT: load is counted per task, against the task's OWN workflow. - -My first wiring resolved lanes from the CANDIDATE task's workflow and applied that flat set to every -assigned row. On a project running several workflows — the normal case, not an exotic one — -assignments in another workflow's load-bearing lanes vanished from the tally, and the -already-loaded-agent-wins bug returned through a different door. - -A column id means something only RELATIVE TO ITS OWN WORKFLOW. `blocker-fanout.ts` states this and -offers a per-task `classify`; the option is now the same shape rather than a third invention. - -REVERT PROOF, measured: answer the predicate from one workflow's lanes for every row (the flat-set -shape) and the cross-workflow case below picks the loaded agent. -*/ -/* -FNXC:WorkflowResolvedColumns 2026-07-30-12:25 (#2796 review — greptile): - -THE PREDICATE MUST SEE THE HELPER'S OWN ROWS, NOT A CALLER'S EARLIER SNAPSHOT. - -The scheduler built a Set of load-bearing task IDs from its own `listTasks` read, and this helper -then applied the predicate to rows from ITS read. Anything changing in between diverged in both -directions: a task MOVED out of a load-bearing lane kept its id in the set and still counted, while a -task created or newly assigned in between was missing and counted as zero. - -The fix memoises the resolved LANES per task and tests them against `candidate.column`, so the verdict -comes from the row the helper actually holds. That only works if the helper passes its own live rows -to the predicate — this pins that contract. If the helper ever pre-resolved or cached rows, the -scheduler's fix would silently go back to answering about a board that no longer exists. - -It is a contract test, not an end-to-end reproduction: the race lives in a dispatch path this suite -cannot stand up, and the existing `scheduler-load-lane-union` test says the same of its own call site. -*/ -describe("countsAsAssignmentLoad is called with the helper's own task rows", () => { - it("passes the live column, so a caller keyed on a stale snapshot cannot win", async () => { - const agents = [ - makeAgent({ id: "AG-A", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "AG-B", createdAt: "2026-01-02T00:00:00.000Z" }), - ]; - /* - The helper's snapshot: FN-MOVED has already left the load-bearing lane and sits in `shipped`. - A caller that decided "FN-MOVED bears load" from an earlier read must not be able to impose that. - */ - const taskStore = { - listTasks: async () => [ - makeTask({ id: "FN-MOVED", assignedAgentId: "AG-A", column: "shipped" } as never), - ], - } as never; - - const seen: Array<{ id: string; column: string }> = []; - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-NEW" }), - agentStore: { listAgents: async () => agents, getChainOfCommand: async () => [] } as never, - taskStore, - countsAsAssignmentLoad: (t: { id: string; column: string }) => { - seen.push({ id: t.id, column: t.column }); - /* The shape the scheduler now uses: resolved lanes for this task, tested against its LIVE column. */ - return new Set(["backlog", "building", "signoff"]).has(t.column); - }, - }); - - /* The predicate saw the helper's row, with the column as it is NOW. */ - expect(seen).toEqual([{ id: "FN-MOVED", column: "shipped" }]); - /* And therefore AG-A carries no load, so the older agent wins on the tiebreaker. */ - expect(selected?.id).toBe("AG-A"); - }); -}); - -describe("assignment load is counted per task, across workflows", () => { - it("counts an assignment held in ANOTHER workflow's wip lane", async () => { - const agents = [ - makeAgent({ id: "AG-BUSY", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "AG-IDLE", createdAt: "2026-01-02T00:00:00.000Z" }), - ]; - // The new card's board calls its wip lane `building`; the busy agent's existing work sits in a - // DIFFERENT workflow whose wip lane is `implementing`. - const taskStore = { - listTasks: async () => [ - makeTask({ id: "FN-OTHER-WF", assignedAgentId: "AG-BUSY", column: "implementing" } as never), - ], - } as never; - - const selected = await selectPermanentAgentForTask({ - task: makeTask({ id: "FN-NEW" }), - agentStore: { listAgents: async () => agents, getChainOfCommand: async () => [] } as never, - taskStore, - // Per-task: each row answered against its own workflow's lanes. - countsAsAssignmentLoad: (t: { column: string }) => - ["backlog", "building", "signoff"].includes(t.column) || ["queued", "implementing"].includes(t.column), - }); - - expect(selected?.id).toBe("AG-IDLE"); - }); -}); diff --git a/packages/engine/src/__tests__/ephemeral-worker-manager.test.ts b/packages/engine/src/__tests__/ephemeral-worker-manager.test.ts deleted file mode 100644 index 1bab0d0c44..0000000000 --- a/packages/engine/src/__tests__/ephemeral-worker-manager.test.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { EventEmitter } from "node:events"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core"; -import { EphemeralWorkerManager } from "../agents/ephemeral-worker-manager.js"; - -const BASE_TIME = "2026-07-03T00:00:00.000Z"; - -type AgentPatch = Partial & { metadata?: Record; runtimeConfig?: Record }; - -type FakeAgentStore = AgentStore & EventEmitter & { - agents: Map; - createAgent: ReturnType; - deleteAgent: ReturnType; - assignTask: ReturnType; - syncExecutionTaskLink: ReturnType; - updateAgentState: ReturnType; - findAgentByName: ReturnType; - listAgents: ReturnType; - getAgent: ReturnType; -}; - -type Harness = { - agentStore: FakeAgentStore; - taskStore: TaskStore & { tasks: Map; getTask: ReturnType }; - logger: { log: ReturnType; warn: ReturnType }; - externalPending: ReturnType; - getSettings: ReturnType; - manager: EphemeralWorkerManager; -}; - -function makeAgent(id: string, patch: AgentPatch = {}): Agent { - return { - id, - name: patch.name ?? id, - role: patch.role ?? "executor", - state: patch.state ?? "idle", - taskId: patch.taskId, - createdAt: patch.createdAt ?? BASE_TIME, - updatedAt: patch.updatedAt ?? BASE_TIME, - metadata: patch.metadata ?? {}, - runtimeConfig: patch.runtimeConfig, - } as Agent; -} - -function makeTask(id: string, patch: Partial = {}): Task { - return { - id, - title: id, - description: "test task", - column: "in-progress", - steps: [], - createdAt: BASE_TIME, - updatedAt: BASE_TIME, - ...patch, - } as Task; -} - -function createAgentStore(initialAgents: Agent[] = []): FakeAgentStore { - const emitter = new EventEmitter() as FakeAgentStore; - emitter.agents = new Map(initialAgents.map((agent) => [agent.id, structuredClone(agent)])); - - emitter.getAgent = vi.fn(async (agentId: string) => emitter.agents.get(agentId) ?? null); - emitter.listAgents = vi.fn(async () => Array.from(emitter.agents.values())); - emitter.findAgentByName = vi.fn(async (name: string) => Array.from(emitter.agents.values()).find((agent) => agent.name === name) ?? null); - emitter.createAgent = vi.fn(async (input: Partial) => { - const id = `agent-${emitter.agents.size + 1}`; - const agent = makeAgent(id, { - name: input.name ?? id, - role: input.role ?? "executor", - state: input.state ?? "idle", - metadata: input.metadata as Record | undefined, - runtimeConfig: input.runtimeConfig as Record | undefined, - }); - emitter.agents.set(agent.id, agent); - emitter.emit("agent:created", agent); - return agent; - }); - emitter.assignTask = vi.fn(async (agentId: string, taskId: string) => { - const agent = emitter.agents.get(agentId); - if (agent) { - agent.taskId = taskId; - agent.updatedAt = BASE_TIME; - emitter.emit("agent:assigned", agent, taskId); - } - return agent ?? null; - }); - emitter.syncExecutionTaskLink = vi.fn(async (agentId: string, taskId?: string) => { - const agent = emitter.agents.get(agentId); - if (agent) { - if (taskId) agent.taskId = taskId; - else delete (agent as { taskId?: string }).taskId; - agent.updatedAt = BASE_TIME; - } - return agent ?? null; - }); - emitter.updateAgentState = vi.fn(async (agentId: string, state: Agent["state"]) => { - const agent = emitter.agents.get(agentId); - if (!agent) throw new Error(`Agent ${agentId} not found`); - const from = agent.state; - agent.state = state; - agent.updatedAt = BASE_TIME; - emitter.emit("agent:stateChanged", agentId, from, state); - return agent; - }); - emitter.deleteAgent = vi.fn(async (agentId: string) => { - if (!emitter.agents.has(agentId)) throw new Error(`Agent ${agentId} not found`); - emitter.agents.delete(agentId); - emitter.emit("agent:deleted", agentId); - }); - return emitter; -} - -function createHarness(initialAgents: Agent[] = []): Harness { - const agentStore = createAgentStore(initialAgents); - const tasks = new Map(); - const taskStore = { - tasks, - getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null), - } as unknown as Harness["taskStore"]; - const logger = { log: vi.fn(), warn: vi.fn() }; - const externalPending = vi.fn(() => false); - const getSettings = vi.fn(async () => ({ ephemeralAgentsEnabled: true })); - const manager = new EphemeralWorkerManager({ - agentStore, - taskStore, - logger, - isDeletionPendingExternal: externalPending, - getSettings, - }); - return { agentStore, taskStore, logger, externalPending, getSettings, manager }; -} - -async function flushMicrotasks(turns = 6): Promise { - for (let i = 0; i < turns; i += 1) await Promise.resolve(); -} - -describe("EphemeralWorkerManager", () => { - let harness: Harness; - - beforeEach(() => { - vi.clearAllMocks(); - harness = createHarness(); - }); - - describe("task ownership", () => { - it("uses durable assigned agents without creating task workers", async () => { - const durable = makeAgent("durable-1", { name: "Durable", state: "idle", metadata: {} }); - harness.agentStore.agents.set(durable.id, durable); - - const owner = await harness.manager.onTaskStart(makeTask("FN-DURABLE", { assignedAgentId: durable.id })); - - expect(owner).toEqual({ agentId: durable.id, ephemeral: false }); - expect(harness.agentStore.syncExecutionTaskLink).toHaveBeenCalledWith(durable.id, "FN-DURABLE"); - expect(harness.agentStore.createAgent).not.toHaveBeenCalled(); - expect(harness.agentStore.updateAgentState).toHaveBeenNthCalledWith(1, durable.id, "active"); - expect(harness.agentStore.updateAgentState).toHaveBeenNthCalledWith(2, durable.id, "running"); - expect(harness.manager.getOwner("FN-DURABLE")).toEqual(owner); - }); - - it("creates, assigns, and runs an ephemeral worker for unassigned tasks", async () => { - const owner = await harness.manager.onTaskStart(makeTask("FN-EPHEMERAL")); - - expect(owner).toEqual({ agentId: "agent-1", ephemeral: true }); - expect(harness.agentStore.createAgent).toHaveBeenCalledWith(expect.objectContaining({ - name: "executor-FN-EPHEMERAL", - role: "executor", - metadata: expect.objectContaining({ agentKind: "task-worker", taskWorker: true }), - runtimeConfig: { enabled: false }, - })); - expect(harness.agentStore.assignTask).toHaveBeenCalledWith("agent-1", "FN-EPHEMERAL"); - expect(harness.agentStore.updateAgentState).toHaveBeenNthCalledWith(1, "agent-1", "active"); - expect(harness.agentStore.updateAgentState).toHaveBeenNthCalledWith(2, "agent-1", "running"); - }); - - it("reuses an existing cross-restart ephemeral worker for the same task", async () => { - const existing = makeAgent("worker-1", { - name: "executor-FN-REUSE", - taskId: "FN-REUSE", - metadata: { agentKind: "task-worker" }, - runtimeConfig: { enabled: false }, - }); - harness = createHarness([existing]); - - const owner = await harness.manager.onTaskStart(makeTask("FN-REUSE")); - - expect(owner).toEqual({ agentId: existing.id, ephemeral: true }); - expect(harness.agentStore.createAgent).not.toHaveBeenCalled(); - expect(harness.logger.log).toHaveBeenCalledWith(expect.stringContaining("Reusing existing ephemeral worker")); - }); - - it("deletes stale same-name workers before respawning", async () => { - const stale = makeAgent("worker-stale", { - name: "executor-FN-RESPAWN", - taskId: "FN-OLD", - metadata: { agentKind: "task-worker" }, - runtimeConfig: { enabled: false }, - }); - harness = createHarness([stale]); - - const owner = await harness.manager.onTaskStart(makeTask("FN-RESPAWN")); - - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith(stale.id); - expect(owner).toEqual({ agentId: "agent-1", ephemeral: true }); - expect(harness.agentStore.agents.has(stale.id)).toBe(false); - }); - - it("refuses to spawn when ephemeral agents are disabled", async () => { - harness.getSettings.mockResolvedValueOnce({ ephemeralAgentsEnabled: false }); - - const owner = await harness.manager.onTaskStart(makeTask("FN-DISABLED")); - - expect(owner).toBeNull(); - expect(harness.agentStore.createAgent).not.toHaveBeenCalled(); - expect(harness.logger.warn).toHaveBeenCalledWith(expect.stringContaining("ephemeralAgentsEnabled=false")); - }); - - it("falls back to task-worker ownership when assignedAgentId points to an ephemeral", async () => { - const ephemeral = makeAgent("child-1", { - name: "child", - metadata: { agentKind: "task-worker" }, - runtimeConfig: { enabled: false }, - }); - harness.agentStore.agents.set(ephemeral.id, ephemeral); - - const owner = await harness.manager.onTaskStart(makeTask("FN-ASSIGNED-EPHEMERAL", { assignedAgentId: ephemeral.id })); - - expect(owner).toEqual({ agentId: "agent-2", ephemeral: true }); - expect(harness.agentStore.syncExecutionTaskLink).not.toHaveBeenCalledWith(ephemeral.id, "FN-ASSIGNED-EPHEMERAL"); - expect(harness.agentStore.createAgent).toHaveBeenCalledWith(expect.objectContaining({ name: "executor-FN-ASSIGNED-EPHEMERAL" })); - }); - }); - - describe("completion and error cleanup", () => { - it("returns durable owners to active and does not delete them on completion or error", async () => { - const durable = makeAgent("durable-cleanup", { name: "Durable Cleanup", state: "active" }); - harness.agentStore.agents.set(durable.id, durable); - - await harness.manager.onTaskStart(makeTask("FN-DURABLE-COMPLETE", { assignedAgentId: durable.id })); - await harness.manager.onTaskComplete("FN-DURABLE-COMPLETE"); - expect(harness.agentStore.syncExecutionTaskLink).toHaveBeenLastCalledWith(durable.id, undefined); - expect(harness.agentStore.deleteAgent).not.toHaveBeenCalledWith(durable.id); - expect((await harness.agentStore.getAgent(durable.id))?.state).toBe("active"); - expect((await harness.agentStore.getAgent(durable.id))?.taskId).toBeUndefined(); - - await harness.manager.onTaskStart(makeTask("FN-DURABLE-ERROR", { assignedAgentId: durable.id })); - await harness.manager.onTaskError("FN-DURABLE-ERROR"); - expect(harness.agentStore.deleteAgent).not.toHaveBeenCalledWith(durable.id); - expect((await harness.agentStore.getAgent(durable.id))?.state).toBe("active"); - }); - - it("deletes ephemeral owners on completion and error", async () => { - await harness.manager.onTaskStart(makeTask("FN-COMPLETE")); - await harness.manager.onTaskComplete("FN-COMPLETE"); - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith("agent-1"); - expect(harness.manager.isDeletionPending("agent-1")).toBe(false); - - await harness.manager.onTaskStart(makeTask("FN-ERROR")); - await harness.manager.onTaskError("FN-ERROR"); - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith("agent-1"); - }); - - it("recovers a cross-restart owner by name during completion cleanup", async () => { - const existing = makeAgent("worker-disk", { - name: "executor-FN-DISK", - taskId: "FN-DISK", - metadata: { agentKind: "task-worker" }, - runtimeConfig: { enabled: false }, - }); - harness = createHarness([existing]); - - await harness.manager.onTaskComplete("FN-DISK"); - - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith(existing.id); - expect(harness.logger.log).toHaveBeenCalledWith(expect.stringContaining("Recovered ephemeral owner")); - }); - - it("logs genuine cleanup warnings and suppresses benign delete races", async () => { - await harness.manager.onTaskStart(makeTask("FN-WARN")); - harness.agentStore.deleteAgent.mockRejectedValueOnce(new Error("delete failed")); - await harness.manager.onTaskError("FN-WARN"); - expect(harness.logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to delete agent agent-1 after error: delete failed")); - - harness.logger.warn.mockClear(); - const benignOwner = await harness.manager.onTaskStart(makeTask("FN-BENIGN")); - expect(benignOwner).toBeDefined(); - harness.agentStore.deleteAgent.mockRejectedValueOnce(new Error(`Agent ${benignOwner!.agentId} not found`)); - await harness.manager.onTaskComplete("FN-BENIGN"); - expect(harness.logger.warn).not.toHaveBeenCalledWith(expect.stringContaining("Failed to delete agent")); - }); - }); - - describe("halt listener cleanup", () => { - it("deletes task-worker and spawned ephemerals that enter halted states", async () => { - const taskWorker = makeAgent("worker-paused", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - const spawned = makeAgent("spawned-error", { metadata: { type: "spawned" }, runtimeConfig: { enabled: false } }); - harness.agentStore.agents.set(taskWorker.id, taskWorker); - harness.agentStore.agents.set(spawned.id, spawned); - harness.manager.attachStateChangeListener(); - - harness.agentStore.emit("agent:stateChanged", taskWorker.id, "running", "paused"); - harness.agentStore.emit("agent:stateChanged", spawned.id, "running", "error"); - await flushMicrotasks(); - - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith(taskWorker.id); - expect(harness.agentStore.deleteAgent).toHaveBeenCalledWith(spawned.id); - }); - - it("ignores non-ephemeral agents, unchanged states, and externally pending deletes", async () => { - const durable = makeAgent("durable-paused", { metadata: {}, runtimeConfig: { enabled: true } }); - const pending = makeAgent("pending-paused", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - harness.agentStore.agents.set(durable.id, durable); - harness.agentStore.agents.set(pending.id, pending); - harness.externalPending.mockImplementation((agentId: string) => agentId === pending.id); - harness.manager.attachStateChangeListener(); - - harness.agentStore.emit("agent:stateChanged", durable.id, "active", "paused"); - harness.agentStore.emit("agent:stateChanged", pending.id, "running", "paused"); - harness.agentStore.emit("agent:stateChanged", pending.id, "paused", "paused"); - await flushMicrotasks(); - - expect(harness.agentStore.deleteAgent).not.toHaveBeenCalled(); - }); - - it("prevents duplicate listener deletes and detaches cleanly", async () => { - let resolveDelete: (() => void) | undefined; - const worker = makeAgent("worker-dup", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - harness.agentStore.agents.set(worker.id, worker); - harness.agentStore.deleteAgent.mockImplementationOnce(async (agentId: string) => { - await new Promise((resolve) => { resolveDelete = resolve; }); - harness.agentStore.agents.delete(agentId); - }); - const listener = harness.manager.attachStateChangeListener(); - expect(harness.manager.attachStateChangeListener()).toBe(listener); - - harness.agentStore.emit("agent:stateChanged", worker.id, "running", "paused"); - harness.agentStore.emit("agent:stateChanged", worker.id, "running", "error"); - await flushMicrotasks(); - expect(harness.agentStore.deleteAgent).toHaveBeenCalledTimes(1); - resolveDelete?.(); - await flushMicrotasks(); - - harness.manager.detachStateChangeListener(); - const afterDetach = makeAgent("worker-detached", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - harness.agentStore.agents.set(afterDetach.id, afterDetach); - harness.agentStore.emit("agent:stateChanged", afterDetach.id, "running", "paused"); - await flushMicrotasks(); - expect(harness.agentStore.deleteAgent).toHaveBeenCalledTimes(1); - }); - - it("suppresses benign halt-delete races but logs genuine failures", async () => { - const benign = makeAgent("worker-benign", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - const genuine = makeAgent("worker-genuine", { metadata: { agentKind: "task-worker" }, runtimeConfig: { enabled: false } }); - harness.agentStore.agents.set(benign.id, benign); - harness.agentStore.agents.set(genuine.id, genuine); - harness.manager.attachStateChangeListener(); - harness.agentStore.deleteAgent - .mockRejectedValueOnce(new Error(`Agent ${benign.id} not found`)) - .mockRejectedValueOnce(new Error("delete failed")); - - harness.agentStore.emit("agent:stateChanged", benign.id, "running", "paused"); - harness.agentStore.emit("agent:stateChanged", genuine.id, "running", "paused"); - await flushMicrotasks(); - - expect(harness.logger.warn).toHaveBeenCalledTimes(1); - expect(harness.logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Failed to delete ephemeral agent ${genuine.id}`)); - }); - }); - - describe("startup reconciliation", () => { - it("returns zero for empty or all-durable agent lists", async () => { - expect(await harness.manager.reconcileOrphaned()).toBe(0); - - harness.agentStore.agents.set("durable", makeAgent("durable", { metadata: {}, taskId: "FN-1" })); - expect(await harness.manager.reconcileOrphaned()).toBe(0); - expect(harness.agentStore.deleteAgent).not.toHaveBeenCalled(); - }); - - it("keeps populated in-progress ephemeral workers and deletes stale task bindings", async () => { - const live = makeAgent("live-worker", { metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }); - const done = makeAgent("done-worker", { metadata: { agentKind: "task-worker" }, taskId: "FN-DONE" }); - const todo = makeAgent("todo-worker", { metadata: { agentKind: "task-worker" }, taskId: "FN-TODO" }); - harness.agentStore.agents.set(live.id, live); - harness.agentStore.agents.set(done.id, done); - harness.agentStore.agents.set(todo.id, todo); - harness.taskStore.tasks.set("FN-LIVE", makeTask("FN-LIVE", { column: "in-progress" })); - harness.taskStore.tasks.set("FN-DONE", makeTask("FN-DONE", { column: "done" })); - harness.taskStore.tasks.set("FN-TODO", makeTask("FN-TODO", { column: "todo" })); - - expect(await harness.manager.reconcileOrphaned()).toBe(2); - - expect(harness.agentStore.agents.has(live.id)).toBe(true); - expect(harness.agentStore.agents.has(done.id)).toBe(false); - expect(harness.agentStore.agents.has(todo.id)).toBe(false); - }); - - it("deletes no-task, missing-task, paused, and error ephemerals", async () => { - const agents = [ - makeAgent("no-task", { metadata: { agentKind: "task-worker" } }), - makeAgent("missing-task", { metadata: { agentKind: "task-worker" }, taskId: "FN-MISSING" }), - makeAgent("paused", { state: "paused", metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }), - makeAgent("error", { state: "error", metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }), - ]; - for (const agent of agents) harness.agentStore.agents.set(agent.id, agent); - harness.taskStore.tasks.set("FN-LIVE", makeTask("FN-LIVE", { column: "in-progress" })); - - expect(await harness.manager.reconcileOrphaned()).toBe(4); - for (const agent of agents) expect(harness.agentStore.agents.has(agent.id)).toBe(false); - }); - - it("counts benign delete races and continues past genuine delete failures", async () => { - const benign = makeAgent("sweep-benign", { metadata: { agentKind: "task-worker" } }); - const failing = makeAgent("sweep-failing", { metadata: { agentKind: "task-worker" } }); - const next = makeAgent("sweep-next", { metadata: { agentKind: "task-worker" } }); - harness.agentStore.agents.set(benign.id, benign); - harness.agentStore.agents.set(failing.id, failing); - harness.agentStore.agents.set(next.id, next); - harness.agentStore.deleteAgent - .mockRejectedValueOnce(new Error(`Agent ${benign.id} not found`)) - .mockRejectedValueOnce(new Error("delete failed")) - .mockImplementationOnce(async (agentId: string) => { harness.agentStore.agents.delete(agentId); }); - - expect(await harness.manager.reconcileOrphaned()).toBe(2); - - expect(harness.logger.warn).toHaveBeenCalledWith(expect.stringContaining(`Startup sweep failed to delete ephemeral agent ${failing.id}`)); - expect(harness.agentStore.agents.has(next.id)).toBe(false); - expect(harness.logger.log).toHaveBeenCalledWith(expect.stringContaining("Startup ephemeral sweep cleaned 2 orphaned agent(s)")); - }); - }); - - it("reset clears owners and pending deletions", async () => { - await harness.manager.onTaskStart(makeTask("FN-RESET")); - expect(harness.manager.getOwner("FN-RESET")).toBeDefined(); - - harness.manager.reset(); - - expect(harness.manager.getOwner("FN-RESET")).toBeUndefined(); - }); -}); - -/* -FNXC:WorkflowLifecycleColumns 2026-07-31-06:10 (engine feed): - -THE INVARIANT: the zombie sweep asks the task's OWN workflow whether its worker is still working. - -A LIVE WORKER WAS REAPED ON A RENAMED BOARD, and the two guards compounded in the worst possible -order. `shouldDeleteOnSweep` tested a hard-coded terminal Set first, then fell through to -`return task.column !== "in-progress"`. On a renamed board the terminal test missed, and the fallthrough -is TRUE for a renamed wip lane — so an ephemeral worker ACTIVELY EXECUTING a task was classified as a -zombie and deleted, destroying work in flight. - -Census-invisible on both halves: the terminal check is a `Set` literal (a definition, not a -comparison), and the wip check was reached only after it. Found by grepping for lane-shaped list -literals, not by the backlog. - -THE FALLBACK IS DELIBERATELY ASYMMETRIC. An unresolvable workflow keeps the legacy literals rather -than guessing: failing to reap a dead worker costs a slot, reaping a live one destroys work. Those -are not symmetric, so the uncertain case must fail toward keeping the worker. - -REVERT PROOF, measured: restore the literal pair and the renamed-wip case fails — the live worker is -deleted. -*/ -describe("ephemeral zombie sweep resolves the board's own lanes", () => { - const RENAMED_IR = { - version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [], - columns: [ - { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] }, - { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, - { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, - ], - }; - - function renamedHarness() { - const harness = createHarness(); - const selection = { workflowId: "wf-renamed", stepIds: [] as string[] }; - Object.assign(harness.taskStore as unknown as Record, { - getTaskWorkflowSelection: () => selection, - getTaskWorkflowSelectionAsync: async () => selection, - getWorkflowDefinition: async () => ({ ir: RENAMED_IR }), - }); - return harness; - } - - it("KEEPS a worker whose task sits in a RENAMED wip lane", async () => { - const harness = renamedHarness(); - const live = makeAgent("renamed-live", { metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }); - harness.agentStore.agents.set(live.id, live); - harness.taskStore.tasks.set("FN-LIVE", makeTask("FN-LIVE", { column: "building" })); - - await harness.manager.reconcileOrphaned(); - - expect(harness.agentStore.agents.has(live.id)).toBe(true); - }); - - it("still reaps a worker whose task reached a RENAMED complete lane", async () => { - // The sweep must keep sweeping — keeping everything would be its own leak. - const harness = renamedHarness(); - const done = makeAgent("renamed-done", { metadata: { agentKind: "task-worker" }, taskId: "FN-DONE" }); - harness.agentStore.agents.set(done.id, done); - harness.taskStore.tasks.set("FN-DONE", makeTask("FN-DONE", { column: "shipped" })); - - await harness.manager.reconcileOrphaned(); - - expect(harness.agentStore.agents.has(done.id)).toBe(false); - }); - - it("still reaps a worker parked in a RENAMED hold lane", async () => { - const harness = renamedHarness(); - const parked = makeAgent("renamed-parked", { metadata: { agentKind: "task-worker" }, taskId: "FN-PARKED" }); - harness.agentStore.agents.set(parked.id, parked); - harness.taskStore.tasks.set("FN-PARKED", makeTask("FN-PARKED", { column: "backlog" })); - - await harness.manager.reconcileOrphaned(); - - expect(harness.agentStore.agents.has(parked.id)).toBe(false); - }); -}); - -/* -FNXC:WorkflowLifecycleColumns 2026-07-31-09:30 (#2787 review — greptile P1): -A SECOND wip lane must keep its worker too. - -`resolveLifecycleColumns` returns the FIRST column carrying each trait, so the first version of this -fix recognised only one implementation lane. A board declaring two — a common shape once a workflow -splits implementation from, say, an integration lane — still reaped a live worker in the second. The -same defect this suite exists to prevent, one degree narrower, and it would have surfaced the first -time someone added that column rather than at conversion time. - -The guard now unions `columnsWithFlag(ir, "countsTowardWip")`, which is every wip-bearing column. - -REVERT PROOF, measured: narrow the check back to `lanes.wip` and the second-lane case below fails. -*/ -describe("the zombie sweep honours EVERY wip lane, not the first", () => { - const TWO_WIP_IR = { - version: "v2", id: "wf-two-wip", name: "two wip", nodes: [], edges: [], - columns: [ - { id: "backlog", name: "Backlog", traits: [{ trait: "intake" }, { trait: "hold" }] }, - { id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, - { id: "integrating", name: "Integrating", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, - { id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] }, - ], - }; - - function twoWipHarness() { - const harness = createHarness(); - const selection = { workflowId: "wf-two-wip", stepIds: [] as string[] }; - Object.assign(harness.taskStore as unknown as Record, { - getTaskWorkflowSelection: () => selection, - getTaskWorkflowSelectionAsync: async () => selection, - getWorkflowDefinition: async () => ({ ir: TWO_WIP_IR }), - }); - return harness; - } - - it("KEEPS a worker whose task sits in the SECOND wip lane", async () => { - const harness = twoWipHarness(); - const live = makeAgent("second-lane-live", { metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }); - harness.agentStore.agents.set(live.id, live); - harness.taskStore.tasks.set("FN-LIVE", makeTask("FN-LIVE", { column: "integrating" })); - - await harness.manager.reconcileOrphaned(); - - expect(harness.agentStore.agents.has(live.id)).toBe(true); - }); - - it("still keeps a worker in the FIRST wip lane", async () => { - const harness = twoWipHarness(); - const live = makeAgent("first-lane-live", { metadata: { agentKind: "task-worker" }, taskId: "FN-LIVE" }); - harness.agentStore.agents.set(live.id, live); - harness.taskStore.tasks.set("FN-LIVE", makeTask("FN-LIVE", { column: "building" })); - - await harness.manager.reconcileOrphaned(); - - expect(harness.agentStore.agents.has(live.id)).toBe(true); - }); -}); diff --git a/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts b/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts deleted file mode 100644 index 566d7f8818..0000000000 --- a/packages/engine/src/__tests__/executor-ephemeral-disabled-dispatch-gate.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { TaskDetail } from "@fusion/core"; -import "./executor-test-helpers.js"; -import { TaskExecutor } from "../executor.js"; -import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js"; - -/* -FNXC:EphemeralAgents 2026-07-01-00:00: -Regression coverage for the ephemeral-disabled dispatch gate. `ephemeralAgentsEnabled: false` -must stop the workflow engine from running unassigned work, not just the legacy spawn path. -The bug: EphemeralWorkerManager.onTaskStart is a fire-and-forget bookkeeping callback that runs -AFTER execution begins, and the workflow dispatch paths in TaskExecutor.execute() -(executeWorkflowGraph, and the work-engine dispatch downstream of it) -never consulted the toggle — so tasks reaching execute() without a permanent assignment ran -anyway. These tests assert the invariant at the single routing boundary that fronts every -workflow dispatch entry point (Surface Enumeration), not just one reproduction. - -FNXC:EngineTests 2026-07-19-19:20 (U10b): -The enumerated dispatch surfaces collapsed from three to one. `maybeExecuteWorkflowGraph` -(which could DECLINE a task and fall through to a legacy implementation path) and the -workflow-authoritative driver are deleted; routing now ends in `executeWorkflowGraph(task)` -and work-engine dispatch lives inside `runImplementation`, downstream of the graph. The -requirement is unchanged: with the toggle off and no permanent assignment, execute() must -requeue the task and reach NO execution surface at all. -*/ - -const now = "2026-07-01T00:00:00.000Z"; - -function task(overrides: Partial = {}): TaskDetail { - return { - id: "FN-EPHEMERAL-GATE", - title: "Ephemeral-disabled dispatch gate", - description: "Gate coverage for ephemeralAgentsEnabled=false workflow dispatch", - column: "in-progress", - dependencies: [], - steps: [{ name: "Implement", status: "pending" }], - currentStep: 0, - log: [], - branch: "fusion/fn-ephemeral-gate", - baseBranch: "main", - worktree: "/tmp/fusion-fn-ephemeral-gate", - status: null, - error: null, - paused: false, - userPaused: false, - autoMerge: true, - mergeRetries: 0, - createdAt: now, - updatedAt: now, - ...overrides, - } as TaskDetail; -} - -function settings(overrides: Record = {}) { - return { - autoMerge: true, - maxAutoMergeRetries: 3, - maxConcurrent: 2, - maxWorktrees: 4, - pollIntervalMs: 15000, - ...overrides, - }; -} - -describe("executor ephemeral-disabled dispatch gate", () => { - it("blocks and re-queues an unassigned task when ephemeralAgentsEnabled=false", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ column: "in-progress", assignedAgentId: undefined }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); - const executor = new TaskExecutor(store, "/tmp/test"); - - const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); - - expect(blocked).toBe(true); - expect(store.moveTask).toHaveBeenCalledWith( - live.id, - "todo", - expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }), - ); - expect(store.updateTask).toHaveBeenCalledWith( - live.id, - expect.objectContaining({ status: "queued" }), - undefined, - ); - expect(store.logEntry).toHaveBeenCalledWith( - live.id, - expect.stringContaining("ephemeral agents disabled"), - expect.stringContaining("Executor pre-dispatch ephemeral gate"), - undefined, - ); - }); - - it("allows dispatch when ephemeralAgentsEnabled is on (default)", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: undefined }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: true })); - const executor = new TaskExecutor(store, "/tmp/test"); - - const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); - - expect(blocked).toBe(false); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.updateTask).not.toHaveBeenCalled(); - }); - - it("allows dispatch when the toggle is absent (undefined defaults to enabled)", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: undefined }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings()); - const executor = new TaskExecutor(store, "/tmp/test"); - - expect(await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live)).toBe(false); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("allows a task assigned to a permanent (non-ephemeral) agent through", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: "agent-permanent" }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); - const agentStore = { - getAgent: vi.fn().mockResolvedValue({ id: "agent-permanent", name: "reviewer", role: "executor" }), - }; - const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); - - const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); - - expect(blocked).toBe(false); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.updateTask).not.toHaveBeenCalled(); - }); - - it("blocks a task whose assigned agent is itself ephemeral", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: "executor-FN-EPHEMERAL-GATE" }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); - // isEphemeralAgent keys off the runtime-managed task-worker marker. - const agentStore = { - getAgent: vi.fn().mockResolvedValue({ - id: "executor-FN-EPHEMERAL-GATE", - name: "executor-FN-EPHEMERAL-GATE", - role: "executor", - metadata: { agentKind: "task-worker", taskWorker: true }, - }), - }; - const executor = new TaskExecutor(store, "/tmp/test", { agentStore } as any); - - const blocked = await (executor as any).blockOuterDispatchWhenEphemeralDisabled(live); - - expect(blocked).toBe(true); - expect(store.updateTask).toHaveBeenCalledWith( - live.id, - expect.objectContaining({ status: "queued" }), - undefined, - ); - }); - - /* - FNXC:EphemeralAgents 2026-07-01-00:00: - Surface Enumeration — one gate must cover every workflow dispatch entry point. - Drive the real execute() and assert that neither the graph nor the work-engine dispatch it - fronts is reached when the gate blocks. This is the invariant that prevented the fix from - being repro-only. - */ - it("execute() reaches no workflow dispatch path when ephemeral is disabled and task is unassigned", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: undefined }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: false })); - - const executor = new TaskExecutor(store, "/tmp/test", {} as any); - - const graphSpy = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined); - const workEngineSpy = vi - .spyOn(executor as any, "maybeDispatchWorkflowWorkEngine") - .mockResolvedValue(false); - - await executor.execute(live); - - // Every workflow dispatch entry point must be unreachable once the gate blocks. - expect(graphSpy).not.toHaveBeenCalled(); - expect(workEngineSpy).not.toHaveBeenCalled(); - - // And the task is re-queued for the scheduler to assign a permanent agent. - expect(store.updateTask).toHaveBeenCalledWith( - live.id, - expect.objectContaining({ status: "queued" }), - undefined, - ); - }); - - it("execute() still reaches the workflow graph path when ephemeral agents are enabled", async () => { - resetExecutorMocks(); - const store = createMockStore(); - const live = task({ assignedAgentId: undefined }); - store.getTask.mockResolvedValue(live); - store.getSettings.mockResolvedValue(settings({ ephemeralAgentsEnabled: true })); - - const executor = new TaskExecutor(store, "/tmp/test"); - // Stub the graph so execute() stops at the routing boundary — we only need - // to prove the gate did NOT short-circuit dispatch when the toggle is on. - const graphSpy = vi.spyOn(executor as any, "executeWorkflowGraph").mockResolvedValue(undefined); - - await executor.execute(live); - - expect(graphSpy).toHaveBeenCalledTimes(1); - expect(store.updateTask).not.toHaveBeenCalledWith( - live.id, - expect.objectContaining({ status: "queued" }), - undefined, - ); - }); -}); diff --git a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts index dfb274b578..fdb9a4429d 100644 --- a/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts +++ b/packages/engine/src/__tests__/executor-fast-mode-workflows.test.ts @@ -116,6 +116,64 @@ describe("fast mode workflow/runtime invariants", () => { } }); + it("rehydrates a held direct-graph principal fence into the runner", async () => { + const selected = { workflowId: "WF-fenced-resume", stepIds: [] }; + const { store, executor } = makeExecutorForTask(task()); + store.getTaskWorkflowSelectionAsync = vi.fn(async () => selected); + store.getWorkflowDefinition = vi.fn(async () => ({ + id: selected.workflowId, + name: "Fenced resume", + ir: { + version: "v1", + name: "Fenced resume", + nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end" }], + edges: [{ from: "start", to: "end" }], + }, + })); + store.listWorkflowWorkItemsForTask = vi.fn(async () => [{ + id: "work-item-1", + taskId: "FN-6226", + nodeId: "start", + nodeInstanceId: "start", + kind: "task", + state: "held", + principalAgentId: "reviewer-1", + workflowRole: "reviewer", + authorityKind: "review-node-override", + }]); + store.transitionWorkflowWorkItem = vi.fn(async (_id: string, state: string, patch: object = {}) => ({ + id: "work-item-1", + taskId: "FN-6226", + nodeId: "start", + nodeInstanceId: "start", + kind: "task", + state, + principalAgentId: "reviewer-1", + workflowRole: "reviewer", + authorityKind: "review-node-override", + ...patch, + })); + const run = vi.spyOn(WorkflowGraphTaskRunner.prototype, "run").mockResolvedValue({ + disposition: "completed", + outcome: "success", + visitedNodeIds: ["start"], + context: {}, + } as never); + + try { + await (executor as any).executeWorkflowGraph(task()); + expect(run).toHaveBeenCalledWith(expect.anything(), expect.anything(), "start", { + "workflow:work-item-id": "work-item-1", + "workflow:principal-agent-id": "reviewer-1", + "workflow:principal-role": "reviewer", + "workflow:principal-authority": "review-node-override", + "workflow:node-instance-id": "start", + }); + } finally { + run.mockRestore(); + } + }); + it("graph executor with a custom workflow skips custom pre-merge prompt/gate nodes in fast mode", async () => { const { store, executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" })); const executeStep = vi.spyOn(executor as any, "executeWorkflowStep").mockResolvedValue({ success: true }); diff --git a/packages/engine/src/__tests__/log-severity-manifest.ts b/packages/engine/src/__tests__/log-severity-manifest.ts index 3c17b80e3d..ac6e5619fd 100644 --- a/packages/engine/src/__tests__/log-severity-manifest.ts +++ b/packages/engine/src/__tests__/log-severity-manifest.ts @@ -120,6 +120,5 @@ export const logSeverityManifest: SeverityManifestEntry[] = [ { pkg: "engine", file: "run-verification-tool.ts", anchor: "executorLog.debug(`[fn_run_verification] command failed (exit=", priorSeverity: "warn", severity: "debug" }, { pkg: "engine", file: "worktree-acquisition.ts", anchor: "logger.debug(`Reusing existing worktree: ${path}`)", priorSeverity: "log", severity: "debug" }, { pkg: "engine", file: "worktree-acquisition.ts", anchor: "logger.debug(`Reusing existing worktree: ${worktreePath}`)", priorSeverity: "log", severity: "debug" }, - { pkg: "engine", file: "ephemeral-worker-manager.ts", anchor: "this.log.debug(`Skipping task-worker creation for ${task.id}: task already has execution owner`)", priorSeverity: "warn", severity: "debug" }, { pkg: "core", file: "postgres/embedded-lifecycle.ts", anchor: "log.debug(`embedded postgres: already running on port", priorSeverity: "log", severity: "debug" }, ]; diff --git a/packages/engine/src/__tests__/log-severity-spam-contract.test.ts b/packages/engine/src/__tests__/log-severity-spam-contract.test.ts index 0ccdf32448..ea963acef1 100644 --- a/packages/engine/src/__tests__/log-severity-spam-contract.test.ts +++ b/packages/engine/src/__tests__/log-severity-spam-contract.test.ts @@ -288,7 +288,6 @@ describe("log severity spam contract (source)", () => { const exec = readSrc("executor.ts"); const heartbeat = readSrc("agent-heartbeat.ts"); const autoClaim = readSrc("auto-claim-snapshot.ts"); - const ephemeral = readSrc("ephemeral-worker-manager.ts"); const worktree = readSrc("worktree-acquisition.ts"); expect(scheduler).toMatch(/schedulerLog\.debug\(`No linked feature found for task/); @@ -314,8 +313,6 @@ describe("log severity spam contract (source)", () => { expect(autoClaim).toMatch(/this\.logger\.debug\(`invalidate reason=\$\{reason\}`\)/); expect(autoClaim).not.toMatch(/this\.logger\.log\(`invalidate reason=\$\{reason\}`\)/); - expect(ephemeral).toMatch(/this\.log\.debug\(`Skipping task-worker creation for \$\{task\.id\}: task already has execution owner`\)/); - expect(ephemeral).not.toMatch(/this\.log\.warn\(`Skipping task-worker creation for \$\{task\.id\}: task already has execution owner`\)/); expect(worktree).toMatch(/logger\.debug\(`Reusing existing worktree: \$\{path\}`\)/); expect(worktree).toMatch(/logger\.debug\(`Reusing existing worktree: \$\{worktreePath\}`\)/); diff --git a/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts b/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts index 61b27537c7..c44ea4b8f0 100644 --- a/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts +++ b/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts @@ -45,10 +45,6 @@ Documented exceptions. Each is a literal that survives ON PURPOSE, with the reas site. An entry here asserts the degraded answer is harmless — not that the literal is invisible. */ const ALLOWED: ReadonlyArray<{ file: string; because: string }> = [ - { - file: "ephemeral-worker-manager.ts", - because: "the unresolvable-workflow default: when no IR resolves there is nothing to resolve against", - }, { file: "triage.ts", because: "the U11 orphan case — a row resting in a column its workflow no longer declares has no trait to resolve", diff --git a/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts b/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts deleted file mode 100644 index 2153007b3a..0000000000 --- a/packages/engine/src/__tests__/scheduler-ephemeral-toggle.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import type { Agent, Task, TaskStore } from "@fusion/core"; -import { Scheduler } from "../scheduler.js"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; - -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, existsSync: vi.fn() }; -}); - -vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, readFile: vi.fn() }; -}); - -/* -FNXC:PlanReviewStep 2026-07-26-17:10: -The default workflow is plan-in-place: a `todo` card releases only after Plan Review passed, so these -scheduler fixtures model a card that already cleared the gate (the state every real card is in when -the capacity sweep sees it). Holding an unreviewed card is the gate working — that path is owned by -`pre-release-plan-review.test.ts`. -*/ -const PASSED_PLAN_REVIEW = { - workflowStepId: "plan-review", - workflowStepName: "Plan Review", - status: "passed" as const, - source: "node" as const, - phase: "pre-merge" as const, -}; - -function makeTask(overrides: Partial = {}): Task { - return { - id: "FN-100", - description: "test", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - workflowStepResults: [PASSED_PLAN_REVIEW], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - ...overrides, - } as Task; -} - -function makeAgent(overrides: Partial & Pick): Agent { - return { - name: overrides.name ?? overrides.id, - role: overrides.role ?? "executor", - state: overrides.state ?? "idle", - createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", - updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z", - metadata: overrides.metadata ?? {}, - ...overrides, - }; -} - -function createStore(task: Task, settings: Record, tasksForList?: Task[]): TaskStore { - const moveTask = vi.fn().mockResolvedValue(undefined); - return { - listTasks: vi.fn().mockImplementation(async () => tasksForList ?? [task]), - getSettings: vi.fn().mockResolvedValue(settings), - /* - FNXC:EngineTests 2026-06-27-10:05: - Scheduler fakes must expose the production `updateSettings` heartbeat write so ephemeral-agent dispatch assertions measure scheduler behavior instead of fake drift. - */ - updateSettings: vi.fn().mockResolvedValue(settings), - getTask: vi.fn().mockResolvedValue(task), - updateTask: vi.fn().mockResolvedValue(undefined), - moveTask, - /* - FNXC:EngineTests 2026-07-23-21:20: - Scheduler dispatch now goes through the atomic `moveTaskIf` (user-paused dispatch fix, commit 0818fc1da). - The fake delegates to the mock `moveTask` after the predicate passes so existing dispatch assertions on `store.moveTask` stay meaningful. - */ - moveTaskIf: vi.fn(async (id: string, column: Task["column"], predicate: (live: Task) => boolean | Promise, opts?: Record) => { - const cur = (tasksForList ?? [task]).find((t) => t.id === id) ?? task; - if (!(await predicate(cur)) || cur.column === column) return { task: cur, moved: false }; - await moveTask(id, column, opts); - cur.column = column; - return { task: cur, moved: true }; - }), - parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]), - logEntry: vi.fn().mockResolvedValue(undefined), - getRootDir: vi.fn().mockReturnValue("/tmp/project"), - getTasksDir: vi.fn().mockReturnValue("/tmp/project/.fusion/tasks"), - on: vi.fn(), - off: vi.fn(), - } as unknown as TaskStore; -} - -async function runSchedulerOnce(scheduler: Scheduler): Promise { - await scheduler.start(); - await new Promise((resolve) => setTimeout(resolve, 0)); - scheduler.stop(); -} - -describe("Scheduler ephemeralAgentsEnabled toggle", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFile).mockResolvedValue("# Prompt\n"); - }); - - it("default on: dispatches without auto-assigned agent", async () => { - const task = makeTask({ id: "FN-101" }); - const store = createStore(task, { maxConcurrent: 2, maxWorktrees: 4, ephemeralAgentsEnabled: true }); - const scheduler = new Scheduler(store); - - await runSchedulerOnce(scheduler); - - expect(store.updateTask).not.toHaveBeenCalledWith("FN-101", expect.objectContaining({ assignedAgentId: expect.any(String) })); - expect(store.moveTask).toHaveBeenCalledWith("FN-101", "in-progress", expect.any(Object)); - }); - - it("off + no permanent executor: keeps task queued in todo", async () => { - const task = makeTask({ id: "FN-102" }); - const store = createStore(task, { maxConcurrent: 2, maxWorktrees: 4, ephemeralAgentsEnabled: false }); - const scheduler = new Scheduler(store, { - agentStore: { - listAgents: vi.fn().mockResolvedValue([]), - getChainOfCommand: vi.fn().mockResolvedValue([]), - } as never, - }); - - await runSchedulerOnce(scheduler); - - expect(store.updateTask).toHaveBeenCalledWith("FN-102", { status: "queued" }); - expect(store.logEntry).toHaveBeenCalledWith("FN-102", "queued — no permanent executor available (ephemeral agents disabled)"); - expect(store.moveTask).not.toHaveBeenCalled(); - }); - - it("off + permanent executor: assigns then dispatches", async () => { - const task = makeTask({ id: "FN-103" }); - const store = createStore(task, { maxConcurrent: 2, maxWorktrees: 4, ephemeralAgentsEnabled: false }); - const scheduler = new Scheduler(store, { - agentStore: { - listAgents: vi.fn().mockResolvedValue([makeAgent({ id: "agent-1" })]), - getChainOfCommand: vi.fn().mockResolvedValue([]), - } as never, - }); - - await runSchedulerOnce(scheduler); - - expect(store.updateTask).toHaveBeenCalledWith("FN-103", { assignedAgentId: "agent-1" }); - expect(store.moveTask).toHaveBeenCalledWith("FN-103", "in-progress", expect.any(Object)); - }); - - it("off + multiple executors: picks least-loaded", async () => { - const task = makeTask({ id: "FN-104" }); - const tasks = [ - task, - makeTask({ id: "FN-A", column: "in-progress", assignedAgentId: "agent-heavy" }), - makeTask({ id: "FN-B", column: "todo", assignedAgentId: "agent-heavy" }), - makeTask({ id: "FN-C", column: "in-review", assignedAgentId: "agent-light" }), - ]; - const store = createStore(task, { maxConcurrent: 2, maxWorktrees: 4, ephemeralAgentsEnabled: false }, tasks); - const scheduler = new Scheduler(store, { - agentStore: { - listAgents: vi.fn().mockResolvedValue([ - makeAgent({ id: "agent-heavy", createdAt: "2026-01-01T00:00:00.000Z" }), - makeAgent({ id: "agent-light", createdAt: "2026-01-01T00:00:01.000Z" }), - ]), - getChainOfCommand: vi.fn().mockResolvedValue([]), - } as never, - }); - - await runSchedulerOnce(scheduler); - - expect(store.updateTask).toHaveBeenCalledWith("FN-104", { assignedAgentId: "agent-light" }); - expect(store.moveTask).toHaveBeenCalledWith("FN-104", "in-progress", expect.any(Object)); - }); -}); diff --git a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts index 275503cf80..fcc6fd3e12 100644 --- a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts +++ b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts @@ -315,25 +315,6 @@ describe("Scheduler workflow cutover", () => { expect(onSchedule).not.toHaveBeenCalledWith(expect.objectContaining({ id: "FN-300" })); }); - it("queues without dispatch when ephemeral agents are disabled and no agent store is available", async () => { - const ready = task({ id: "FN-101" }); - const store = storeWith([ready], { ephemeralAgentsEnabled: false }); - const onSchedule = vi.fn(); - const scheduler = new Scheduler(store, { onSchedule }); - (scheduler as unknown as { running: boolean }).running = true; - - await scheduler.schedule(); - - expect(store.updateTask).toHaveBeenCalledWith("FN-101", { status: "queued" }); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-101", - "queued — permanent executor selection unavailable (ephemeral agents disabled)", - ); - expect(store.moveTaskIf).not.toHaveBeenCalledWith("FN-101", "in-progress", expect.anything(), expect.anything()); - expect(onSchedule).not.toHaveBeenCalled(); - expect(ready.column).toBe("todo"); - }); - it("passes worktree naming and directory settings to the workflow release allocator", async () => { const ready = task({ id: "FN-102" }); const store = storeWith([ready], { diff --git a/packages/engine/src/__tests__/workflow-agent-capacity.test.ts b/packages/engine/src/__tests__/workflow-agent-capacity.test.ts new file mode 100644 index 0000000000..d42d78cb0f --- /dev/null +++ b/packages/engine/src/__tests__/workflow-agent-capacity.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { WorkflowAgentCapacity } from "../agents/workflow-agent-capacity.js"; + +const agent = (id: string, maxWorkflowSessions?: number) => ({ id, runtimeConfig: { maxWorkflowSessions } }) as any; + +describe("WorkflowAgentCapacity", () => { + it("keeps workflow and heartbeat limits independent while enforcing project then agent limits", async () => { + const capacity = new WorkflowAgentCapacity(); + const constrained = agent("executor", 1); + expect(await capacity.acquire({ projectId: "project-a", agent: constrained, attemptId: "one", maxProjectSessions: 2 })).toMatchObject({ status: "acquired" }); + expect(await capacity.acquire({ projectId: "project-a", agent: constrained, attemptId: "two", maxProjectSessions: 2 })).toEqual({ status: "held", reason: "agent-capacity" }); + expect(await capacity.acquire({ projectId: "project-a", agent: agent("reviewer"), attemptId: "three", maxProjectSessions: 2 })).toMatchObject({ status: "acquired" }); + expect(await capacity.acquire({ projectId: "project-a", agent: agent("merger"), attemptId: "four", maxProjectSessions: 2 })).toEqual({ status: "held", reason: "project-capacity" }); + }); + + it("isolates matching agent and attempt IDs across projects", async () => { + const capacity = new WorkflowAgentCapacity(); + expect(await capacity.acquire({ projectId: "project-a", agent: agent("same", 1), attemptId: "attempt" })).toMatchObject({ status: "acquired" }); + expect(await capacity.acquire({ projectId: "project-b", agent: agent("same", 1), attemptId: "attempt" })).toMatchObject({ status: "acquired" }); + expect(capacity.activeSessions("same", "project-a")).toBe(1); + expect(capacity.activeSessions("same", "project-b")).toBe(1); + expect(await capacity.release("attempt", "project-a")).toBe(true); + expect(capacity.activeSessions("same", "project-b")).toBe(1); + }); + + it("allows a fenced attempt to reacquire and releases exactly once", async () => { + const capacity = new WorkflowAgentCapacity(); + const input = { projectId: "project-a", agent: agent("executor", 1), attemptId: "attempt", maxProjectSessions: 1 }; + const first = await capacity.acquire(input); + expect(await capacity.acquire(input)).toEqual(first); + expect(capacity.activeSessions("executor")).toBe(1); + expect(await capacity.release("attempt")).toBe(true); + expect(await capacity.release("attempt")).toBe(false); + expect(capacity.activeSessions("executor")).toBe(0); + }); + + it("passes a finite durable TTL so a crashed engine lease can be reclaimed", async () => { + const calls: Array> = []; + const capacity = new WorkflowAgentCapacity({ + acquireWorkflowSessionCapacity: async (input) => { calls.push(input); return "acquired"; }, + releaseWorkflowSessionCapacity: async () => undefined, + }); + await capacity.acquire({ projectId: "project-a", agent: agent("executor"), attemptId: "crash-safe" }); + expect(calls[0]).toMatchObject({ attemptId: "crash-safe", leaseDurationMs: 10 * 60_000 }); + await capacity.release("crash-safe", "project-a"); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-agent-routing.test.ts b/packages/engine/src/__tests__/workflow-agent-routing.test.ts new file mode 100644 index 0000000000..6260b4ca32 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-agent-routing.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { isCurrentReviewerNodeOverride, routeWorkflowPrincipal, validateFencedWorkflowPrincipal } from "../agents/workflow-agent-router.js"; + +const agent = (id: string, roles: string[], createdAt = "2026-01-01T00:00:00.000Z") => ({ + id, name: id, roles, role: roles[0], state: "idle", createdAt, updatedAt: createdAt, metadata: {}, +}) as any; +const ir: any = { version: "v2", name: "test", columns: [{ id: "todo", name: "Todo", traits: [] }], nodes: [] }; + +describe("routeWorkflowPrincipal", () => { + it("uses exact review override and returns to task owner for execution", () => { + const owner = agent("owner", ["custom"]); + const reviewer = agent("reviewer", ["custom"]); + expect(routeWorkflowPrincipal({ task: { assignedAgentId: "owner" }, ir, node: { id: "r", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } }, agents: [owner, reviewer] })).toMatchObject({ status: "routed", route: { agent: reviewer, authority: "review-node-override" } }); + expect(routeWorkflowPrincipal({ task: { assignedAgentId: "owner" }, ir, node: { id: "e", kind: "prompt", config: { seam: "execute" } }, agents: [owner, reviewer] })).toMatchObject({ status: "routed", route: { agent: owner, authority: "task-assignee" } }); + }); + + it("holds rather than falling back when a named principal is unavailable", () => { + const paused = { ...agent("owner", ["executor"]), state: "paused" }; + const pool = agent("pool", ["executor"]); + expect(routeWorkflowPrincipal({ task: { assignedAgentId: "owner" }, ir, node: { id: "e", kind: "prompt", config: { seam: "execute" } }, agents: [paused, pool] })).toEqual({ status: "held", role: "executor", reason: "named-principal-unavailable" }); + }); + + it("never routes an ephemeral task worker through a durable workflow role", () => { + const worker = { + ...agent("worker", ["executor"]), + name: "executor-FN-8764", + metadata: { taskWorker: true }, + }; + expect(routeWorkflowPrincipal({ task: {}, ir, node: { id: "e", kind: "prompt", config: { seam: "execute" } }, agents: [worker] })) + .toEqual({ status: "held", role: "executor", reason: "role-pool-exhausted" }); + }); + + it("keeps a fenced reviewer on its exact node and fails closed after an override edit", () => { + const reviewer = agent("reviewer", ["reviewer"]); + const node = { id: "review", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } } as any; + expect(validateFencedWorkflowPrincipal({ + task: {}, node, principalAgentId: "reviewer", role: "reviewer", authority: "review-node-override", agents: [reviewer], + })).toMatchObject({ status: "routed", route: { agent: reviewer, authority: "review-node-override" } }); + expect(validateFencedWorkflowPrincipal({ + task: {}, node: { ...node, reviewerAgentId: "other" }, principalAgentId: "reviewer", role: "reviewer", authority: "review-node-override", agents: [reviewer], + })).toEqual({ status: "held", role: "reviewer", reason: "named-principal-unavailable" }); + }); + + it("revokes a review authority when its exact durable override changes", () => { + const reviewerNode = { id: "review", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } }; + const reviewerIr = { ...ir, nodes: [reviewerNode] } as any; + expect(isCurrentReviewerNodeOverride(reviewerIr, "review", "reviewer")).toBe(true); + expect(isCurrentReviewerNodeOverride({ ...reviewerIr, nodes: [{ ...reviewerNode, reviewerAgentId: "replacement" }] }, "review", "reviewer")).toBe(false); + expect(isCurrentReviewerNodeOverride(reviewerIr, "missing", "reviewer")).toBe(false); + }); + + it("fences reviewer overrides to the exact foreach template instance", () => { + const reviewerNode = { id: "review", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } }; + const reviewerIr = { + ...ir, + nodes: [{ id: "foreach", kind: "foreach", config: { template: { nodes: [reviewerNode] } } }], + } as any; + expect(isCurrentReviewerNodeOverride(reviewerIr, "foreach#0:review", "reviewer")).toBe(true); + expect(isCurrentReviewerNodeOverride(reviewerIr, "foreach#0:review", "replacement")).toBe(false); + expect(isCurrentReviewerNodeOverride({ + ...reviewerIr, + nodes: [{ ...reviewerIr.nodes[0], config: { template: { nodes: [{ ...reviewerNode, reviewerAgentId: "replacement" }] } } }], + }, "foreach#0:review", "reviewer")).toBe(false); + }); + + it("fences nested optional, foreach, and loop reviewer instances independently", () => { + const review = { id: "review", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } }; + const nestedIr = { + ...ir, + nodes: [{ + id: "optional", + kind: "optional-group", + config: { template: { nodes: [{ + id: "steps", + kind: "foreach", + config: { template: { nodes: [{ + id: "repeat", + kind: "loop", + config: { template: { nodes: [review] } }, + }] } }, + }] } }, + }], + } as any; + expect(isCurrentReviewerNodeOverride(nestedIr, "optional::steps#2:repeat#1:review", "reviewer")).toBe(true); + expect(isCurrentReviewerNodeOverride(nestedIr, "optional::steps#2:repeat#1:review", "other")).toBe(false); + }); + + it("revalidates a nested reviewer fence against the persisted node instance", () => { + const reviewer = agent("reviewer", ["reviewer"]); + const nested = { id: "review", kind: "prompt", reviewerAgentId: "reviewer", config: { workflowRole: "reviewer" } } as any; + const nestedIr = { ...ir, nodes: [{ id: "foreach", kind: "foreach", config: { template: { nodes: [nested] } } }] } as any; + expect(validateFencedWorkflowPrincipal({ + task: {}, ir: nestedIr, node: nested, nodeInstanceId: "foreach#0:review", principalAgentId: "reviewer", + role: "reviewer", authority: "review-node-override", agents: [reviewer], + })).toMatchObject({ status: "routed" }); + const editedIr = { ...nestedIr, nodes: [{ ...nestedIr.nodes[0], config: { template: { nodes: [{ ...nested, reviewerAgentId: "replacement" }] } } }] }; + expect(validateFencedWorkflowPrincipal({ + task: {}, ir: editedIr, node: nested, nodeInstanceId: "foreach#0:review", principalAgentId: "reviewer", + role: "reviewer", authority: "review-node-override", agents: [reviewer], + })).toEqual({ status: "held", role: "reviewer", reason: "named-principal-unavailable" }); + }); + + it("moves a raced role-pool route to the next deterministic candidate", () => { + const first = agent("first", ["executor"], "2026-01-01T00:00:00.000Z"); + const second = agent("second", ["executor"], "2026-01-02T00:00:00.000Z"); + const node = { id: "execute", kind: "prompt", config: { seam: "execute" } } as any; + expect(routeWorkflowPrincipal({ task: {}, ir, node, agents: [first, second] })) + .toMatchObject({ status: "routed", route: { agent: first, authority: "role-pool" } }); + expect(routeWorkflowPrincipal({ task: {}, ir, node, agents: [first, second], excludedPoolAgentIds: new Set(["first"]) })) + .toMatchObject({ status: "routed", route: { agent: second, authority: "role-pool" } }); + }); + + it("fails a task-assignee fence after assignment changes instead of rerouting", () => { + const owner = agent("owner", ["custom"]); + expect(validateFencedWorkflowPrincipal({ + task: { assignedAgentId: "other" }, node: { id: "execute", kind: "prompt", config: { seam: "execute" } } as any, + principalAgentId: "owner", role: "executor", authority: "task-assignee", agents: [owner], + })).toEqual({ status: "held", role: "executor", reason: "named-principal-unavailable" }); + }); + + it("fails a column fence after the durable binding is redirected", () => { + const bound = agent("bound", ["executor"]); + const node = { id: "execute", kind: "prompt", column: "todo", config: { seam: "execute" } } as any; + const boundIr = { + ...ir, + columns: [{ id: "todo", name: "Todo", traits: [], agent: { agentId: "bound", mode: "override" } }], + nodes: [node], + } as any; + expect(validateFencedWorkflowPrincipal({ + task: {}, ir: boundIr, node, principalAgentId: "bound", role: "executor", authority: "column-binding", agents: [bound], + })).toMatchObject({ status: "routed", route: { agent: bound } }); + expect(validateFencedWorkflowPrincipal({ + task: {}, ir: { ...boundIr, columns: [{ ...boundIr.columns[0], agent: { agentId: "replacement", mode: "override" } }] }, node, + principalAgentId: "bound", role: "executor", authority: "column-binding", agents: [bound], + })).toEqual({ status: "held", role: "executor", reason: "named-principal-unavailable" }); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-graph-foreach.test.ts b/packages/engine/src/__tests__/workflow-graph-foreach.test.ts index 291678663e..05cb6888f4 100644 --- a/packages/engine/src/__tests__/workflow-graph-foreach.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-foreach.test.ts @@ -95,6 +95,21 @@ describe("WorkflowGraphExecutor foreach (U3)", () => { expect(result.visitedNodeIds).toContain("fe"); }); + it("uses a distinct materialized identity for each template-session admission", async () => { + const admitted: string[] = []; + const executor = new WorkflowGraphExecutor({ + seams: baseSeams({ stepExecute: async () => ({ outcome: "success" as const }) }), + beforeNodeExecution: (node, _task, context) => { + if (node.id === "exec") admitted.push(String(context["workflow:node-instance-id"])); + }, + }); + + const result = await executor.run(taskWithSteps(2), settingsOn(), foreachIr(singleExecuteTemplate())); + + expect(result.outcome).toBe("success"); + expect(admitted).toEqual(["fe#0:exec", "fe#1:exec"]); + }); + it("zero steps → foreach traverses its success edge without running any instance", async () => { const exec = vi.fn(async () => ({ outcome: "success" as const })); const seams = baseSeams({ stepExecute: exec }); diff --git a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts index db36da4f34..05c6db0186 100644 --- a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts @@ -438,6 +438,79 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => { expect(result.context?.["node:lint:value"]).toBe("APPROVE"); }); + it("runs principal admission before an executable handler and honors its fail-closed result", async () => { + const calls: string[] = []; + const admitted: string[] = []; + const runner = new WorkflowGraphTaskRunner({ + store: storeWith(definition(fullLifecycleIr())), + seams: recordingSeams(calls), + runCustomNode: async (node) => { + calls.push(`custom:${node.id}`); + return { outcome: "success" }; + }, + beforeNodeExecution: (node, _task, context) => { + if (node.id === "lint") { + admitted.push(node.id); + context["workflow:principal-agent-id"] = "planner"; + return { outcome: "failure", value: "workflow-principal-named-principal-unavailable:triage" }; + } + return undefined; + }, + }); + + const result = await runner.run(task, flagOn); + expect(admitted).toEqual(["lint"]); + expect(calls).toEqual([]); + expect(result.disposition).toBe("suspended"); + expect(result.suspension).toMatchObject({ reason: "capacity", nodeId: "lint" }); + expect(result.context?.["workflow:principal-agent-id"]).toBe("planner"); + }); + + it("restores a direct-resume principal fence before node admission", async () => { + const observed: Record[] = []; + const runner = new WorkflowGraphTaskRunner({ + store: storeWith(definition(fullLifecycleIr())), + seams: recordingSeams([]), + runCustomNode: async () => ({ outcome: "success" }), + beforeNodeExecution: (node, _task, context) => { + if (node.id === "lint") observed.push({ ...context }); + }, + }); + + await runner.run(task, flagOn, "lint", { + "workflow:work-item-id": "work-item-1", + "workflow:principal-agent-id": "reviewer-1", + "workflow:principal-role": "reviewer", + "workflow:principal-authority": "review-node-override", + "workflow:node-instance-id": "lint", + }); + + expect(observed).toEqual([expect.objectContaining({ + "workflow:work-item-id": "work-item-1", + "workflow:principal-agent-id": "reviewer-1", + "workflow:principal-role": "reviewer", + "workflow:principal-authority": "review-node-override", + "workflow:node-instance-id": "lint", + })]); + }); + + it("releases a principal reservation after its node handler settles", async () => { const released: string[] = []; + const runner = new WorkflowGraphTaskRunner({ + store: storeWith(definition(fullLifecycleIr())), + seams: recordingSeams([]), + runCustomNode: async () => ({ outcome: "success" }), + beforeNodeExecution: (node, _task, context) => { + if (node.id === "execute") { + context["workflow:release-principal"] = () => released.push(node.id); + } + }, + }); + + const result = await runner.run(task, flagOn); + expect(result.disposition).toBe("completed"); + expect(released).toEqual(["execute"]); + }); + it("onEvent diagnostics failures never affect the run", async () => { const runner = new WorkflowGraphTaskRunner({ store: storeWith(definition(fullLifecycleIr())), diff --git a/packages/engine/src/__tests__/workflow-task-runtime.test.ts b/packages/engine/src/__tests__/workflow-task-runtime.test.ts index e409cce278..7345cd6d81 100644 --- a/packages/engine/src/__tests__/workflow-task-runtime.test.ts +++ b/packages/engine/src/__tests__/workflow-task-runtime.test.ts @@ -576,6 +576,101 @@ describe("WorkflowTaskRuntime", () => { ]); }); + it("fences a routed permanent principal before invoking a classified work-item handler", async () => { + const transitions: Array<{ id: string; state: WorkflowWorkItemState; patch?: Record }> = []; + const workItem = { + id: "work-fenced", + runId: "run-1", + taskId: task.id, + nodeId: "execute", + kind: "task", + state: "running", + attempt: 0, + retryAfter: null, + leaseOwner: "scheduler-a", + leaseExpiresAt: "2026-06-09T00:01:00.000Z", + lastError: null, + blockedReason: null, + stableWorkflowRunId: null, + continuationSequence: null, + waitReason: null, + sourceColumn: null, + targetColumn: null, + irHash: null, + principalAgentId: null, + workflowRole: null, + authorityKind: null, + nodeInstanceId: null, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + } satisfies WorkflowWorkItem; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => task, + getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: selectedIr() }), + transitionWorkflowWorkItem: (id, state, patch) => { + transitions.push({ id, state, patch }); + return { ...workItem, state, ...patch } as WorkflowWorkItem; + }, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + resolveWorkflowPrincipal: () => ({ + status: "routed", + route: { + agent: { id: "executor-owner" }, + role: "executor", + authority: "task-assignee", + }, + } as any), + }); + + await runtime.runWorkItem(workItem, flagOff); + + expect(transitions[0]).toEqual({ + id: "work-fenced", + state: "running", + patch: { + principalAgentId: "executor-owner", + workflowRole: "executor", + authorityKind: "task-assignee", + nodeInstanceId: "execute", + }, + }); + expect(transitions[1]?.state).toBe("succeeded"); + }); + + it("holds a claimed work item when the shared pre-handler fence is unavailable", async () => { + const workItem = { + id: "work-preflight", runId: "run-1", taskId: task.id, nodeId: "execute", kind: "task", state: "running", + attempt: 0, retryAfter: null, leaseOwner: "scheduler-a", leaseExpiresAt: null, lastError: null, blockedReason: null, + stableWorkflowRunId: null, continuationSequence: null, waitReason: null, sourceColumn: null, targetColumn: null, irHash: null, + principalAgentId: "executor-owner", workflowRole: "executor", authorityKind: "task-assignee", nodeInstanceId: "execute", + createdAt: "2026-06-09T00:00:00.000Z", updatedAt: "2026-06-09T00:00:00.000Z", + } satisfies WorkflowWorkItem; + const preflight = vi.fn(async () => ({ outcome: "failure" as const, value: "workflow-principal-agent-capacity:executor" })); + const transitions: string[] = []; + const runtime = new WorkflowTaskRuntime({ + store: { + getTask: async () => task, + getTaskWorkflowSelection: () => ({ workflowId: "WF-001", stepIds: [] }), + getWorkflowDefinition: async () => ({ ir: selectedIr() }), + transitionWorkflowWorkItem: (_id, state) => { transitions.push(state); return { ...workItem, state } as WorkflowWorkItem; }, + }, + primitives: recordingPrimitives([]), + runCustomNode: async () => ({ outcome: "success" }), + beforeNodeExecution: preflight, + }); + + await expect(runtime.runWorkItem(workItem, flagOff)).resolves.toMatchObject({ + disposition: "manual-required", + reason: "workflow-principal-agent-capacity:executor", + }); + expect(preflight).toHaveBeenCalledOnce(); + expect(transitions).toEqual(["held"]); + }); + it("fails and releases a workflow work item when the addressed node fails", async () => { const transitions: Array<{ id: string; state: WorkflowWorkItemState; patch?: Record }> = []; const workItem = { diff --git a/packages/engine/src/__tests__/workflow-work-scheduler.test.ts b/packages/engine/src/__tests__/workflow-work-scheduler.test.ts index 3bc5eaa12e..eb70bc8a14 100644 --- a/packages/engine/src/__tests__/workflow-work-scheduler.test.ts +++ b/packages/engine/src/__tests__/workflow-work-scheduler.test.ts @@ -11,6 +11,26 @@ describe("claimDueWorkflowWorkItem", () => { expect(acquireWorkflowWorkItemLease).toHaveBeenCalledOnce(); }); + it("offers a durable availability hold back to the scoped lease claimer", async () => { + const held = { + ...item, + state: "held", + blockedReason: "workflow-principal-named-principal-unavailable:executor", + }; + const acquireWorkflowWorkItemLease = vi.fn(async () => ({ ...held, state: "running" })); + + const result = await claimDueWorkflowWorkItem({ + listDueWorkflowWorkItems: async () => [held], + acquireWorkflowWorkItemLease, + }, { leaseOwner: "recovery-worker", leaseDurationMs: 1000 }); + + expect(result?.workItem).toMatchObject({ id: "WW-1", state: "running" }); + expect(acquireWorkflowWorkItemLease).toHaveBeenCalledWith("WW-1", "recovery-worker", { + now: undefined, + leaseDurationMs: 1000, + }); + }); + it("does not consume a work lease when mission lineage is unapproved", async () => { const acquireWorkflowWorkItemLease = vi.fn(() => item); const logEntry = vi.fn(async () => undefined); diff --git a/packages/engine/src/agents/agent-action-gate.ts b/packages/engine/src/agents/agent-action-gate.ts index 0a6e7bde2f..c325a55397 100644 --- a/packages/engine/src/agents/agent-action-gate.ts +++ b/packages/engine/src/agents/agent-action-gate.ts @@ -46,6 +46,18 @@ export interface AgentActionGateContext { taskId?: string; runId?: string; permissionPolicy: AgentPermissionPolicy; + /** Live workflow authority is validated for each tool call; absence preserves ordinary policy. */ + workflowAuthority?: { + projectId: string; + taskId: string; + runId: string; + workItemId: string; + nodeInstanceId: string; + principalAgentId: string; + kind: "task-assignee" | "review-node-override"; + /** Revalidates the durable lease, current principal, task and node fence. */ + isLive: () => boolean | Promise; + }; createApprovalRequest: (decision: AgentActionGateDecision, args: Record) => Promise; /** * FNXC:ApprovalRedemption 2026-07-26-13:05: @@ -327,6 +339,58 @@ export function evaluateAgentActionGate(params: { }; } +/** + * FNXC:WorkflowAgentRouting 2026-08-07-03:46: + * Workflow authority is a narrow session capability, not an agent-wide policy + * change. A task mutation aimed at another task must retain ordinary policy + * even if the caller holds a live authority token for this task. + */ +export async function hasLiveWorkflowAuthority( + context: AgentActionGateContext, + args: Record, + toolName?: string, +): Promise { + const authority = context.workflowAuthority; + if (!authority + || authority.principalAgentId !== context.agentId + || authority.taskId !== context.taskId + || authority.runId !== context.runId) return false; + + const targetTaskId = typeof args.id === "string" + ? args.id + : typeof args.task_id === "string" + ? args.task_id + : undefined; + if (targetTaskId && targetTaskId !== authority.taskId) return false; + + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:40: + A task-scoped workflow grant may cover work on its own task, not board, + workflow, mission, or agent administration. In particular, planning may + propose follow-up tasks, but creating or delegating them remains governed by + the principal's ordinary policy instead of inheriting the plan's elevation. + */ + const taskScopedTools = new Set([ + "fn_task_add_dep", + "fn_task_update", + ]); + if (toolName && !taskScopedTools.has(toolName) && ( + toolName.startsWith("fn_task_") + || toolName.startsWith("fn_agent_") + || toolName.startsWith("fn_workflow_") + || toolName.startsWith("fn_mission_") + || toolName.startsWith("fn_milestone_") + || toolName.startsWith("fn_slice_") + || toolName.startsWith("fn_feature_") + || toolName.startsWith("fn_ideation_") + || toolName === "fn_delegate_task" + || toolName === "fn_spawn_agent" + || toolName === "fn_update_agent_config" + )) return false; + + return await authority.isLive(); +} + export function resolveGateOutcome( decision: AgentActionGateDecision, latestRequest: { id: string; status: ApprovalRequestStatus; decidedAt?: string } | null, diff --git a/packages/engine/src/agents/agent-assignment.ts b/packages/engine/src/agents/agent-assignment.ts deleted file mode 100644 index 0c725dbb60..0000000000 --- a/packages/engine/src/agents/agent-assignment.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core"; -import { isAgentAutoAssignable, isEphemeralAgent } from "@fusion/core"; - -/* -FNXC:WorkflowLifecycleColumns 2026-07-31-05:40 (batch-engine feed): -The lanes an assigned card still counts as LOAD against its agent. - -CENSUS-INVISIBLE: this is a `Set` literal, i.e. a definition rather than a comparison, so nothing in -the lifecycle backlog ever pointed at this file. Found by grepping for lane-shaped list literals -after the same shape turned up in `duplicate-intake` and `blocker-fanout`. - -The failure is a silent DEGRADATION, not an error. This set gates the per-agent assignment-load -tally used to pick the least-loaded agent. On a renamed board no task's column matched, so -`assignmentLoad` stayed empty, every candidate compared as load 0, and the sort fell straight through -to its `createdAt` tiebreak — which is stable. The result is that the SAME agent wins every -assignment while the others sit idle. Nothing logs, nothing fails; the board just distributes badly. - -DELIBERATE-LITERAL — the fallback for a caller that cannot resolve lanes, reviewed 2026-07-31-05:40. -*/ -const LEGACY_ACTIVE_COLUMNS: ReadonlySet = new Set(["todo", "in-progress", "in-review"]); - -type SelectPermanentAgentForTaskOptions = { - task: Task; - agentStore: Pick; - taskStore: Pick; - /* - FNXC:WorkflowLifecycleColumns 2026-07-31-11:40 (#2787 review — greptile P1, third round): - A PER-TASK predicate, not a flat set. - - The flat `activeColumns` I first added was resolved from the CANDIDATE task's workflow and then - applied to every row `listTasks` returned. On a project running several workflows, assignments in - another workflow's load-bearing lanes were omitted from the tally — the same - already-loaded-agent-wins bug this parameter exists to fix, now reachable through a different door. - - A column id is meaningful only RELATIVE TO ITS OWN WORKFLOW. `blocker-fanout.ts` documents exactly - this and offers `classify` for it; this mirrors that shape rather than inventing a third one. - - Omitted → the legacy trio, i.e. today's behaviour. - */ - countsAsAssignmentLoad?: (task: Task) => boolean; -}; - -function isAgentEnabled(agent: Agent): boolean { - return (agent.runtimeConfig?.enabled as boolean | undefined) !== false; -} - -/** - * Permanent, enabled, non-errored executor agents — the pool the scheduler can - * auto-assign mission/queue tasks to when ephemeral agents are disabled. - * - * Catalog-imported "company" agents land with role "custom" (see - * mapRoleToCapability) and are therefore NOT in this pool, which is why a - * mission can silently stall when ephemeral agents are off and the only agents - * present came from an import. Callers use this to preflight that situation. - */ -export async function listEligibleExecutorAgents( - agentStore: Pick, -): Promise { - const agents = await agentStore.listAgents({ role: "executor", includeEphemeral: true }); - /* - FNXC:AgentRouting 2026-07-12-12:15: - Issue #2015 (NEXT-871): the scheduler auto-assign pool admitted EVERY enabled executor-role agent, so a - liaison-type agent whose role field is "executor" was round-robin-assigned product-code tasks. Agents with - runtimeConfig.assignmentPolicy "explicit-only"/"none" are excluded from all automatic assignment. - */ - return agents.filter( - (agent) => agent.role === "executor" - && !isEphemeralAgent(agent) - && agent.state !== "error" - && isAgentEnabled(agent) - && isAgentAutoAssignable(agent), - ); -} - -function taskLinksToScope(task: Pick, scopeTask: Pick): boolean { - if (task.id === scopeTask.id) return false; - if (scopeTask.sliceId && task.sliceId === scopeTask.sliceId) return true; - if (scopeTask.missionId && task.missionId === scopeTask.missionId) return true; - return false; -} - -export async function selectPermanentAgentForTask({ task, agentStore, taskStore, countsAsAssignmentLoad }: SelectPermanentAgentForTaskOptions): Promise { - const eligibleAgents = await listEligibleExecutorAgents(agentStore); - - if (eligibleAgents.length === 0) { - return null; - } - - const allTasks = await taskStore.listTasks({ slim: true }); - - const linkedAssignedAgentIds = new Set(); - if (task.missionId || task.sliceId) { - for (const candidateTask of allTasks) { - if (!candidateTask.assignedAgentId) continue; - if (taskLinksToScope(candidateTask, task)) { - linkedAssignedAgentIds.add(candidateTask.assignedAgentId); - } - } - } - - const preferredAgentIds = new Set(); - for (const linkedAgentId of linkedAssignedAgentIds) { - preferredAgentIds.add(linkedAgentId); - const chain = await agentStore.getChainOfCommand(linkedAgentId).catch(() => []); - for (const chainAgent of chain) { - preferredAgentIds.add(chainAgent.id); - } - } - - const preferredEligible = eligibleAgents.filter((agent) => preferredAgentIds.has(agent.id)); - const candidatePool = preferredEligible.length > 0 ? preferredEligible : eligibleAgents; - - const assignmentLoad = new Map(); - for (const taskItem of allTasks) { - const bearsLoad = countsAsAssignmentLoad - ? countsAsAssignmentLoad(taskItem) - /* DELIBERATE-LITERAL — the unconverted-caller default, reviewed 2026-07-31-05:40. */ - : LEGACY_ACTIVE_COLUMNS.has(taskItem.column); - if (!taskItem.assignedAgentId || !bearsLoad) continue; - assignmentLoad.set(taskItem.assignedAgentId, (assignmentLoad.get(taskItem.assignedAgentId) ?? 0) + 1); - } - - const sorted = [...candidatePool].sort((a, b) => { - const loadA = assignmentLoad.get(a.id) ?? 0; - const loadB = assignmentLoad.get(b.id) ?? 0; - if (loadA !== loadB) return loadA - loadB; - - const createdAtCompare = a.createdAt.localeCompare(b.createdAt); - if (createdAtCompare !== 0) return createdAtCompare; - - return a.id.localeCompare(b.id); - }); - - return sorted[0] ?? null; -} diff --git a/packages/engine/src/agents/agent-reflection.ts b/packages/engine/src/agents/agent-reflection.ts index 0873f6bdc0..49430526a9 100644 --- a/packages/engine/src/agents/agent-reflection.ts +++ b/packages/engine/src/agents/agent-reflection.ts @@ -834,6 +834,7 @@ export class AgentReflectionService { return { id: agentId, name: `Unknown Agent (${agentId})`, + roles: ["custom"], role: "custom", state: "idle", createdAt: now, diff --git a/packages/engine/src/agents/ephemeral-worker-manager.ts b/packages/engine/src/agents/ephemeral-worker-manager.ts deleted file mode 100644 index 73597289ee..0000000000 --- a/packages/engine/src/agents/ephemeral-worker-manager.ts +++ /dev/null @@ -1,429 +0,0 @@ -/** - * EphemeralWorkerManager - * - * Owns the lifecycle of runtime-spawned task-worker agents — the short-lived - * `executor-FN-XXXX` agents created by the executor to track ownership of an - * in-progress task. Coordinates between TaskExecutor callbacks and AgentStore - * so that workers are: - * - * - Spawned at most once per task (deduplicated across runtime restarts). - * - Cleaned up when the task completes, errors, or the parent runtime is - * restarted with stale on-disk state from a previous session. - * - Reconciled on startup: anything not bound to an in-progress task is a - * zombie and gets deleted. - * - * This logic used to live inline inside InProcessRuntime, where it relied - * solely on an in-memory `taskAgentMap` that reset on every process start. - * That meant any restart between `onStart` and `onComplete` would orphan - * the ephemeral worker on disk; over time hundreds piled up. The dedup - * lookup on creation, the on-disk fallback on completion, and the startup - * sweep here close that gap. - */ -import type { AgentStore, AgentState, Agent, TaskStore, Task, Settings } from "@fusion/core"; -import { isEphemeralAgent, resolveWorkflowIrForTask, columnsWithFlag } from "@fusion/core"; - -/* -FNXC:WorkflowResolvedColumns 2026-07-31-15:45 (fleet — the WIP half of an existing fallback): -DELIBERATE-LITERAL — the unresolvable-workflow default, alongside `TERMINAL_TASK_COLUMNS`. - -That block already answers its terminal question through a named set and its WIP question through an -inline `!== "in-progress"`. Both are the same documented fallback; only one was counted, because the -census reads inline comparisons regardless of the branch they sit in while a named set is a -definition. Naming this one makes the pair consistent and stops it reading as unconverted debt. -*/ -const LEGACY_WIP_LANES: ReadonlySet = new Set(["in-progress"]); - -export interface TaskOwner { - agentId: string; - /** True for runtime-spawned workers; false for durable assigned agents. */ - ephemeral: boolean; -} - -export interface EphemeralWorkerLogger { - log: (msg: string, ...rest: unknown[]) => void; - warn: (msg: string, ...rest: unknown[]) => void; - /** Optional; demoted steady-state skips use debug when present (FUSION_DEBUG). */ - debug?: (msg: string, ...rest: unknown[]) => void; -} - -export interface EphemeralWorkerManagerOptions { - agentStore: AgentStore; - taskStore: TaskStore; - logger: EphemeralWorkerLogger; - /** - * External pending-deletion check — TaskExecutor maintains its own set - * for spawned-child cleanup; we treat those as "already handled" so we - * don't race the executor on the same agentId. - */ - isDeletionPendingExternal?: (agentId: string) => boolean; - getSettings?: () => Promise>; -} - -const TERMINAL_TASK_COLUMNS = new Set(["done", "archived"]); - -export class EphemeralWorkerManager { - private readonly agentStore: AgentStore; - private readonly taskStore: TaskStore; - private readonly log: EphemeralWorkerLogger; - private readonly isDeletionPendingExternal: (agentId: string) => boolean; - private readonly getSettings: () => Promise>; - - /** taskId → owner. In-memory only; on-disk fallback covers restart gaps. */ - private readonly taskAgentMap = new Map(); - /** agentIds with in-flight delete; prevents racing parallel cleanup paths. */ - private readonly pendingDeletions = new Set(); - - private stateChangeListener?: (agentId: string, from: AgentState, to: AgentState) => void; - - constructor(options: EphemeralWorkerManagerOptions) { - this.agentStore = options.agentStore; - this.taskStore = options.taskStore; - this.log = options.logger; - this.isDeletionPendingExternal = options.isDeletionPendingExternal ?? (() => false); - this.getSettings = options.getSettings ?? (async () => ({ ephemeralAgentsEnabled: true })); - } - - // ── public surface ─────────────────────────────────────────────────────── - - /** - * Establish ownership for a task that just started executing. - * - If the task carries an `assignedAgentId` pointing at a durable agent, - * bind that agent to the task and flip it through active → running. - * - Otherwise spawn (or reclaim) an ephemeral `executor-${task.id}` worker. - * - * Cross-restart safe: looks up an existing ephemeral by name before - * creating a new one. - */ - async onTaskStart(task: Task): Promise { - try { - const assignedAgentId = task.assignedAgentId; - if (assignedAgentId) { - const assignedAgent = await this.agentStore.getAgent(assignedAgentId); - if (assignedAgent && !isEphemeralAgent(assignedAgent)) { - this.taskAgentMap.set(task.id, { agentId: assignedAgent.id, ephemeral: false }); - await this.agentStore.syncExecutionTaskLink(assignedAgent.id, task.id); - const currentState = assignedAgent.state; - if (currentState !== "running") { - if (currentState !== "active") { - await this.agentStore.updateAgentState(assignedAgent.id, "active"); - } - await this.agentStore.updateAgentState(assignedAgent.id, "running"); - } - return { agentId: assignedAgent.id, ephemeral: false }; - } - } - - // Already-tracked in this session: leave alone. - const cached = this.taskAgentMap.get(task.id); - if (cached) { - /* - FNXC:EngineDiagnostics 2026-08-03-05:54: - Already-owned tasks (assigned permanent agent or prior onTaskStart) hit this on every - start — expected re-entrance, not degradation. Was warn and yellow-flagged the TUI. - */ - if (this.log.debug) { - this.log.debug(`Skipping task-worker creation for ${task.id}: task already has execution owner`); - } - return cached; - } - - // Cross-restart dedup. taskAgentMap resets per process, so without - // this check a task started in a prior session would get a fresh - // duplicate on every retry — historically how `executor-FN-XXXX` - // duplicates piled up by the hundreds on disk. - const existing = await this.lookupExistingByName(`executor-${task.id}`); - if (existing) { - if (existing.taskId === task.id) { - this.taskAgentMap.set(task.id, { agentId: existing.id, ephemeral: true }); - this.log.log(`Reusing existing ephemeral worker ${existing.id} for task ${task.id} after restart`); - return { agentId: existing.id, ephemeral: true }; - } - // Stale ephemeral from a prior attempt — delete so the executor- name - // is reusable. - try { - await this.agentStore.deleteAgent(existing.id); - this.log.log(`Deleted stale ephemeral worker ${existing.id} for task ${task.id} before respawn`); - } catch (delErr) { - this.log.warn(`Failed to delete stale ephemeral worker ${existing.id} for ${task.id}:`, delErr); - } - } - - const settings = await this.getSettings(); - if (settings.ephemeralAgentsEnabled === false) { - this.log.warn( - `Task ${task.id} has no permanent agent assignment; ephemeralAgentsEnabled=false — refusing to spawn ephemeral worker`, - ); - return null; - } - - const agent = await this.agentStore.createAgent({ - name: `executor-${task.id}`, - role: "executor", - metadata: { - agentKind: "task-worker", - taskWorker: true, - managedBy: "task-executor", - }, - runtimeConfig: { enabled: false }, - }); - this.taskAgentMap.set(task.id, { agentId: agent.id, ephemeral: true }); - await this.agentStore.assignTask(agent.id, task.id); - await this.agentStore.updateAgentState(agent.id, "active"); - await this.agentStore.updateAgentState(agent.id, "running"); - return { agentId: agent.id, ephemeral: true }; - } catch (err) { - this.log.warn(`Failed to initialize execution owner for task ${task.id}:`, err); - return null; - } - } - - /** - * Tear down ownership after a task completes or errors. - * Final state for durable agents matches the outcome (idle/error). - * Ephemeral workers are deleted regardless; if the in-memory owner is - * missing (e.g. restart between onStart and this callback), falls back - * to a name-based lookup so the worker still gets cleaned up. - */ - async onTaskComplete(taskId: string): Promise { - // After a successful task, durable agents return to "active" (heartbeat - // ready). Ephemerals are deleted regardless. - return this.finalize(taskId, "active", "completion"); - } - - async onTaskError(taskId: string): Promise { - return this.finalize(taskId, "error", "error"); - } - - /** - * Listener for agent:stateChanged. Cleans up ephemerals that get halted - * out-of-band — e.g. by HeartbeatMonitor flipping them to paused/error - * outside the onComplete/onError callbacks. - * - * Returns the listener fn so the caller can detach it on shutdown. - */ - attachStateChangeListener(): (agentId: string, from: AgentState, to: AgentState) => void { - if (this.stateChangeListener) return this.stateChangeListener; - const listener = (agentId: string, from: AgentState, to: AgentState): void => { - if (to !== "paused" && to !== "error") return; - if (from === to) return; - if (this.pendingDeletions.has(agentId) || this.isDeletionPendingExternal(agentId)) return; - void (async () => { - try { - const agent = await this.agentStore.getAgent(agentId); - if (!agent) return; - const isWorkerLike = isEphemeralAgent(agent) - || agent.metadata?.taskWorker === true - || agent.metadata?.agentKind === "task-worker" - || agent.metadata?.agentKind === "spawned"; - if (!isWorkerLike) return; - await this.deleteEphemeralAgent(agentId, "halt-listener"); - } catch (err) { - this.log.warn(`Failed to process halt event for agent ${agentId}: ${this.formatError(err)}`); - } - })(); - }; - this.stateChangeListener = listener; - this.agentStore.on("agent:stateChanged", listener); - return listener; - } - - detachStateChangeListener(): void { - if (!this.stateChangeListener) return; - this.agentStore.off("agent:stateChanged", this.stateChangeListener); - this.stateChangeListener = undefined; - } - - /** - * Startup sweep. Returns the count of zombies cleaned up. Best-effort — - * failures are logged and skipped so they never block runtime startup. - * - * Survivors after this pass: agents bound to a still-in-progress task. - * Anything else (no taskId, terminal task column, or halted state) is - * by definition a leak. - */ - async reconcileOrphaned(): Promise { - let cleanedCount = 0; - try { - const allAgents = await this.agentStore.listAgents({ includeEphemeral: true }); - for (const agent of allAgents) { - if (!isEphemeralAgent(agent)) continue; - if (!(await this.shouldDeleteOnSweep(agent))) continue; - try { - await this.agentStore.deleteAgent(agent.id); - cleanedCount += 1; - } catch (err) { - if (this.isBenignDeleteRace(agent.id, err)) { - cleanedCount += 1; - continue; - } - this.log.warn(`Startup sweep failed to delete ephemeral agent ${agent.id}: ${this.formatError(err)}`); - } - } - } catch (err) { - this.log.warn(`Startup ephemeral sweep failed: ${this.formatError(err)}`); - } - if (cleanedCount > 0) { - this.log.log(`Startup ephemeral sweep cleaned ${cleanedCount} orphaned agent(s)`); - } - return cleanedCount; - } - - /** Drop in-memory state. Call on runtime stop. */ - reset(): void { - this.taskAgentMap.clear(); - this.pendingDeletions.clear(); - } - - /** True if a delete is in flight; lets external callers avoid double-delete races. */ - isDeletionPending(agentId: string): boolean { - return this.pendingDeletions.has(agentId); - } - - getOwner(taskId: string): TaskOwner | undefined { - return this.taskAgentMap.get(taskId); - } - - // ── internals ──────────────────────────────────────────────────────────── - - private async finalize( - taskId: string, - terminalState: "active" | "error", - reason: "completion" | "error", - ): Promise { - const owner = this.taskAgentMap.get(taskId) ?? await this.recoverOwnerFromDisk(taskId); - if (!owner) return; - const { agentId, ephemeral } = owner; - if (ephemeral) { - this.pendingDeletions.add(agentId); - } - - const effectiveTerminalState = reason === "error" && !ephemeral ? "active" : terminalState; - - try { - await this.agentStore.updateAgentState(agentId, effectiveTerminalState); - } catch (err) { - this.log.warn(`Failed to update agent ${agentId} to ${effectiveTerminalState} (${reason}): ${this.formatError(err)}`); - } - try { - await this.agentStore.syncExecutionTaskLink(agentId, undefined); - } catch (err) { - this.log.warn(`Failed to clear execution task link for agent ${agentId} on ${reason}: ${this.formatError(err)}`); - } - this.taskAgentMap.delete(taskId); - - if (!ephemeral) return; - try { - await this.agentStore.deleteAgent(agentId); - } catch (err) { - if (this.isBenignDeleteRace(agentId, err)) return; - this.log.warn(`Failed to delete agent ${agentId} after ${reason}: ${this.formatError(err)}`); - } finally { - this.pendingDeletions.delete(agentId); - } - } - - /** - * Look up the ephemeral worker on disk when the in-memory map has no - * record. Covers the cross-restart case where onComplete fires in a - * different process session than the onStart that created the worker. - */ - private async recoverOwnerFromDisk(taskId: string): Promise { - try { - const candidate = await this.lookupExistingByName(`executor-${taskId}`); - if (candidate) { - this.log.log(`Recovered ephemeral owner ${candidate.id} for task ${taskId} from disk (cross-restart)`); - return { agentId: candidate.id, ephemeral: true }; - } - } catch (err) { - this.log.warn(`Cross-restart owner lookup failed for task ${taskId}: ${this.formatError(err)}`); - } - return null; - } - - private async lookupExistingByName(name: string): Promise { - try { - const found = await this.agentStore.findAgentByName(name); - if (found && isEphemeralAgent(found)) return found; - return null; - } catch (err) { - this.log.warn(`findAgentByName(${name}) failed: ${this.formatError(err)}`); - return null; - } - } - - private async shouldDeleteOnSweep(agent: Agent): Promise { - // Halt states are always zombies — the live path would have deleted them. - if (agent.state === "paused" || agent.state === "error") return true; - // No task binding means no work in progress. - if (!agent.taskId) return true; - try { - const task = await this.taskStore.getTask(agent.taskId); - if (!task) return true; - /* - FNXC:WorkflowLifecycleColumns 2026-07-31-06:10 (engine feed): - A LIVE WORKER WAS REAPED AS A ZOMBIE ON A RENAMED BOARD. - - Census-invisible: the terminal check is a `Set` literal (a definition, not a comparison), and - the wip check below it was the bare literal. Both missed on a renamed board, and they compound - in the WORST order — the terminal test failed, so control fell to - `return task.column !== "in-progress"`, which is TRUE for a renamed wip lane. An ephemeral - worker actively executing a task was therefore classified as a zombie and deleted by the sweep. - - Resolved from the task's OWN workflow. The `catch` below already treats an unreadable task as a - broken binding, so an unresolvable workflow keeps the documented literals rather than inventing - a lane: failing to reap a dead worker costs a slot, while reaping a live one destroys work in - flight, and those are not symmetric. - */ - /* - FNXC:WorkflowLifecycleColumns 2026-07-31-09:30 (#2787 review — greptile P1): - MEMBERSHIP, not first-per-role. `resolveLifecycleColumns` returns the FIRST column carrying each - trait, so a workflow declaring TWO implementation lanes had only one of them recognised — a live - worker in the second lane was still classified as a zombie and deleted. Same defect this commit - exists to fix, one degree narrower, and it would have reappeared the first time someone declared - a second wip lane. - - `columnsWithFlag` returns every column carrying the trait, so both halves are unions. - */ - const ir = await resolveWorkflowIrForTask(this.taskStore, task.id).catch(() => undefined); - if (ir === undefined) { - /* DELIBERATE-LITERAL — the unresolvable-workflow default, reviewed 2026-07-31-06:10. */ - if (TERMINAL_TASK_COLUMNS.has(task.column)) return true; - return !LEGACY_WIP_LANES.has(task.column); - } - const terminalLanes = new Set([ - ...columnsWithFlag(ir, "complete"), - ...columnsWithFlag(ir, "archived"), - ]); - if (terminalLanes.has(task.column)) return true; - const wipLanes = new Set(columnsWithFlag(ir, "countsTowardWip")); - return !wipLanes.has(task.column); - } catch { - // If we can't even read the task, assume the binding is broken. - return true; - } - } - - private async deleteEphemeralAgent(agentId: string, reason: string): Promise { - if (this.pendingDeletions.has(agentId)) return; - this.pendingDeletions.add(agentId); - try { - await this.agentStore.deleteAgent(agentId); - } catch (err) { - if (this.isBenignDeleteRace(agentId, err)) return; - this.log.warn(`Failed to delete ephemeral agent ${agentId} (${reason}): ${this.formatError(err)}`); - } finally { - this.pendingDeletions.delete(agentId); - } - } - - private isBenignDeleteRace(agentId: string, err: unknown): boolean { - const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); - if (msg.includes("already deleted") || msg.includes("already removed")) return true; - if (msg.includes(`agent ${agentId.toLowerCase()} not found`)) return true; - return false; - } - - private formatError(err: unknown): string { - return err instanceof Error ? err.message : String(err); - } -} diff --git a/packages/engine/src/agents/workflow-agent-capacity.ts b/packages/engine/src/agents/workflow-agent-capacity.ts new file mode 100644 index 0000000000..7de73f891b --- /dev/null +++ b/packages/engine/src/agents/workflow-agent-capacity.ts @@ -0,0 +1,113 @@ +import type { Agent } from "@fusion/core"; + +/** A capacity lease is intentionally separate from heartbeat run accounting. */ +export interface WorkflowAgentCapacityLease { + readonly projectId: string; + readonly agentId: string; + readonly attemptId: string; +} + +export type WorkflowAgentCapacityResult = + | { status: "acquired"; lease: WorkflowAgentCapacityLease } + | { status: "held"; reason: "project-capacity" | "agent-capacity" }; + +/** The durable AgentStore seam keeps capacity correct when engine processes scale out. */ +export interface WorkflowCapacityLeaseStore { + acquireWorkflowSessionCapacity(input: { + agentId: string; + attemptId: string; + maxProjectSessions?: number; + maxAgentSessions?: number; + leaseDurationMs?: number; + }): Promise<"acquired" | "project-capacity" | "agent-capacity">; + renewWorkflowSessionCapacity?(attemptId: string, leaseDurationMs?: number): Promise; + releaseWorkflowSessionCapacity(attemptId: string): Promise; +} + +/** + * FNXC:WorkflowAgentRouting 2026-08-07-05:52: + * Workflow sessions use database-backed, project-scoped reservations so two + * engines cannot admit the same slot. Heartbeat concurrency remains separate. + * The local map is only a reentrancy cache; durable AgentStore leases are the + * cross-process source of truth and releases are intentionally idempotent. + */ +export class WorkflowAgentCapacity { + private static readonly LEASE_DURATION_MS = 10 * 60_000; + private readonly leases = new Map(); + private readonly renewals = new Map>(); + + public constructor(private readonly leaseStore?: WorkflowCapacityLeaseStore) {} + + private attemptKey(projectId: string, attemptId: string): string { + return `${projectId}\u0000${attemptId}`; + } + + public async acquire(input: { + projectId: string; + agent: Pick; + attemptId: string; + maxProjectSessions?: number; + }): Promise { + const attemptKey = this.attemptKey(input.projectId, input.attemptId); + const existing = this.leases.get(attemptKey); + if (existing) return { status: "acquired", lease: existing }; + const agentLimit = input.agent.runtimeConfig?.maxWorkflowSessions; + const maxAgentSessions = typeof agentLimit === "number" && Number.isFinite(agentLimit) + ? agentLimit + : undefined; + if (this.leaseStore) { + const outcome = await this.leaseStore.acquireWorkflowSessionCapacity({ + agentId: input.agent.id, + attemptId: input.attemptId, + maxProjectSessions: input.maxProjectSessions, + maxAgentSessions, + leaseDurationMs: WorkflowAgentCapacity.LEASE_DURATION_MS, + }); + if (outcome !== "acquired") return { status: "held", reason: outcome }; + } else { + // Unit-test/local fallback retains deterministic semantics without pretending to coordinate processes. + const local = [...this.leases.values()].filter((lease) => lease.projectId === input.projectId); + if (input.maxProjectSessions !== undefined && local.length >= input.maxProjectSessions) return { status: "held", reason: "project-capacity" }; + if (maxAgentSessions !== undefined && local.filter((lease) => lease.agentId === input.agent.id).length >= maxAgentSessions) return { status: "held", reason: "agent-capacity" }; + } + const lease = { projectId: input.projectId, agentId: input.agent.id, attemptId: input.attemptId }; + this.leases.set(attemptKey, lease); + this.startRenewal(attemptKey, input.attemptId); + return { status: "acquired", lease }; + } + + private startRenewal(key: string, attemptId: string): void { + if (!this.leaseStore?.renewWorkflowSessionCapacity || this.renewals.has(key)) return; + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:16: + * Live model work may outlast the crash-reclaim TTL. Renew below half-life; + * if the process dies this timer dies too and the durable row becomes safely + * reclaimable, while a failed renewal never revives an expired attempt. + */ + const timer = setInterval(() => { + void this.leaseStore?.renewWorkflowSessionCapacity?.(attemptId, WorkflowAgentCapacity.LEASE_DURATION_MS) + .catch(() => false); + }, WorkflowAgentCapacity.LEASE_DURATION_MS / 3); + timer.unref?.(); + this.renewals.set(key, timer); + } + + public async release(attemptId: string, projectId?: string): Promise { + const key = projectId + ? this.attemptKey(projectId, attemptId) + : [...this.leases.entries()].find(([, lease]) => lease.attemptId === attemptId)?.[0]; + if (!key) return false; + const lease = this.leases.get(key); + if (!lease) return false; + this.leases.delete(key); + const timer = this.renewals.get(key); + if (timer) clearInterval(timer); + this.renewals.delete(key); + await this.leaseStore?.releaseWorkflowSessionCapacity(attemptId); + return true; + } + + public activeSessions(agentId: string, projectId?: string): number { + return [...this.leases.values()].filter((lease) => lease.agentId === agentId && (!projectId || lease.projectId === projectId)).length; + } +} diff --git a/packages/engine/src/agents/workflow-agent-router.ts b/packages/engine/src/agents/workflow-agent-router.ts new file mode 100644 index 0000000000..f24148e328 --- /dev/null +++ b/packages/engine/src/agents/workflow-agent-router.ts @@ -0,0 +1,185 @@ +import { + classifyWorkflowAgentNode, + isEphemeralAgent, + resolveColumnAgentBinding, + type Agent, + type TaskDetail, + type WorkflowAgentRole, + type WorkflowIr, + type WorkflowIrNode, +} from "@fusion/core"; + +export type WorkflowPrincipalAuthority = "task-assignee" | "review-node-override" | "column-binding" | "role-pool"; + +export interface WorkflowPrincipalRoute { + agent: Agent; + role: WorkflowAgentRole; + authority: WorkflowPrincipalAuthority; +} + +export type WorkflowPrincipalRouteResult = + | { status: "unclassified" } + | { status: "held"; role: WorkflowAgentRole; reason: "named-principal-unavailable" | "role-pool-exhausted" } + | { status: "routed"; route: WorkflowPrincipalRoute }; + +/** + * FNXC:WorkflowAgentRouting 2026-08-07-04:31: + * A claimed work item is a principal fence, not routing metadata. Resume must + * revalidate the stored principal against the current task and exact reviewer + * node, then use that same durable identity rather than resolving a new pool + * candidate. A changed owner or edited-away review override fails closed. + */ +/** + * FNXC:WorkflowAgentRouting 2026-08-07-04:56: + * A reviewer override is an exact-node capability. Session tool gates use this + * helper after reloading the workflow IR so editing/removing an override revokes + * authority immediately rather than leaving a task-wide reviewer grant alive. + */ +export function isCurrentReviewerNodeOverride( + ir: WorkflowIr | undefined, + nodeInstanceId: string, + principalAgentId: string, +): boolean { + const node = findWorkflowNodeInstance(ir, nodeInstanceId); + return node?.reviewerAgentId === principalAgentId && classifyWorkflowAgentNode(node) === "reviewer"; +} + +/** + * FNXC:WorkflowAgentRouting 2026-08-07-05:29: + * Reviewer authority is fenced to the instantiated template node, not merely a + * top-level ID. A foreach review attempt uses `#:`, so + * resolving only `ir.nodes` would leave an edited nested override authorized. + */ +export function findWorkflowNodeInstance(ir: WorkflowIr | undefined, nodeInstanceId: string): WorkflowIrNode | undefined { + if (!ir) return undefined; + return findTemplateNodeInstance(ir.nodes, nodeInstanceId); +} + +/** Resolve recursively because template containers may be nested. */ +function findTemplateNodeInstance(nodes: readonly WorkflowIrNode[], nodeInstanceId: string): WorkflowIrNode | undefined { + const direct = nodes.find((node) => node.id === nodeInstanceId); + if (direct) return direct; + + for (const container of nodes) { + const templateNodes = (container.config as { template?: { nodes?: WorkflowIrNode[] } } | undefined)?.template?.nodes; + if (!templateNodes) continue; + const optionalPrefix = `${container.id}::`; + if (nodeInstanceId.startsWith(optionalPrefix)) { + const nested = findTemplateNodeInstance(templateNodes, nodeInstanceId.slice(optionalPrefix.length)); + if (nested) return nested; + } + const iterationPrefix = `${container.id}#`; + if (!nodeInstanceId.startsWith(iterationPrefix)) continue; + const separator = nodeInstanceId.indexOf(":", iterationPrefix.length); + if (separator < 0 || !/^\d+$/.test(nodeInstanceId.slice(iterationPrefix.length, separator))) continue; + const nested = findTemplateNodeInstance(templateNodes, nodeInstanceId.slice(separator + 1)); + if (nested) return nested; + } + return undefined; +} + +export function validateFencedWorkflowPrincipal(input: { + task: Pick; + ir?: WorkflowIr; + node: WorkflowIrNode; + principalAgentId: string; + role: WorkflowAgentRole; + authority: WorkflowPrincipalAuthority; + agents: readonly Agent[]; + /** Instantiated template identity used to revalidate exact-node review authority. */ + nodeInstanceId?: string; + activeSessions?: ReadonlyMap; +}): WorkflowPrincipalRouteResult { + const classifiedRole = classifyWorkflowAgentNode(input.node); + if (classifiedRole !== input.role) { + return { status: "held", role: input.role, reason: "named-principal-unavailable" }; + } + if (input.authority === "task-assignee" && input.task.assignedAgentId !== input.principalAgentId) { + return { status: "held", role: input.role, reason: "named-principal-unavailable" }; + } + if (input.authority === "review-node-override" && ( + input.nodeInstanceId + ? !isCurrentReviewerNodeOverride(input.ir, input.nodeInstanceId, input.principalAgentId) + : input.node.reviewerAgentId !== input.principalAgentId + )) { + return { status: "held", role: input.role, reason: "named-principal-unavailable" }; + } + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:45: + * A column binding is named authority too. A resumed work item must reject a + * binding that was removed or redirected; otherwise an old column principal + * could keep acting after an operator changed workflow routing. + */ + if (input.authority === "column-binding" && (!input.ir || resolveColumnAgentBinding(input.ir, input.node.id)?.agentId !== input.principalAgentId)) { + return { status: "held", role: input.role, reason: "named-principal-unavailable" }; + } + const agent = input.agents.find((candidate) => candidate.id === input.principalAgentId); + return available(agent, input.activeSessions ?? new Map()) + ? { status: "routed", route: { agent, role: input.role, authority: input.authority } } + : { status: "held", role: input.role, reason: "named-principal-unavailable" }; +} + +/** A named principal is never silently replaced once a precedence branch names it. */ +function available(agent: Agent | undefined, activeSessions: ReadonlyMap): agent is Agent { + /* + * FNXC:WorkflowAgentRouting 2026-08-07-03:38: + * Workflow-stage routing may only select durable operator-visible principals. + * Legacy task workers remain transient implementation infrastructure and must + * never satisfy a role-pool route, even when their singular compatibility role + * matches. A named transient identity is likewise unavailable and holds closed. + */ + if (!agent || isEphemeralAgent(agent) || agent.state === "paused" || agent.state === "error") return false; + const max = agent.runtimeConfig?.maxWorkflowSessions; + return typeof max !== "number" || activeSessions.get(agent.id) === undefined || activeSessions.get(agent.id)! < max; +} + +/** + * FNXC:WorkflowAgentRouting 2026-08-07-03:12: + * Routing selects a durable principal deterministically and never mutates task + * ownership. A reviewer override applies only to the exact classified node; + * unavailable named identities hold instead of falling through to the pool. + */ +export function routeWorkflowPrincipal(input: { + task: Pick; + ir: WorkflowIr; + node: WorkflowIrNode; + agents: readonly Agent[]; + activeSessions?: ReadonlyMap; + /** + * FNXC:WorkflowAgentRouting 2026-08-07-07:32: + * Agent-capacity admission may race another engine after this process took its + * snapshot. Retrying a pool route excludes only the contender that lost that + * durable race; named authority must always hold closed instead. + */ + excludedPoolAgentIds?: ReadonlySet; +}): WorkflowPrincipalRouteResult { + const role = classifyWorkflowAgentNode(input.node); + if (!role) return { status: "unclassified" }; + const activeSessions = input.activeSessions ?? new Map(); + const byId = new Map(input.agents.map((agent) => [agent.id, agent])); + const named = (id: string | undefined, authority: WorkflowPrincipalAuthority): WorkflowPrincipalRouteResult | undefined => { + if (!id) return undefined; + const agent = byId.get(id); + return available(agent, activeSessions) + ? { status: "routed", route: { agent, role, authority } } + : { status: "held", role, reason: "named-principal-unavailable" }; + }; + if (role === "reviewer") { + const overridden = named(input.node.reviewerAgentId, "review-node-override"); + if (overridden) return overridden; + } + const owner = named(input.task.assignedAgentId, "task-assignee"); + if (owner) return owner; + const column = resolveColumnAgentBinding(input.ir, input.node.id); + const bound = named(column?.agentId, "column-binding"); + if (bound) return bound; + const pool = input.agents + .filter((agent) => !input.excludedPoolAgentIds?.has(agent.id) + && agent.roles.includes(role) && available(agent, activeSessions)) + .sort((left, right) => (activeSessions.get(left.id) ?? 0) - (activeSessions.get(right.id) ?? 0) + || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); + const agent = pool[0]; + return agent + ? { status: "routed", route: { agent, role, authority: "role-pool" } } + : { status: "held", role, reason: "role-pool-exhausted" }; +} diff --git a/packages/engine/src/execution/reviewer.ts b/packages/engine/src/execution/reviewer.ts index 41e4f29c12..b03cf762a0 100644 --- a/packages/engine/src/execution/reviewer.ts +++ b/packages/engine/src/execution/reviewer.ts @@ -261,8 +261,12 @@ export async function reviewStep( ? (_id, delta) => options.onText!(delta) : undefined, persistAgentToolOutput: effectiveSettings?.persistAgentToolOutput, - // Reviewer sessions are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(effectiveSettings, { ephemeral: true }), + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:13: + * Review is a durable workflow role, not an ephemeral stage worker. + * Preserve the permanent-agent thinking-log policy for review sessions. + */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(effectiveSettings, { ephemeral: false }), }) : null; @@ -319,8 +323,19 @@ export async function reviewStep( let reviewerInstructions = ""; if (options.agentStore && options.rootDir) { try { - const agents = await options.agentStore.listAgents({ role: "reviewer" }); - for (const agent of agents) { + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:45: + * A graph-fenced reviewer may carry any role tag. When the caller names + * that principal, use its own instructions instead of silently selecting + * the first reviewer-tagged agent from the pool. + */ + const explicitAgent = options.agentId + ? await options.agentStore.getAgent(options.agentId).catch(() => null) + : null; + const candidates = explicitAgent + ? [explicitAgent] + : await options.agentStore.listAgents({ role: "reviewer" }); + for (const agent of candidates) { if (agent.instructionsText || agent.instructionsPath) { const memoryMode = resolveAgentMemoryInclusionMode({ agent, globalSettings: effectiveSettings }).mode; reviewerInstructions = await resolveAgentInstructions(agent, options.rootDir, undefined, memoryMode); @@ -375,7 +390,8 @@ export async function reviewStep( } } - const assignedAgentId = options.task?.assignedAgentId ?? null; + // A routed reviewer principal owns its memory/runtime identity for this session. + const assignedAgentId = options.agentId ?? options.task?.assignedAgentId ?? null; const agentStore = options.agentStore; const memoryAgent = options.rootDir diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 85d0dc5591..e01568823a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -16,7 +16,7 @@ import { DEFAULT_PROVIDER_INSTANCE_ID, type ProviderInstanceRef, type TaskStore, import { getUnmetSchedulingDependencies } from "./scheduler.js"; import type { ImplementationExit, ImplementationExitReporter } from "./executor/implementation-exit.js"; import { emitWorkflowLifecycleEvent } from "@fusion/core"; -import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, hasUserAutoMergeHold, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core"; +import { resolveTaskLifecycleColumns, resolveProjectColumnsForRoles, resolveWipTargetForTask, resolveTerminalColumns, RetryStormError, serializeRetryStormError, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, resolveWorkflowIrForTask, columnsWithFlag, evaluateForeachMergeProof, resolveCompleteColumn, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveLifecycleColumns, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, hasUserAutoMergeHold, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, DEFAULT_MAX_POST_REVIEW_FIXES, COMPLETION_SUMMARY_NODE_ID, PLAN_REVIEW_GROUP_ID, upsertWorkflowStepResult, normalizeWorkflowReviewFindings, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AgentStore, classifyWorkflowAgentNode, isWorkflowAgentRole, resolveExecutorFallbackModel, resolveValidatorFallbackModel, parseExplicitDuplicateMarker, nonExecutableDuplicateRedirectReason } from "@fusion/core"; import { BLOCKED_THRASH_LIMIT, buildExternalBlockMetadataPatch, @@ -31,6 +31,8 @@ import { generateFeatureVideo, type GenerateFeatureVideoOptions } from "./review import { moveTaskToReplanColumn, resolvePlannerLanes, resolvePlannerLanesForTaskAsync, resolveReplanTargetColumn } from "./execution/replan-target.js"; import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult, WorkflowWorkItem, TaskMoveLanes } from "@fusion/core"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult, type WorkflowColumnBoundaryHooks } from "./workflows/workflow-graph-task-runner.js"; +import { isCurrentReviewerNodeOverride, routeWorkflowPrincipal, validateFencedWorkflowPrincipal } from "./agents/workflow-agent-router.js"; +import { WorkflowAgentCapacity } from "./agents/workflow-agent-capacity.js"; import { createExecutorColumnBoundaryHooks } from "./workflow-column-boundary-hooks.js"; import { ensureWorkflowCompletionSummary } from "./workflows/workflow-completion-summary.js"; import { createCodeNodeRunner } from "./execution/code-node-runner.js"; @@ -1965,6 +1967,34 @@ export class TaskExecutor { activeWorktrees tracks the worktree paths a task currently holds for liveness/owner checks. In workspace mode a single task acquires N sub-repo worktrees (foundation `task.workspaceWorktrees`), so the value is a SET of paths, not one path. A non-workspace (single-repo) task holds a one-element set — every consumer is converted to membership semantics so the single-repo path is byte-for-byte unchanged (KTD2). Helpers below add/remove/iterate the set. */ private activeWorktrees = new Map>(); + /** Workflow stage reservations are intentionally independent from heartbeat slots. */ + private readonly workflowAgentCapacity: WorkflowAgentCapacity; + /** + * FNXC:WorkflowAgentRouting 2026-08-07-03:46: + * This process-local index carries a durable work item's narrow authority to + * the model tool gate. It is keyed by task only for the live graph turn and + * is removed in graph cleanup; `isLive` also revalidates the exact record so + * a replaced node cannot inherit authority from its predecessor. + */ + private readonly activeWorkflowAuthorities = new Map(); + + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:13: + * Every classified graph session must run as its routed durable principal, + * including pool and column routes that intentionally receive no policy + * elevation. Keep identity separate from the narrower authority index so a + * column/pool principal cannot inherit task-assignee tool privileges. + */ + private readonly activeWorkflowPrincipals = new Map(); /** * FNXC:Workspace 2026-06-21-12:00: Register a worktree path under a task's active set, creating the set on first add (KTD2). Single-repo tasks call this once → one-element set. @@ -2669,13 +2699,71 @@ export class TaskExecutor { const actorName = agent?.name ?? `Task worker ${taskId ?? "unknown"}`; const isEphemeral = !agent || isEphemeralAgent(agent); const policy = resolveEffectiveAgentPermissionPolicy(agent?.permissionPolicy, projectDefaultPolicy); + const workflowAuthority = taskId ? this.activeWorkflowAuthorities.get(taskId) : undefined; + const authorityMatchesActor = workflowAuthority?.agentId === actorId; return { agentId: actorId, agentName: actorName, isEphemeral, taskId, - runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined, + runId: authorityMatchesActor ? workflowAuthority!.runId : taskId ? this.getRunContextFor(taskId)?.runId : undefined, permissionPolicy: policy, + ...(authorityMatchesActor ? { + workflowAuthority: { + projectId: this.store.getRootDir(), + taskId: workflowAuthority!.taskId, + runId: workflowAuthority!.runId, + workItemId: workflowAuthority!.workItemId, + nodeInstanceId: workflowAuthority!.nodeInstanceId, + principalAgentId: workflowAuthority!.agentId, + kind: workflowAuthority!.kind, + isLive: async () => { + const current = this.activeWorkflowAuthorities.get(workflowAuthority!.taskId); + if (current !== workflowAuthority + || !this.activeWorkflowGraphAbortControllers.has(workflowAuthority!.taskId) + || this.activeWorkflowGraphAbortControllers.get(workflowAuthority!.taskId)!.signal.aborted) { + return false; + } + if (!workflowAuthority!.requiresDurableFence) return true; + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:31: + * Tool authority for a claimed continuation survives only while its + * exact leased work item still names this principal and node. This + * rejects a stale, cancelled, or re-assigned record even when an + * old in-process session has not noticed the cancellation yet. + */ + const items = await this.store.listWorkflowWorkItemsForTask(workflowAuthority!.taskId); + const item = items.find((candidate) => candidate.id === workflowAuthority!.workItemId); + if (item?.state !== "running" + || item.principalAgentId !== workflowAuthority!.agentId + || item.nodeInstanceId !== workflowAuthority!.nodeInstanceId + || !item.leaseOwner + || (item.leaseExpiresAt !== null && Date.parse(item.leaseExpiresAt) <= Date.now())) { + return false; + } + const liveTask = await this.store.getTask(workflowAuthority!.taskId); + if (workflowAuthority!.kind === "task-assignee") { + return liveTask.assignedAgentId === workflowAuthority!.agentId; + } + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:56: + * A reviewer override is authority for one exact IR node attempt, + * not a task-wide reviewer grant. Re-read the selected workflow + * definition at every gated call so an operator removing or changing + * the node override immediately fences an already-running session. + */ + if (workflowAuthority!.kind === "review-node-override") { + const liveIr = await resolveWorkflowIrForTask(this.store, workflowAuthority!.taskId); + return isCurrentReviewerNodeOverride( + liveIr, + workflowAuthority!.nodeInstanceId, + workflowAuthority!.agentId, + ); + } + return false; + }, + }, + } : {}), createApprovalRequest: async (decision, args) => await this.approvalRequestStore.create({ requester: { actorId, @@ -3620,6 +3708,7 @@ export class TaskExecutor { private rootDir: string, private options: TaskExecutorOptions = {}, ) { + this.workflowAgentCapacity = new WorkflowAgentCapacity(this.options.agentStore); /* 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. @@ -6492,6 +6581,14 @@ export class TaskExecutor { this.graphRouting.add(task.id); } let graphAbortController: AbortController | undefined; + const workflowCapacityAttemptIds = new Set(); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-05:06: + * Direct graph dispatch is also a production session-launch path. Track its + * per-node durable fences so direct runs do not degrade principals to a + * process-local map while scheduled continuations remain fenced in Postgres. + */ + const directWorkflowPrincipalWorkItemIds = new Set(); /* FNXC:GlobalConcurrencyControls 2026-07-14-18:30: The hold/release sweep may have already tryAcquired a global slot for this card before moving it to in-progress. Claim that pre-held slot for the full graph run so utilization stays honest between workflow nodes and triage cannot overfill the cap while this task is still graph-owned. @@ -6694,6 +6791,239 @@ export class TaskExecutor { seams: this.createAuthoritativeWorkflowSeams(settings), prepareNodeExecution: (node, nodeTask, requirement) => this.prepareGraphNodeExecution(node, nodeTask, settings, requirement), + /* + * FNXC:WorkflowAgentRouting 2026-08-07-03:38: + * Graph execution resolves permanent workflow principals before handlers + * can create a model session. An unavailable explicit owner, column agent, + * or reviewer override fails closed at its node instead of silently + * selecting a different pool member. The durable work-item fence is + * established by the work-item runtime path; this live graph admission + * makes the same routing contract authoritative for direct dispatch. + */ + beforeNodeExecution: async (node, nodeTask, context) => { + 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. + if (!this.options.agentStore || !columnAgentIr) { + return { outcome: "failure" as const, value: `workflow-principal-routing-unavailable:${classifiedRole}` }; + } + const agents = await this.options.agentStore.listAgents({ includeEphemeral: true }); + const activeSessions = new Map(agents.map((agent) => [agent.id, this.workflowAgentCapacity.activeSessions(agent.id, this.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: columnAgentIr, + node, + principalAgentId: fencedPrincipalId, + role: fencedRole, + authority: fencedAuthority, + agents, + nodeInstanceId, + activeSessions, + }) + : routeWorkflowPrincipal({ + task: nodeTask, + ir: columnAgentIr, + node, + agents, + activeSessions, + }); + if (routed.status === "unclassified") return undefined; + const holdDirectPrincipalWorkItem = async ( + reason: string, + principalAgentId: string | null, + authorityKind: "task-assignee" | "review-node-override" | "column-binding" | "role-pool" | null, + ): Promise => { + if (typeof this.store.upsertWorkflowWorkItem !== "function") return; + const item = await this.store.upsertWorkflowWorkItem({ + runId: `${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, + }); + directWorkflowPrincipalWorkItemIds.add(item.id); + }; + if (routed.status === "held") { + const reviewerOverride = classifiedRole === "reviewer" ? node.reviewerAgentId : undefined; + const columnBinding = 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 }; + } + const attemptId = `${resolvedRunId}:${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 this.workflowAgentCapacity.acquire({ + projectId: this.options.agentStore.workflowProjectId ?? this.store.getRootDir(), + agent: routed.route.agent, + attemptId, + maxProjectSessions: 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(); + 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: columnAgentIr, + node, + agents, + activeSessions, + excludedPoolAgentIds, + }); + if (retryRoute.status !== "routed" || retryRoute.route.authority !== "role-pool") break; + routed = retryRoute; + capacity = await this.workflowAgentCapacity.acquire({ + projectId: this.options.agentStore.workflowProjectId ?? this.store.getRootDir(), + agent: routed.route.agent, + attemptId, + maxProjectSessions: 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 && typeof this.store.upsertWorkflowWorkItem === "function") { + try { + const item = await this.store.upsertWorkflowWorkItem({ + runId: `${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, + }); + durableWorkItemId = item.id; + directWorkflowPrincipalWorkItemIds.add(item.id); + } catch { + void this.workflowAgentCapacity.release(attemptId, this.options.agentStore.workflowProjectId ?? this.store.getRootDir()); + return { outcome: "failure" as const, value: `workflow-principal-fence-unavailable:${routed.route.role}` }; + } + } + workflowCapacityAttemptIds.add(attemptId); + this.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") { + this.activeWorkflowAuthorities.set(nodeTask.id, { + agentId: routed.route.agent.id, + taskId: nodeTask.id, + runId: resolvedRunId ?? `${nodeTask.id}:${node.id}`, + workItemId: durableWorkItemId ?? attemptId, + nodeInstanceId, + requiresDurableFence: durableWorkItemId !== undefined, + kind: routed.route.authority, + }); + } else { + this.activeWorkflowAuthorities.delete(nodeTask.id); + } + context["workflow:release-principal"] = () => { + void this.workflowAgentCapacity.release(attemptId, this.options.agentStore?.workflowProjectId ?? this.store.getRootDir()); + workflowCapacityAttemptIds.delete(attemptId); + const principal = this.activeWorkflowPrincipals.get(nodeTask.id); + if (principal?.nodeInstanceId === nodeInstanceId) { + this.activeWorkflowPrincipals.delete(nodeTask.id); + this.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; + }, runCustomNode: customNodeExecution.runner(settings), publishTaskProjection: async (taskId, patch) => { await this.store.updateTaskAtomic(taskId, (liveTask) => { @@ -6827,7 +7157,23 @@ export class TaskExecutor { lastError: null, }); } - result = await runner.run(detail, settings, continuation?.nodeId); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:45: + * A direct graph resume owns the same durable continuation as scheduler + * work-item dispatch. Rehydrate its fence before the graph reaches + * beforeNodeExecution so recovery validates this exact principal instead + * of silently choosing a fresh role-pool candidate. + */ + const continuationContext = continuation?.principalAgentId + ? { + "workflow:work-item-id": continuation.id, + "workflow:principal-agent-id": continuation.principalAgentId, + "workflow:principal-role": continuation.workflowRole, + "workflow:principal-authority": continuation.authorityKind, + "workflow:node-instance-id": continuation.nodeInstanceId ?? continuation.nodeId, + } + : undefined; + result = await runner.run(detail, settings, continuation?.nodeId, continuationContext); } catch (err) { if (continuation) { await this.store.transitionWorkflowWorkItem(continuation.id, "failed", { @@ -6847,6 +7193,40 @@ export class TaskExecutor { }); return; } + const principalHoldReason = Object.values(result.context ?? {}).find((value): value is string => + typeof value === "string" && value.startsWith("workflow-principal-"), + ); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:45: + * Principal availability is a recoverable continuation hold, not a graph + * failure. Do not terminalize the direct fence or call graph failure + * handling; the next direct resume must receive the same fenced identity. + */ + if (principalHoldReason) { + if (continuation && typeof this.store.transitionWorkflowWorkItem === "function") { + await this.store.transitionWorkflowWorkItem(continuation.id, "held", { + leaseOwner: null, + leaseExpiresAt: null, + lastError: principalHoldReason, + blockedReason: principalHoldReason, + }).catch(() => undefined); + } + return; + } + /* Direct graph node fences are terminalized only after the interpreter + * returns, preserving their historical principal through all handler and + * tool-gate calls while ensuring completed work cannot render as active. + * Availability holds intentionally remain held for recovery instead. */ + if (result.disposition !== "suspended" && directWorkflowPrincipalWorkItemIds.size > 0 && typeof this.store.transitionWorkflowWorkItem === "function") { + const terminalState = result.disposition === "completed" ? "succeeded" : "failed"; + await Promise.all([...directWorkflowPrincipalWorkItemIds].map(async (id) => { + await this.store.transitionWorkflowWorkItem(id, terminalState, { + leaseOwner: null, + leaseExpiresAt: null, + lastError: terminalState === "failed" ? "workflow-graph-node-failed" : null, + }).catch(() => undefined); + })); + } if (result.disposition === "fell-back") { executorLog.warn(`[workflow-graph] ${task.id} could not resolve workflow — parking task instead of legacy fallback: ${result.reason}`); await this.handleGraphFailure(task, { @@ -6913,6 +7293,9 @@ export class TaskExecutor { */ this.options.semaphore?.release(); } + for (const attemptId of workflowCapacityAttemptIds) void this.workflowAgentCapacity.release(attemptId, this.options.agentStore?.workflowProjectId ?? this.store.getRootDir()); + this.activeWorkflowAuthorities.delete(task.id); + this.activeWorkflowPrincipals.delete(task.id); if (graphAbortController && this.activeWorkflowGraphAbortControllers.get(task.id) === graphAbortController) { this.activeWorkflowGraphAbortControllers.delete(task.id); } @@ -8864,6 +9247,8 @@ export class TaskExecutor { agentStore: this.options.agentStore, rootDir: this.rootDir, settings, + /* FNXC:WorkflowAgentRouting 2026-08-07-04:45: reviewer sessions inherit the exact graph-fenced principal, including a node-local override. */ + agentId: this.activeWorkflowPrincipals.get(seamTask.id)?.agentId, onSessionCreated: (s) => this.registerSubagentSession(seamTask.id, s), onSessionEnded: (s) => this.unregisterSubagentSession(seamTask.id, s), }, @@ -9993,7 +10378,12 @@ export class TaskExecutor { let outcome: WorkflowStepOutcome = mode === "script" ? await this.executeScriptWorkflowStep(live, step, worktreePath, settings, nodeEnv) - : await this.executeWorkflowStep(live, step, worktreePath, settings, nodeEnv, { unattended }); + : await this.executeWorkflowStep(live, step, worktreePath, settings, nodeEnv, { + unattended, + principalAgentId: typeof graphContext?.["workflow:principal-agent-id"] === "string" + ? graphContext["workflow:principal-agent-id"] + : undefined, + }); /* * FNXC:WorkflowReviewFindings 2026-08-05-06:29: * Script nodes retain their exit-code verdict semantics, but an explicitly classified review @@ -12755,50 +13145,6 @@ export class TaskExecutor { return true; } - /* - FNXC:EphemeralAgents 2026-07-01-00:00: - `ephemeralAgentsEnabled: false` means "never spawn short-lived executor-FN-XXXX workers; only permanent agents run work" (see types.ts ephemeralAgentsEnabled). The legacy spawn refusal lives in EphemeralWorkerManager.onTaskStart (ephemeral-worker-manager.ts), but that runs as a fire-and-forget bookkeeping callback AFTER execution has already begun, so it cannot stop a run. The workflow-engine dispatch paths (executeWorkflowGraph, maybeDispatchWorkflowWorkEngine) execute tasks in-process without ever consulting the toggle. Any task that reaches execute() without a permanent assignment via a non-scheduler path (resume-after-restart, heartbeat re-entry, mission/autopilot, work-engine claim) therefore ran despite the operator disabling ephemeral agents. - - This guard is the executor's last line of defense, mirroring the scheduler cutover gate (scheduler.ts:2464) and the spawn refusal (ephemeral-worker-manager.ts:132). It runs once at the top of the outer dispatch — before all three workflow paths — so a single check covers every workflow dispatch entry point. A task explicitly assigned to a permanent (non-ephemeral) agent is exactly how ephemeral-off mode is meant to run, so those are allowed through; everything else is re-queued for the scheduler to auto-assign a permanent agent or hold. - */ - private async blockOuterDispatchWhenEphemeralDisabled(task: Task): Promise { - const settings = await this.store.getSettings(); - if (settings.ephemeralAgentsEnabled !== false) return false; - - // A permanent (non-ephemeral) assignment is the sanctioned executor when - // ephemeral workers are off. `assignedAgentId` is only ever set by permanent - // assignment — default ephemeral mode never sets it — so when we cannot - // resolve the agent (no agentStore) we trust the presence of the id and allow - // the run rather than starving a legitimately-assigned task. - const assignedId = task.assignedAgentId?.trim(); - if (assignedId) { - if (!this.options.agentStore) return false; - const agent = await this.options.agentStore.getAgent(assignedId).catch(() => null); - if (agent && !isEphemeralAgent(agent)) return false; - } - - const liveTask = (await this.store.getTask(task.id).catch(() => null)) ?? task; - const reboundColumn = await resolveReboundColumnFor(this.store, liveTask.id); - if (liveTask.column !== reboundColumn) { - await this.store.moveTask(liveTask.id, reboundColumn, { - preserveProgress: true, - preserveWorktree: true, - preserveResumeState: true, - moveSource: "engine", - recoveryRehome: true, - }); - } - await this.store.updateTask(liveTask.id, { status: "queued" }, this.getRunContextFor(liveTask.id)); - await this.store.logEntry( - liveTask.id, - "queued — ephemeral agents disabled; no permanent executor assigned", - "Executor pre-dispatch ephemeral gate blocked workflow/authoritative execution.", - this.getRunContextFor(liveTask.id), - ); - executorLog.log(`${liveTask.id}: executor dispatch blocked — ephemeralAgentsEnabled=false and no permanent agent assigned`); - return true; - } - /* FNXC:GlobalConcurrencyControls 2026-07-15-03:50: Structural cleanup for scheduler pre-held global slots: every execute() exit path @@ -12864,13 +13210,6 @@ export class TaskExecutor { if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release(); return; } - // FNXC:EphemeralAgents 2026-07-01-00:00: gate ALL workflow dispatch paths - // (graph/authoritative/work-engine) on ephemeralAgentsEnabled before any of - // them can claim the task, so the single check covers all three entry points. - if (await this.blockOuterDispatchWhenEphemeralDisabled(task)) { - if (dropPreHeldExecutorSlot(task.id)) this.options.semaphore?.release(); - return; - } /* FNXC:WorkflowExecution 2026-07-19-10:40: U10 (R9) — the `workflowAuthoritativeDispatch` branch is DELETED along with @@ -14270,6 +14609,13 @@ export class TaskExecutor { ? [createReflectOnPerformanceTool(this.options.reflectionService, assignedAgentId)] : []; const assignedAgent = await this.getAuthoritativeAssignedAgent(assignedAgentId); + const routedPrincipalAgentId = this.activeWorkflowPrincipals.get(task.id)?.agentId; + const routedPrincipalAgent = routedPrincipalAgentId + ? await this.getAuthoritativeAssignedAgent(routedPrincipalAgentId) + : undefined; + if (routedPrincipalAgentId && !routedPrincipalAgent) { + throw new Error(`workflow-principal-unavailable:${routedPrincipalAgentId}`); + } // Column-agent SESSION IDENTITY (U4, R2/R3/R4/R8): when the governing execute // seam node's declared column binds an agent that supersedes the task's @@ -14281,7 +14627,13 @@ export class TaskExecutor { // `identityAgent` — the effective column agent when a binding governs, else // the assigned agent (U5/KTD-3 principal substitution). const columnAgentSeam = await this.resolveSeamColumnAgent(task, detail); - const identityAgent = columnAgentSeam?.agent ?? assignedAgent; + /* + * FNXC:WorkflowAgentRouting 2026-08-07-03:46: + * Once graph admission has fenced a durable workflow principal, the model + * session must use that exact identity instead of re-resolving ownership or + * a column binding. This prevents a retry from silently changing authority. + */ + const identityAgent = routedPrincipalAgent ?? columnAgentSeam?.agent ?? assignedAgent; const executorRuntimeHint = extractRuntimeHint(identityAgent?.runtimeConfig); // U5 (R6): track the effective column-agent principal so the heartbeat // scheduler's reverse guard knows this agent is executing a task it may not @@ -14514,8 +14866,8 @@ export class TaskExecutor { taskId: task.id, agent: "executor", persistAgentToolOutput: settings.persistAgentToolOutput, - // Executor sessions are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Executor workflow sessions use durable routed principals; preserve permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: (taskId, delta) => { lastAssistantText += delta; stuckDetector?.recordActivity(taskId); @@ -18155,8 +18507,8 @@ export class TaskExecutor { taskId: task.id, agent: "executor", persistAgentToolOutput: settings.persistAgentToolOutput, - // Executor sessions are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Executor workflow sessions use durable routed principals; preserve permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: this.options.onAgentText, onAgentTool: this.options.onAgentTool, }); @@ -18944,7 +19296,7 @@ ${scopeGuard} worktreePath: string, settings: Settings, taskEnv?: NodeJS.ProcessEnv, - stepOptions?: { unattended?: boolean }, + stepOptions?: { unattended?: boolean; principalAgentId?: string }, ): Promise { let toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly"; // (U3) Genuinely-unattended run — set FUSION_HEADLESS=1 below so skills record @@ -19199,13 +19551,29 @@ ${workflowStep.prompt} You have access to the file system to review changes.${inlineFixBlock}${verdictBlock}`; + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:45: + * The graph admission fence chooses the permanent identity before this + * session exists. Resolve that exact agent for its model, skills, audit, + * and log attribution; never fall back to task ownership after routing. + */ + const workflowPrincipal = stepOptions?.principalAgentId + ? await this.getAuthoritativeAssignedAgent(stepOptions.principalAgentId) + : undefined; + if (stepOptions?.principalAgentId && !workflowPrincipal) { + throw new Error(`workflow-principal-unavailable:${stepOptions.principalAgentId}`); + } + const sessionTask = workflowPrincipal + ? { ...task, assignedAgentId: workflowPrincipal.id } + : task; const agentLogger = new AgentLogger({ store: this.store, taskId: task.id, + // AgentLogger has a lane enum; its stream label remains the review lane. agent: "reviewer", persistAgentToolOutput: settings.persistAgentToolOutput, - // Review-in-executor sessions are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Graph-owned review sessions use their durable routed reviewer principal. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: (taskId, delta) => { this.options.onAgentText?.(taskId, delta); }, @@ -19221,7 +19589,8 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB // steps to inherit project execution-lane model settings before defaults. // Review gates are independent validation surfaces and must not silently use // the same implementation model merely because they execute in this method. - const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId); + const assignedRuntimeConfig = workflowPrincipal?.runtimeConfig + ?? await this.getAssignedAgentRuntimeConfig(task.assignedAgentId); const laneModel = isReviewTypeWorkflowStep ? resolveValidatorSessionModel( task.validatorModelProvider, @@ -19268,13 +19637,13 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB // Build skill selection context for workflow step session const skillContext = await buildSessionSkillContext({ agentStore: this.options.agentStore!, - task, + task: sessionTask, sessionPurpose: "executor", projectRootDir: this.rootDir, pluginRunner: this.options.pluginRunner, }); - const workflowAgent = await this.getAuthoritativeAssignedAgent(task.assignedAgentId); + const workflowAgent = workflowPrincipal ?? await this.getAuthoritativeAssignedAgent(task.assignedAgentId); const workflowRuntimeHint = extractRuntimeHint(workflowAgent?.runtimeConfig); // Signal to skills running in this step (e.g. compound-engineering ce-plan / // ce-work) that they are inside a Fusion autonomous workflow step, NOT an diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 4a292251bf..e50b4fbd10 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -497,7 +497,6 @@ export { type InteractiveAgentFactory, type PlanningExecutorSelection, } from "./execution/interactive-ai-session.js"; -export { selectPermanentAgentForTask, listEligibleExecutorAgents } from "./agents/agent-assignment.js"; // Register createFnAgent into core's loader so consumers in @fusion/core // (e.g. ai-summarize, memory-compaction) can resolve it without a circular diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 89fc4f62d0..84a6815b93 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -1112,8 +1112,8 @@ async function attemptInMergeVerificationFix( taskId, agent: "merger", persistAgentToolOutput: settings.persistAgentToolOutput, - // Merger agents are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Merger workflow sessions use durable routed principals and permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: options.onAgentText, onAgentTool: options.onAgentTool, }); @@ -2364,8 +2364,8 @@ async function runAiAgentForAutostashConflict(params: { taskId, agent: "merger", persistAgentToolOutput: settings.persistAgentToolOutput, - // Merger agents are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Merger workflow sessions use durable routed principals and permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: options.onAgentText ? (_id: string, delta: string) => options.onAgentText!(delta) : undefined, @@ -2780,8 +2780,8 @@ async function runAiAgentForAutostashHardFail(params: { taskId, agent: "merger", persistAgentToolOutput: settings.persistAgentToolOutput, - // Merger agents are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Merger workflow sessions use durable routed principals and permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: options.onAgentText ? (_id: string, delta: string) => options.onAgentText!(delta) : undefined, @@ -5901,8 +5901,8 @@ You are assisting with a paused \`git pull --rebase\`. taskId, agent: "merger", persistAgentToolOutput: settings.persistAgentToolOutput, - // Merger agents are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Merger workflow sessions use durable routed principals and permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: options?.onAgentText ? (_id, delta) => options.onAgentText?.(delta) : undefined, @@ -10849,8 +10849,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo taskId, agent: "merger", persistAgentToolOutput: settings.persistAgentToolOutput, - // Merger agents are task-scoped ephemeral workers. - persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: true }), + /* FNXC:WorkflowAgentRouting 2026-08-07-04:13: Merger workflow sessions use durable routed principals and permanent-agent logging policy. */ + persistAgentThinkingLog: resolvePersistAgentThinkingLog(settings, { ephemeral: false }), onAgentText: options.onAgentText ? (_id, delta) => options.onAgentText!(delta) : undefined, diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index cc4b3299d0..da8a66061f 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -78,6 +78,7 @@ import { buildCustomProviderModels } from "./auth/custom-provider-registry.js"; import { buildGateRejection, evaluateAgentActionGate, + hasLiveWorkflowAuthority, resolveGateOutcome, type AgentActionGateContext, } from "./agents/agent-action-gate.js"; @@ -2135,6 +2136,16 @@ export function wrapToolsWithActionGate( permissionPolicy: gateContext.permissionPolicy, }); + /* + * FNXC:WorkflowAgentRouting 2026-08-07-03:43: + * A routed principal may bypass restrictive policy only for its live, + * fenced task/node attempt. This per-call probe prevents the authority + * from escaping to heartbeat, chat, another task, or a stale retry. + */ + if (await hasLiveWorkflowAuthority(gateContext, params, tool.name)) { + return originalExecute(...args); + } + const latestApproval = gateContext.findApprovalByDedupeKey ? await gateContext.findApprovalByDedupeKey(decision.approvalDedupeKey) : await gateContext.findPendingApprovalByDedupeKey?.(decision.approvalDedupeKey).then((request) => diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 20adcf8278..10b9d3ae5a 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -62,7 +62,6 @@ import { PluginRunner } from "../plugins/plugin-runner.js"; import { MissionAutopilot } from "../missions/mission-autopilot.js"; import { MissionExecutionLoop } from "../missions/mission-execution-loop.js"; import { TriageProcessor } from "../triage.js"; -import { EphemeralWorkerManager } from "../agents/ephemeral-worker-manager.js"; import { validateProjectNodeMapping } from "../project/node-dispatch-validation.js"; import { attachAgentLinkSync } from "../agents/task-agent-sync.js"; import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js"; @@ -762,12 +761,6 @@ export class InProcessRuntime private agentStore?: AgentStore; private heartbeatMonitor?: HeartbeatMonitor; private triggerScheduler?: HeartbeatTriggerScheduler; - /** - * Coordinates the ephemeral task-worker lifecycle (spawn dedup, finalize, - * halt-listener cleanup, startup sweep). See `ephemeral-worker-manager.ts`. - * Created once the AgentStore is available; guard call sites with `?`. - */ - private workerManager?: EphemeralWorkerManager; private lastActivityAt: string = new Date().toISOString(); private pluginRunner?: PluginRunner; private pluginStore?: PluginStore; @@ -1368,15 +1361,11 @@ export class InProcessRuntime worktree-created / node-acquired lines. Demote this echo to debug. */ runtimeLog.debug(`Started executing task ${task.id} in ${worktreePath}`); - // Legacy invariant (implemented in EphemeralWorkerManager): - // if (this.taskAgentMap.has(task.id)) { ... "Skipping task-worker creation for" ... } - void this.workerManager?.onTaskStart(task); }, onComplete: (task) => { this.recordActivity(); runtimeLog.log(`Completed task ${task.id}`); this.recordTaskCompletion(task.id, true); - void this.workerManager?.onTaskComplete(task.id); }, onError: (task, error) => { this.recordActivity(); @@ -1413,7 +1402,6 @@ export class InProcessRuntime })(); } - void this.workerManager?.onTaskError(task.id); }, }; @@ -1554,29 +1542,6 @@ export class InProcessRuntime const isTimerManagedAgent = (agent: import("@fusion/core").Agent) => isHeartbeatEnabledAgent(agent) && isTickableHeartbeatState(agent.state); - // Wire the ephemeral worker manager (now that the executor exists, so - // its spawned-child pending-deletion set can be consulted) and run - // the startup orphan sweep. See ephemeral-worker-manager.ts for the - // full lifecycle contract. Non-fatal: failures are logged and never - // block startup. - if (this.agentStore && !this.workerManager) { - this.workerManager = new EphemeralWorkerManager({ - agentStore: this.agentStore, - taskStore: this.taskStore, - logger: runtimeLog, - isDeletionPendingExternal: (agentId) => this.executor?.isEphemeralDeletionPending(agentId) ?? false, - getSettings: async () => { - const settings = await this.taskStore.getSettings(); - return { ephemeralAgentsEnabled: settings.ephemeralAgentsEnabled }; - }, - }); - } - if (this.workerManager) { - this.workerManager.attachStateChangeListener(); - void this.workerManager.reconcileOrphaned().catch((err) => { - runtimeLog.warn(`Deferred workerManager.reconcileOrphaned failed: ${err instanceof Error ? err.message : String(err)}`); - }); - } // Register existing non-ephemeral, heartbeat-enabled agents in tickable states. try { @@ -2055,8 +2020,6 @@ export class InProcessRuntime // 3. Tear down the ephemeral worker manager (detaches the // agent:stateChanged listener and clears in-memory tracking). Safe to // call when uninitialized. - this.workerManager?.detachStateChangeListener(); - this.workerManager?.reset(); this.executor?.disposeEphemeralTimers(); // 4. Stop trigger scheduler diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index b8956f944f..d7eed65fe6 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -36,7 +36,6 @@ import { resolveEffectiveNode, type EffectiveNode } from "./project/effective-no import { applyUnavailableNodePolicy, decideOwningNodeHandoff } from "./project/node-routing-policy.js"; import type { NodeDispatchValidationResult } from "./project/node-dispatch-validation.js"; import type { MeshLeaseManager } from "./project/mesh-lease-manager.js"; -import { selectPermanentAgentForTask } from "./agents/agent-assignment.js"; import type { AutoClaimSnapshotManager } from "./scheduling/auto-claim-snapshot.js"; import { StaleTaskReporter } from "./healing/stale-task-reporter.js"; import { BacklogPressureReporter } from "./scheduling/backlog-pressure-reporter.js"; @@ -2690,118 +2689,7 @@ export class Scheduler { } } - if (latestSettings.ephemeralAgentsEnabled === false && !freshTask.assignedAgentId) { - /* - FNXC:WorkflowScheduling 2026-06-23-22:33: - The workflow cutover path must not silently dispatch unassigned work when ephemeral agents are disabled. Queue until permanent-agent selection is available so upgrades preserve the executor contract instead of falling through to local execution. - */ - if (!this.options.agentStore) { - await this.store.updateTask(task.id, { status: "queued" }); - if (!this.wasPermanentAgentUnavailable.has(task.id)) { - await this.logDispatchQueuedReason( - task.id, - "queued — permanent executor selection unavailable (ephemeral agents disabled)", - ); - this.wasPermanentAgentUnavailable.add(task.id); - } - return null; - } - - /* - FNXC:WorkflowLifecycleColumns 2026-07-31-09:30 (#2787 review — greptile P1): - PASS THE RESOLVED LANES. Without this the optional parameter added to - `selectPermanentAgentForTask` is never supplied by the only production caller, so the - predicate keeps its legacy default and the load tally stays empty on a renamed board — - a converted function reachable only through an argument nobody passes is the - guard-that-cannot-fire pattern, and shipping one would have been worse than leaving the - literal in place, because the site then reads as done. - - The set is a MEMBERSHIP union of every wip/review lane the board declares, not the - first-per-role ids: a workflow may declare more than one implementation lane, and load - held in the second must still count. - */ - /* - FNXC:WorkflowLifecycleColumns 2026-07-31-11:40 (#2787 review — greptile P1, third round): - RESOLVE PER TASK, because a project runs several workflows at once. - - My first wiring resolved the lanes from the CANDIDATE task's workflow and handed that flat - set to a tally that runs over EVERY assigned row. Assignments living in another workflow's - load-bearing lanes were therefore omitted — the same already-loaded-agent-wins bug the - parameter exists to fix, reached through a different door. A column id means something - only relative to its OWN workflow; `blocker-fanout.ts` documents exactly this and offers a - per-task `classify`, so this passes a per-task predicate rather than a board-wide set. - - One IR cache for the whole selection, per the caller-owned-cache contract, so a board - spanning three workflows reads three IRs and not one per assigned card. - */ - const loadLaneIrCache = new Map>>(); - const resolveLoadLanes = async (candidate: Task): Promise> => { - const ir = await resolveWorkflowIrForTask(this.store, candidate.id, loadLaneIrCache).catch(() => undefined); - /* DELIBERATE-LITERAL — the unresolvable-workflow default. */ - if (!ir) return new Set(["todo", "in-progress", "in-review"]); - return new Set([ - ...columnsWithFlag(ir, "intake"), - ...columnsWithFlag(ir, "hold"), - ...columnsWithFlag(ir, "countsTowardWip"), - ...columnsWithFlag(ir, "mergeOrchestration"), - ...columnsWithFlag(ir, "mergeBlocker"), - ...columnsWithFlag(ir, "humanReview"), - ]); - }; - /* - FNXC:WorkflowResolvedColumns 2026-07-30-12:10 (#2796 review — greptile): - MEMOISE THE LANES, NOT THE VERDICT — the two snapshots are not the same list. - - This pre-computed a Set of load-bearing task IDs from ITS OWN `listTasks` read, and - `selectPermanentAgentForTask` then applies the predicate to rows from ITS read. Anything - that changes in between diverges, and it diverges in both directions: a task MOVED out of - a load-bearing lane keeps its id in the set and is still counted, while a task created or - newly assigned in between is missing from the set and counts as zero. Either way the - balancer acts on a board that no longer exists. - - Caching the resolved LANES per task instead of a boolean removes the dependency. Lane - membership is a property of the task's workflow, which a move does not change, so the - predicate can be evaluated against the column on the row the helper actually holds. Only - the workflow lookup is memoised; the comparison is live. - - A task absent from the map (created between the two reads) falls back to the same legacy - trio the resolver itself uses when a workflow will not resolve, rather than silently - counting as no load. - */ - const LEGACY_LOAD_LANES: ReadonlySet = new Set(["todo", "in-progress", "in-review"]); - const loadLanesByTaskId = new Map>(); - for (const candidate of await this.store.listTasks({ slim: true })) { - if (!candidate.assignedAgentId) continue; - loadLanesByTaskId.set(candidate.id, await resolveLoadLanes(candidate)); - } - - const selectedAgent = await selectPermanentAgentForTask({ - task: freshTask, - agentStore: this.options.agentStore, - taskStore: this.store, - countsAsAssignmentLoad: (candidate: Task) => - (loadLanesByTaskId.get(candidate.id) ?? LEGACY_LOAD_LANES).has(candidate.column), - }); - if (!selectedAgent) { - await this.store.updateTask(task.id, { status: "queued" }); - if (!this.wasPermanentAgentUnavailable.has(task.id)) { - await this.logDispatchQueuedReason( - task.id, - "queued — no permanent executor available (ephemeral agents disabled)", - ); - this.wasPermanentAgentUnavailable.add(task.id); - } - return null; - } - await this.store.updateTask(task.id, { assignedAgentId: selectedAgent.id }); - await this.store.logEntry( - task.id, - `Auto-assigned to permanent agent ${selectedAgent.id} (ephemeral agents disabled)`, - ); - this.wasPermanentAgentUnavailable.delete(task.id); - } else { - this.wasPermanentAgentUnavailable.delete(task.id); - } + this.wasPermanentAgentUnavailable.delete(task.id); const oscillationSettings = latestSettings as Settings & { dispatchOscillationSettleMs?: number; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 1e6da63e57..081cf95933 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -39,6 +39,7 @@ import { resolveLifecycleColumns, resolveWorkflowIrForTaskWithProvenance, resolveProjectColumnsForRoles, + isWorkflowAgentNodeForRole, resolveWorktreeCapacityLimit, workflowHasColumn, getStepParser, @@ -233,6 +234,8 @@ import { finalizePlanningSegment, startPlanningSegment } from "@fusion/core"; import { collectPlanReviewFeedbackHistory, isPlanReviewRevisionLog } from "./plan-review-feedback-history.js"; import type { AgentActionGateContext } from "./agents/agent-action-gate.js"; import { buildAgentGatedActionSummary } from "./agents/permanent-agent-gating.js"; +import { routeWorkflowPrincipal } from "./agents/workflow-agent-router.js"; +import { WorkflowAgentCapacity } from "./agents/workflow-agent-capacity.js"; export interface TriageProcessorOptions { @@ -445,6 +448,8 @@ export class TriageProcessor { /** FNXC:PlanningEvacuation 2026-07-25-23:00: stops planning when a card leaves the planner lanes. */ private taskEvacuatedFromPlanningHandler?: (task: Task, meta?: { lanes?: TaskMoveLanes }) => void; private _approvalRequestStore?: ApprovalRequestStore; + /** Workflow planning uses its own durable budget, never heartbeat concurrency. */ + private readonly workflowAgentCapacity: WorkflowAgentCapacity; /** * @param store — Task store instance (also used to listen for `settings:updated` events) @@ -477,6 +482,7 @@ export class TriageProcessor { runId: string, agent: Agent | null, projectDefaultPolicy?: { rules?: Partial; toolRules?: AgentPermissionPolicy["toolRules"] }, + workflowAuthority?: { workItemId: string; nodeInstanceId: string; principalAgentId: string; kind: "task-assignee" | "review-node-override"; isLive: () => boolean | Promise }, ): AgentActionGateContext { const actorId = agent?.id ?? `triage-${taskId}`; const actorName = agent?.name ?? `Triage planner ${taskId}`; @@ -488,6 +494,16 @@ export class TriageProcessor { taskId, runId, permissionPolicy, + ...(workflowAuthority ? { workflowAuthority: { + projectId: this.options.agentStore?.workflowProjectId ?? this.rootDir, + taskId, + runId, + workItemId: workflowAuthority.workItemId, + nodeInstanceId: workflowAuthority.nodeInstanceId, + principalAgentId: workflowAuthority.principalAgentId, + kind: workflowAuthority.kind, + isLive: workflowAuthority.isLive, + } } : {}), createApprovalRequest: async (decision, args) => await this.approvalRequestStore.create({ requester: { actorId, actorType: "agent", actorName }, taskId, @@ -558,6 +574,7 @@ export class TriageProcessor { private rootDir: string, private options: TriageProcessorOptions = {}, ) { + this.workflowAgentCapacity = new WorkflowAgentCapacity(this.options.agentStore); this.unregisterAdmissionProvider = projectAdmissionCoordinator.registerProvider(`specify:${this.rootDir}`, { projectId: this.rootDir, refresh: async () => { @@ -2433,6 +2450,11 @@ export class TriageProcessor { checkout). Declared at method scope because registration happens deep inside the try. */ let registeredPlanningPath: string | null = null; + let workflowCapacityAttemptId: string | undefined; + let workflowCapacityProjectId: string | undefined; + let planningWorkItemId: string | undefined; + let planningAuthority: { workItemId: string; nodeInstanceId: string; principalAgentId: string; kind: "task-assignee" | "review-node-override"; isLive: () => Promise } | undefined; + let planningSessionCompleted = false; /* FNXC:DuplicateIntake 2026-07-26-10:40: @@ -2521,7 +2543,7 @@ export class TriageProcessor { // Track subtasks created during triage when breakIntoSubtasks was requested. const createdSubtasksRef: { current: string[] } = { current: [] }; - const assignedAgent = task.assignedAgentId && this.options.agentStore + let assignedAgent = task.assignedAgentId && this.options.agentStore ? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null) : null; @@ -2532,7 +2554,158 @@ export class TriageProcessor { taskLineageId: task.lineageId, phase: "plan", source: "triage", - } as const; + }; + + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:27: + The production planning session is an agent-executed workflow seam, so + it must select and fence the same permanent principal as graph nodes. + Do not fall back to the synthetic `triage` identity: a missing named + owner or empty role pool leaves planning visibly recoverable instead of + granting ambient authority to an ephemeral worker. + */ + const planningIr = await resolveWorkflowIrForTask(this.store, task.id).catch(() => undefined); + const planningNode = planningIr?.nodes.find((node) => isWorkflowAgentNodeForRole(node, "triage")); + if (this.options.agentStore && planningIr && planningNode && typeof this.options.agentStore.listAgents === "function") { + const agents = await this.options.agentStore.listAgents({ includeEphemeral: true }); + // Narrow test/runtime compatibility: an incomplete legacy AgentStore + // seam cannot claim permanent routing authority, so retain its existing + // non-production fallback instead of treating an absent list as a pool. + if (!Array.isArray(agents)) { + assignedAgent = assignedAgent ?? null; + } else { + let routed = routeWorkflowPrincipal({ + task: currentTask, + ir: planningIr, + node: planningNode, + agents, + }); + if (routed.status === "held") { + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:40: + An unavailable owner or exhausted role pool is durable workflow + state, not a planner-only log. Record the held planning seam before + returning so recovery and operator surfaces retain the fail-closed + reason and no synthetic planner can silently retry around it. + */ + await this.store.upsertWorkflowWorkItem({ + runId: triageRunContext.runId, + taskId: task.id, + nodeId: planningNode.id, + nodeInstanceId: planningNode.id, + kind: "task", + state: "held", + leaseOwner: null, + leaseExpiresAt: null, + blockedReason: `workflow-principal-${routed.reason}:${routed.role}`, + principalAgentId: currentTask.assignedAgentId ?? null, + workflowRole: routed.role, + authorityKind: currentTask.assignedAgentId ? "task-assignee" : null, + }); + await this.store.logEntry(task.id, `Planning held: workflow-principal-${routed.reason}:${routed.role}`); + await this.updatePlanningStateIfStillCurrent(task, { status: "needs-replan" }); + return; + } + if (routed.status === "routed") { + assignedAgent = routed.route.agent; + triageRunContext.agentId = assignedAgent.id; + workflowCapacityAttemptId = `${triageRunContext.runId}:${planningNode.id}`; + workflowCapacityProjectId = this.options.agentStore.workflowProjectId ?? this.rootDir; + let capacity = await this.workflowAgentCapacity.acquire({ + projectId: workflowCapacityProjectId, + agent: assignedAgent, + attemptId: workflowCapacityAttemptId, + maxProjectSessions: settings.maxConcurrent, + }); + /* + FNXC:WorkflowAgentRouting 2026-08-07-07:32: + Role-pool selection is optimistic across engine processes. Retry a + different pool member only after durable agent-capacity rejects this + contender; task owners and other named principals remain fail-closed. + */ + if (capacity.status === "held" && capacity.reason === "agent-capacity" + && routed.route.authority === "role-pool") { + const excludedPoolAgentIds = new Set(); + while (capacity.status === "held" && capacity.reason === "agent-capacity" + && routed.route.authority === "role-pool") { + excludedPoolAgentIds.add(routed.route.agent.id); + const retryRoute = routeWorkflowPrincipal({ + task: currentTask, + ir: planningIr, + node: planningNode, + agents, + excludedPoolAgentIds, + }); + if (retryRoute.status !== "routed" || retryRoute.route.authority !== "role-pool") break; + routed = retryRoute; + assignedAgent = routed.route.agent; + triageRunContext.agentId = assignedAgent.id; + capacity = await this.workflowAgentCapacity.acquire({ + projectId: workflowCapacityProjectId, + agent: assignedAgent, + attemptId: workflowCapacityAttemptId, + maxProjectSessions: settings.maxConcurrent, + }); + } + } + if (capacity.status === "held") { + await this.store.logEntry(task.id, `Planning held: workflow-principal-${capacity.reason}:triage`); + await this.updatePlanningStateIfStillCurrent(task, { status: "needs-replan" }); + return; + } + try { + const item = await this.store.upsertWorkflowWorkItem({ + runId: triageRunContext.runId, + taskId: task.id, + nodeId: planningNode.id, + nodeInstanceId: planningNode.id, + kind: "task", + state: "running", + leaseOwner: `triage:${task.id}`, + leaseExpiresAt: null, + principalAgentId: assignedAgent.id, + workflowRole: routed.route.role, + authorityKind: routed.route.authority, + }); + planningWorkItemId = item.id; + if (routed.route.authority === "task-assignee") { + const fencedPrincipal = routed.route.agent; + /* + FNXC:WorkflowAgentRouting 2026-08-07-06:40: + Planning authority is valid only while this exact durable work + item is actively leased by the assigned owner. Re-read both + records on every gated call so reassignment, cancellation, or + terminalization immediately restores the owner's normal policy. + */ + planningAuthority = { + workItemId: item.id, + nodeInstanceId: planningNode.id, + principalAgentId: fencedPrincipal.id, + kind: "task-assignee", + isLive: async () => { + if (!this.activeSessions.has(task.id)) return false; + const liveItems = await this.store.listWorkflowWorkItemsForTask(task.id); + const liveItem = liveItems.find((candidate) => candidate.id === item.id); + if (liveItem?.state !== "running" + || liveItem.runId !== triageRunContext.runId + || liveItem.principalAgentId !== fencedPrincipal.id + || liveItem.authorityKind !== "task-assignee" + || liveItem.nodeInstanceId !== planningNode.id + || !liveItem.leaseOwner + || (liveItem.leaseExpiresAt !== null && Date.parse(liveItem.leaseExpiresAt) <= Date.now())) return false; + const liveTask = await this.store.getTask(task.id); + return liveTask.assignedAgentId === fencedPrincipal.id; + }, + }; + } + } catch (error) { + await this.workflowAgentCapacity.release(workflowCapacityAttemptId, workflowCapacityProjectId); + workflowCapacityAttemptId = undefined; + throw new Error(`workflow-principal-fence-unavailable:triage`, { cause: error }); + } + } + } + } /* FNXC:TriagePromptPersistence 2026-07-21-16:30: @@ -2894,7 +3067,13 @@ export class TriageProcessor { ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId: task.id, taskTitle: task.title, - actionGateContext: this.buildActionGateContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy), + actionGateContext: this.buildActionGateContext( + task.id, + triageRunContext.runId, + assignedAgent, + settings.defaultAgentPermissionPolicy, + planningAuthority, + ), permanentAgentGating: this.buildPermanentAgentGatingContext(task.id, triageRunContext.runId, assignedAgent, settings.defaultAgentPermissionPolicy), onFallbackModelUsed, }); @@ -3062,6 +3241,7 @@ export class TriageProcessor { // Re-raise errors that pi-coding-agent swallowed after exhausting retries. checkSessionError(session); + planningSessionCompleted = true; if (this.pauseAborted.has(task.id)) { this.pauseAborted.delete(task.id); @@ -3553,6 +3733,16 @@ export class TriageProcessor { } registeredPlanningPath = null; } + if (planningWorkItemId) { + await this.store.transitionWorkflowWorkItem(planningWorkItemId, planningSessionCompleted ? "succeeded" : "failed", { + leaseOwner: null, + leaseExpiresAt: null, + lastError: planningSessionCompleted ? null : "planning-session-ended-before-completion", + }).catch(() => undefined); + } + if (workflowCapacityAttemptId) { + await this.workflowAgentCapacity.release(workflowCapacityAttemptId, workflowCapacityProjectId); + } this.processing.delete(task.id); this.processingSince.delete(task.id); this.coordinatorAdmittedTaskIds.delete(task.id); diff --git a/packages/engine/src/workflows/workflow-graph-executor.ts b/packages/engine/src/workflows/workflow-graph-executor.ts index 118734f092..fd76df3cd3 100644 --- a/packages/engine/src/workflows/workflow-graph-executor.ts +++ b/packages/engine/src/workflows/workflow-graph-executor.ts @@ -9,7 +9,7 @@ import type { WorkflowNodeExtensionResult, WorkflowStepResult, } from "@fusion/core"; -import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, isCompletionSummaryNode, classifyReviewLease, isWorkflowOptionalGroupEnabled, isPlanReviewSatisfied } from "@fusion/core"; +import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, instanceNodeId, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, isCompletionSummaryNode, classifyReviewLease, isWorkflowOptionalGroupEnabled, isPlanReviewSatisfied } from "@fusion/core"; import { isNonPlanDefectPlanReviewFailure } from "../errors/transient-error-detector.js"; import { isSessionContentionError } from "../errors/transient-error-patterns.js"; import { isRequiredArtifactReadFailedValue, parseRequiredArtifactMissingValue } from "../execution/required-workflow-artifacts.js"; @@ -69,6 +69,31 @@ interleaving impossible by construction (exactly one reviewer per gate per attem */ export const PLAN_REVIEW_LEASE_HELD_VALUE = "plan-review-lease-held"; +/** + * FNXC:WorkflowAgentRouting 2026-08-07-05:37: + * Template sessions need a materialized node identity, not their reusable + * template ID. This keeps reviewer overrides and durable principal fences scoped + * to the exact foreach iteration, loop iteration, or optional-group invocation. + */ +function materializedTemplateNodeId(node: WorkflowIrNode, context: Record): string { + const parent = typeof context["workflow:node-instance-id"] === "string" + ? context["workflow:node-instance-id"] + : undefined; + const foreach = context["foreach:active"] as { foreachNodeId?: unknown; stepIndex?: unknown } | undefined; + if (typeof foreach?.foreachNodeId === "string" && typeof foreach.stepIndex === "number") { + const containerId = parent && parent !== foreach.foreachNodeId ? parent : foreach.foreachNodeId; + return instanceNodeId(containerId, foreach.stepIndex, node.id); + } + const loop = context["loop:active"] as { loopNodeId?: unknown; iteration?: unknown } | undefined; + if (typeof loop?.loopNodeId === "string" && typeof loop.iteration === "number") { + const containerId = parent && parent !== loop.loopNodeId ? parent : loop.loopNodeId; + return `${containerId}#${loop.iteration}:${node.id}`; + } + const optionalGroup = context["optional-group:active"]; + if (typeof optionalGroup === "string") return `${optionalGroup}::${node.id}`; + return node.id; +} + /* FNXC:SessionContention 2026-07-25-21:30: A node that failed because another task holds the session path / sub-repo lease it needs is CONTENDED, @@ -161,6 +186,16 @@ export interface WorkflowGraphExecutorDeps { task: TaskDetail, requirement: WorkflowNodePreparationRequirement, ) => void | Promise; + /** + * Invoked immediately before an agent-executed node handler. Implementations + * may fence a durable workflow principal or fail closed before any session is + * constructed; control nodes remain untouched when the callback is absent. + */ + beforeNodeExecution?: ( + node: WorkflowIrNode, + task: TaskDetail, + context: Record, + ) => void | WorkflowNodeResult | Promise; /** Step-inversion (U12, KTD-12): dependencies for the `parse-steps` node * handler (artifact read, projection write, pin-protection probe, audit). * Absent → a parse-steps node fails cleanly. */ @@ -185,6 +220,13 @@ export interface WorkflowGraphExecutorDeps { onBranchProgress?: (progress: WorkflowBranchProgress) => void; /** Stable identifier for this run, used to key persisted branch state. */ runId?: string; + /** + * FNXC:WorkflowAgentRouting 2026-08-07-07:45: + * Durable continuation fields supplied by a direct graph resume are copied + * into the fresh graph context before node admission. The shared router must + * revalidate the already-fenced principal rather than pool-route recovery. + */ + initialContext?: Record; /** Test seam for bounded loop timeout checks. Defaults to Date.now. */ runLoopNowForTests?: () => number; /** @@ -507,6 +549,12 @@ export class WorkflowGraphExecutor { const runId = this.deps.runId ?? `${task.id}:run`; const context: Record = { + ...this.deps.initialContext, + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:45: + * The caller restores only durable continuation metadata; this live + * interpreter invocation always owns its run and workflow identities. + */ [WORKFLOW_RUN_ID_CONTEXT_KEY]: runId, [WORKFLOW_ID_CONTEXT_KEY]: ir.name || "unknown", }; @@ -712,7 +760,7 @@ export class WorkflowGraphExecutor { getLiveSteps: () => this.resolveTaskSteps(task), context, runTemplateNode: (tNode, sig, contextOverride) => - this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false), + this.executeMaterializedTemplateNode(tNode, task, settings, contextOverride ?? context, ir, sig), shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src), persistence: this.deps.stepInstancePersistence, onReworkReset: this.deps.onReworkReset, @@ -740,7 +788,7 @@ export class WorkflowGraphExecutor { const loopResult = await runLoop(node, { context, runTemplateNode: (tNode, sig, contextOverride) => - this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig, false), + this.executeMaterializedTemplateNode(tNode, task, settings, contextOverride ?? context, ir, sig), shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src), signal: this.deps.signal, now: this.deps.runLoopNowForTests, @@ -925,7 +973,7 @@ export class WorkflowGraphExecutor { [WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY]: node.id, ...(this.workflowReviewKind(node) ? { [WORKFLOW_REVIEW_KIND_CONTEXT_KEY]: this.workflowReviewKind(node) } : {}), }; - return this.executeNodeWithRetries(tNode, task, settings, optionalGroupContext, ir, sig, false); + return this.executeMaterializedTemplateNode(tNode, task, settings, optionalGroupContext, ir, sig); }, shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src), signal: this.deps.signal, @@ -1560,6 +1608,25 @@ export class WorkflowGraphExecutor { return undefined; } + /** Execute a reusable template node under its one concrete runtime identity. */ + private async executeMaterializedTemplateNode( + node: WorkflowIrNode, + task: TaskDetail, + settings: WorkflowNodeSettings | undefined, + context: Record, + workflow: WorkflowIr, + signal?: AbortSignal, + ): Promise { + const priorInstanceId = context["workflow:node-instance-id"]; + context["workflow:node-instance-id"] = materializedTemplateNodeId(node, context); + try { + return await this.executeNodeWithRetries(node, task, settings, context, workflow, signal, false); + } finally { + if (priorInstanceId === undefined) delete context["workflow:node-instance-id"]; + else context["workflow:node-instance-id"] = priorInstanceId; + } + } + private async executeNodeWithRetries( node: WorkflowIrNode, task: TaskDetail, @@ -1581,8 +1648,32 @@ export class WorkflowGraphExecutor { for (let attempt = 0; attempt < maxAttempts; attempt++) { // Fail-fast cancellation: a branch or top-level graph abort mid-retry stops re-trying. if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" }); + let releasePrincipal: (() => void) | undefined; try { await this.prepareNodeExecution(node, task, context, settings); + const preflight = await this.deps.beforeNodeExecution?.(node, task, context); + releasePrincipal = typeof context["workflow:release-principal"] === "function" + ? context["workflow:release-principal"] as () => void + : undefined; + if (preflight) { + /* + * FNXC:WorkflowAgentRouting 2026-08-07-06:53: + * Agent routing and capacity refusals have already persisted a durable + * held work item. Suspend traversal rather than following a failure + * edge, because temporary principal unavailability must not terminalize + * the task or convert an operator-visible hold into graph failure. + */ + if (preflight.outcome === "failure" && typeof preflight.value === "string" && preflight.value.startsWith("workflow-principal-")) { + throw new WorkflowGraphSuspended({ + reason: "capacity", + nodeId: node.id, + fromColumn: task.column, + toColumn: task.column, + irHash: "workflow-principal-hold", + }); + } + return preflight; + } const progressRecord = recordProgress && this.shouldRecordNodeProgress(node) ? await this.recordNodeProgressStart(task.id, node) : null; @@ -1610,6 +1701,7 @@ export class WorkflowGraphExecutor { } return projected; } catch (error) { + if (error instanceof WorkflowGraphSuspended) throw error; if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" }); /* FNXC:WorktreeBaseRefresh 2026-08-01-16:33: @@ -1640,6 +1732,15 @@ export class WorkflowGraphExecutor { retrying here and hand the executor a typed contention failure it can back off on. */ if (isSessionContentionError(error instanceof Error ? error.message : String(error))) break; + } finally { + /* + * FNXC:WorkflowAgentRouting 2026-08-07-04:13: + * A workflow reservation lasts for one handler attempt, not the entire + * graph. Releasing here covers success, handler errors, cancellation, + * and retry paths while the executor's outer finally remains crash-safe. + */ + releasePrincipal?.(); + delete context["workflow:release-principal"]; } } diff --git a/packages/engine/src/workflows/workflow-graph-loop.ts b/packages/engine/src/workflows/workflow-graph-loop.ts index 309d0b0270..353c424521 100644 --- a/packages/engine/src/workflows/workflow-graph-loop.ts +++ b/packages/engine/src/workflows/workflow-graph-loop.ts @@ -268,7 +268,18 @@ export async function runOptionalGroup( const entry = findTemplateEntry(template.nodes, template.edges, groupNode.id); const visitedNodeIds: string[] = []; - const groupContext: Record = { ...env.context }; + /* + * FNXC:WorkflowAgentRouting 2026-08-07-05:37: + * Keep the enclosing optional-group's materialized identity while its reusable + * template runs. The graph executor extends this marker for each child so a + * reviewer override cannot leak between separate group invocations. + */ + const groupContext: Record = { + ...env.context, + "optional-group:active": typeof env.context["workflow:node-instance-id"] === "string" + ? env.context["workflow:node-instance-id"] + : groupNode.id, + }; let current: WorkflowIrNode | undefined = entry; let lastResult: WorkflowNodeResult = { outcome: "success" }; diff --git a/packages/engine/src/workflows/workflow-graph-task-runner.ts b/packages/engine/src/workflows/workflow-graph-task-runner.ts index d1e59241f9..0ef6e15202 100644 --- a/packages/engine/src/workflows/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflows/workflow-graph-task-runner.ts @@ -95,6 +95,8 @@ export interface WorkflowGraphTaskRunnerDeps { task: TaskDetail, requirement: WorkflowNodePreparationRequirement, ) => void | Promise; + /** Durable principal fence invoked before classified node handlers. */ + beforeNodeExecution?: WorkflowGraphExecutorDeps["beforeNodeExecution"]; maxRetriesPerNode?: number; /** Optional diagnostics hook (audit/log emission). Never throws into the run. */ onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void; @@ -225,6 +227,7 @@ export class WorkflowGraphTaskRunner { task: TaskDetail, settings: Pick | undefined, startNodeId?: string, + initialContext?: Record, ): Promise { let selection: { workflowId: string; stepIds: string[] } | undefined; try { @@ -380,6 +383,7 @@ export class WorkflowGraphTaskRunner { primitives: wrappedPrimitives, runCustomNode: wrappedRunCustomNode, prepareNodeExecution: this.deps.prepareNodeExecution, + beforeNodeExecution: this.deps.beforeNodeExecution, maxRetriesPerNode: this.deps.maxRetriesPerNode, branchPersistence: this.deps.branchPersistence, branchSemaphore: this.deps.branchSemaphore, @@ -416,6 +420,14 @@ export class WorkflowGraphTaskRunner { // executor's persistence deps probe/flip rows under the SAME id; fall back // to the canonical derivation when unthreaded. runId: this.deps.runId ?? `${task.id}:${definition.id}`, + /* + * FNXC:WorkflowAgentRouting 2026-08-07-07:45: + * Direct graph recovery must restore a continuation's principal fence + * into the interpreter context before node admission. Otherwise resume + * would re-run precedence routing and could replace a held reviewer or + * role-pool principal after a restart. + */ + initialContext, onBranchProgress: (progress) => { this.branchProgress.set(progress.branchId, progress); try { diff --git a/packages/engine/src/workflows/workflow-task-runtime.ts b/packages/engine/src/workflows/workflow-task-runtime.ts index 19ec494ad3..c36de1324e 100644 --- a/packages/engine/src/workflows/workflow-task-runtime.ts +++ b/packages/engine/src/workflows/workflow-task-runtime.ts @@ -22,6 +22,7 @@ import { import type { WorkflowRuntimePrimitives } from "../execution/runtime-primitives.js"; import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js"; import { requiresNonEmptyWorkflowArtifact } from "../execution/required-workflow-artifacts.js"; +import { findWorkflowNodeInstance, type WorkflowPrincipalRouteResult } from "../agents/workflow-agent-router.js"; export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required"; @@ -42,11 +43,23 @@ export interface WorkflowTaskRuntimeDeps extends Omit WorkflowWorkItem; + patch?: { + now?: string; + lastError?: string | null; + blockedReason?: string | null; + leaseOwner?: string | null; + leaseExpiresAt?: string | null; + principalAgentId?: string | null; + workflowRole?: WorkflowWorkItem["workflowRole"]; + authorityKind?: WorkflowWorkItem["authorityKind"]; + nodeInstanceId?: string | null; + }, + ) => WorkflowWorkItem | Promise; }; primitives: WorkflowRuntimePrimitives; runCustomNode: WorkflowCustomNodeRunner; + /** Resolves and fences a principal at the graph-runtime boundary before a session handler runs. */ + resolveWorkflowPrincipal?: (input: { task: TaskDetail; ir: WorkflowIr; node: WorkflowIrNode; workItem: WorkflowWorkItem }) => Promise | WorkflowPrincipalRouteResult; onEvent?: (event: { type: "start" | "terminal"; taskId: string; detail: string }) => void; } @@ -185,12 +198,23 @@ export class WorkflowTaskRuntime { return this.failWorkItem(workItem, `workflow-resolution-error: ${err instanceof Error ? err.message : String(err)}`); } - const node = target.ir.nodes.find((candidate) => candidate.id === workItem.nodeId); + const node = findWorkflowNodeInstance(target.ir, workItem.nodeInstanceId ?? workItem.nodeId); if (!node) { - return this.failWorkItem(workItem, `workflow-work-item-node-missing:${workItem.nodeId}`); + return this.failWorkItem(workItem, `workflow-work-item-node-missing:${workItem.nodeInstanceId ?? workItem.nodeId}`); } - if (workItem.kind === "merge" || workItem.kind === "manual-hold") { + const fencedWorkItem = await this.resolveAndFencePrincipal(workItem, task, target.ir, node); + if (!fencedWorkItem) { + return { + disposition: "manual-required", + outcome: "failure", + visitedNodeIds: [node.id], + context: {}, + reason: "workflow-principal-unavailable", + }; + } + + if (fencedWorkItem.kind === "merge" || workItem.kind === "manual-hold") { await ensureWorkflowCompletionSummary(this.deps.store, task, { reason: `workflow-work-item:${workItem.kind}`, workflowId: target.workflowId, @@ -208,17 +232,28 @@ export class WorkflowTaskRuntime { let outcome: WorkflowNodeOutcome = "success"; let reason: string | undefined; let context: Record = { - [WORKFLOW_RUN_ID_CONTEXT_KEY]: workItem.runId, + [WORKFLOW_RUN_ID_CONTEXT_KEY]: fencedWorkItem.runId, [WORKFLOW_ID_CONTEXT_KEY]: target.workflowId, - "workflow:work-item-id": workItem.id, - "workflow:work-item-kind": workItem.kind, - "workflow:work-item-attempt": workItem.attempt, + "workflow:work-item-id": fencedWorkItem.id, + "workflow:work-item-kind": fencedWorkItem.kind, + "workflow:work-item-attempt": fencedWorkItem.attempt, + "workflow:principal-agent-id": fencedWorkItem.principalAgentId, + "workflow:principal-role": fencedWorkItem.workflowRole, + "workflow:principal-authority": fencedWorkItem.authorityKind, + "workflow:node-instance-id": fencedWorkItem.nodeInstanceId, }; try { - const result = handler + /* + * FNXC:WorkflowAgentRouting 2026-08-07-03:46: + * Durable work-item dispatch uses the same pre-handler fence as whole + * graph dispatch. This keeps a claimed principal/capacity decision ahead + * of every session construction while control nodes remain principal-free. + */ + const preflight = await this.deps.beforeNodeExecution?.(node, task, context); + const result = preflight ?? (handler ? await handler(node, { task, settings: runtimeSettings, context }) - : { outcome: "success" as const }; + : { outcome: "success" as const }); outcome = result.outcome; if (result.value !== undefined) context[`node:${node.id}:value`] = result.value; context = { ...context, ...(result.contextPatch ?? {}) }; @@ -228,22 +263,33 @@ export class WorkflowTaskRuntime { reason = `workflow-work-item-node-error:${err instanceof Error ? err.message : String(err)}`; } + /* + * FNXC:WorkflowAgentRouting 2026-08-07-06:53: + * Principal routing and workflow capacity are availability holds, not node + * failures. Retain the fenced work item in `held` with its operator-visible + * reason so scheduler recovery may retry the same work instead of terminalizing + * a task merely because its named owner or role pool is temporarily unavailable. + */ + const principalHold = outcome === "failure" && reason?.startsWith("workflow-principal-"); const disposition: WorkflowTaskRuntimeDisposition = outcome === "success" ? "completed" - : reason === "manual-required" + : principalHold || reason === "manual-required" ? "manual-required" : "failed"; const terminalState: WorkflowWorkItemState = disposition === "completed" ? "succeeded" - : disposition === "manual-required" - ? "manual-required" - : "failed"; - this.deps.store.transitionWorkflowWorkItem(workItem.id, terminalState, { + : principalHold + ? "held" + : disposition === "manual-required" + ? "manual-required" + : "failed"; + await this.deps.store.transitionWorkflowWorkItem(fencedWorkItem.id, terminalState, { leaseOwner: null, leaseExpiresAt: null, lastError: reason ?? null, + ...(principalHold ? { blockedReason: reason } : {}), }); - this.emit("terminal", workItem.taskId, `work-item:${disposition}`); + this.emit("terminal", workItem.taskId, `work-item:${principalHold ? "held" : disposition}`); return { disposition, outcome, @@ -253,6 +299,51 @@ export class WorkflowTaskRuntime { }; } + /** + * FNXC:WorkflowAgentRouting 2026-08-07-07:32: + * Principal resolution runs immediately after the durable lease is claimed and + * before a handler can construct a session. Recovery revalidates an existing + * fence through the same resolver, then accepts only its exact principal, role, + * and authority; it must never pool-route a stale attempt to a new identity. + */ + private async resolveAndFencePrincipal( + workItem: WorkflowWorkItem, + task: TaskDetail, + ir: WorkflowIr, + node: WorkflowIrNode, + ): Promise { + if (!this.deps.resolveWorkflowPrincipal) return workItem; + const result = await this.deps.resolveWorkflowPrincipal({ task, ir, node, workItem }); + if (result.status === "unclassified") return workItem; + if (result.status === "held") { + await this.deps.store.transitionWorkflowWorkItem!(workItem.id, "held", { + leaseOwner: null, + leaseExpiresAt: null, + lastError: `workflow-${result.reason}:${result.role}`, + blockedReason: `workflow-${result.reason}:${result.role}`, + }); + return null; + } + if (workItem.principalAgentId) { + if (workItem.principalAgentId === result.route.agent.id + && workItem.workflowRole === result.route.role + && workItem.authorityKind === result.route.authority) return workItem; + await this.deps.store.transitionWorkflowWorkItem!(workItem.id, "held", { + leaseOwner: null, + leaseExpiresAt: null, + lastError: "workflow-named-principal-unavailable:fence-mismatch", + blockedReason: "workflow-named-principal-unavailable:fence-mismatch", + }); + return null; + } + return this.deps.store.transitionWorkflowWorkItem!(workItem.id, "running", { + principalAgentId: result.route.agent.id, + workflowRole: result.route.role, + authorityKind: result.route.authority, + nodeInstanceId: workItem.nodeInstanceId ?? node.id, + }); + } + private failWorkItem(workItem: WorkflowWorkItem, reason: string): WorkflowTaskRuntimeResult { this.deps.store.transitionWorkflowWorkItem!(workItem.id, "failed", { leaseOwner: null, diff --git a/packages/engine/src/workflows/workflow-work-scheduler.ts b/packages/engine/src/workflows/workflow-work-scheduler.ts index 32f639cfdd..03ead17b03 100644 --- a/packages/engine/src/workflows/workflow-work-scheduler.ts +++ b/packages/engine/src/workflows/workflow-work-scheduler.ts @@ -4,12 +4,12 @@ import { decideMissionSymbolAdmission } from "../missions/mission-symbol-admissi const WORKFLOW_SYMBOL_LOCK_LEASE_MS = 10 * 60_000; export interface WorkflowWorkSchedulerStore { - listDueWorkflowWorkItems(filter?: WorkflowWorkItemDueFilter): WorkflowWorkItem[]; + listDueWorkflowWorkItems(filter?: WorkflowWorkItemDueFilter): WorkflowWorkItem[] | Promise; acquireWorkflowWorkItemLease( id: string, leaseOwner: string, opts: { leaseDurationMs: number; now?: string }, - ): WorkflowWorkItem | null; + ): WorkflowWorkItem | null | Promise; /** TaskStore supplies these optional scheduler-admission capabilities. */ getTask?(id: string): Promise; getMissionStore?(): MissionStore | AsyncMissionStore; @@ -52,7 +52,7 @@ export async function claimDueWorkflowWorkItem( store: WorkflowWorkSchedulerStore, opts: ClaimWorkflowWorkOptions, ): Promise { - const due = store.listDueWorkflowWorkItems({ + const due = await store.listDueWorkflowWorkItems({ now: opts.now, limit: opts.limit ?? 25, kinds: opts.kinds, @@ -85,7 +85,7 @@ export async function claimDueWorkflowWorkItem( } } - const workItem = store.acquireWorkflowWorkItemLease(candidate.id, opts.leaseOwner, { + const workItem = await store.acquireWorkflowWorkItemLease(candidate.id, opts.leaseOwner, { now: opts.now, leaseDurationMs: opts.leaseDurationMs, });