From 4c9f14e21a2063ed412d2a59b0b0f9a2e06ee0d8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 10 Aug 2026 19:29:42 -0700 Subject: [PATCH] FN-8965: restore explicit intake owner routing Restore deliberate engineer and authorized override assignments while preserving executor-only automatic routing. - Accept eligible engineers as explicit intake owners through normal and reserved-ID task creation. - Honor metadata-authorized role overrides without weakening runtime, state, or assignment-policy gates. - Add resolver and PostgreSQL coverage plus release and architecture documentation. Files changed: .../fn-8965-intake-owner-explicit-routing.md | 7 +++ docs/architecture.md | 2 +- .../postgres/create-task-reserved-id.pg.test.ts | 29 ++++++++++++ .../__tests__/task-intake-owner-resolver.test.ts | 51 ++++++++++++++++++++-- packages/core/src/task-store/task-creation.ts | 7 +++ .../core/src/tasks/task-intake-owner-resolver.ts | 38 ++++++++++++---- 6 files changed, 121 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8965 Fusion-Task-Lineage: 4ecf7fc8-e966-49c8-abf6-e50ae26d8224 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8965-intake-owner-explicit-routing.md | 7 +++ docs/architecture.md | 2 +- .../create-task-reserved-id.pg.test.ts | 29 +++++++++++ .../task-intake-owner-resolver.test.ts | 51 +++++++++++++++++-- packages/core/src/task-store/task-creation.ts | 7 +++ .../src/tasks/task-intake-owner-resolver.ts | 38 +++++++++++--- 6 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-8965-intake-owner-explicit-routing.md diff --git a/.changeset/fn-8965-intake-owner-explicit-routing.md b/.changeset/fn-8965-intake-owner-explicit-routing.md new file mode 100644 index 0000000000..c52cbd0ac4 --- /dev/null +++ b/.changeset/fn-8965-intake-owner-explicit-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore explicit engineer and operator-override task assignment in CLI tools. +category: fix +dev: resolveTaskIntakeOwner now accepts explicit engineer routing and sourceMetadata.executorRoleOverride while retaining automatic executor-only routing. diff --git a/docs/architecture.md b/docs/architecture.md index 8463b3b180..e179f393c5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2397,7 +2397,7 @@ The evaluator fences the latest lock version, current-plan evidence version/hash ## Durable intake executor ownership -FN-8843 resolves task ownership at `_createTaskInternalBackendImpl`, the one pre-insert boundary shared by normal and reserved-ID task creation. A store-owned, project-scoped `AgentStore` supplies the durable-agent snapshot, so each intake does not open its own backend. It resolves an effective workflow independently of optional-step materialization; `workflowId: null` means no workflow steps, not no owner, and uses the eligible executor pool directly. An owner is a non-ephemeral durable executor with runtime enabled, a non-paused/non-error state, and a permitted implementation assignment policy. Explicit eligible ownership wins, followed by a reachable execute-node column binding and the pool ordered by active durable session, creation time, then ID. +FN-8843 resolves task ownership at `_createTaskInternalBackendImpl`, the one pre-insert boundary shared by normal and reserved-ID task creation. A store-owned, project-scoped `AgentStore` supplies the durable-agent snapshot, so each intake does not open its own backend. It resolves an effective workflow independently of optional-step materialization; `workflowId: null` means no workflow steps, not no owner, and uses the eligible executor pool directly. An owner is a non-ephemeral durable agent with runtime enabled, a non-paused/non-error state, and a permitted implementation assignment policy. Explicit ownership accepts executor or engineer roles, and `sourceMetadata.executorRoleOverride: true` bypasses only the explicit role check; policy `none` and all non-role gates remain hard floors. Reachable execute-node bindings and the automatic pool remain executor-only and are ordered by active durable session, creation time, then ID. The resolver deliberately separates four outcomes: `selected` writes the owner on insert; internal options-only `exempt` writes deliberate null for terminal/historical/fixture creators; `rejected` fails before row/reservation/event publication; and `unowned` succeeds only for a zero-eligible-executor project, with null owner plus a `task:intake-owner-unresolved` run-audit event. Named terminal, historical, and fixture-only factories issue the opaque in-process exemption capability; its module-private symbol token makes it non-serializable, so public API, CLI, tool, automation, and remote-node payloads cannot forge it. Per-stage workflow work items still own planning/review principals; stable task ownership only participates in executor routing. diff --git a/packages/core/src/__tests__/postgres/create-task-reserved-id.pg.test.ts b/packages/core/src/__tests__/postgres/create-task-reserved-id.pg.test.ts index de72ba0573..ec7341af74 100644 --- a/packages/core/src/__tests__/postgres/create-task-reserved-id.pg.test.ts +++ b/packages/core/src/__tests__/postgres/create-task-reserved-id.pg.test.ts @@ -70,6 +70,35 @@ pgDescribe("createTaskWithReservedId backend mode (PostgreSQL)", () => { } }); + it("accepts explicit engineers and metadata-authorized overrides through both create gateways", async () => { + const h = await makeHarness(); + const agents = new AgentStore({ rootDir: h.rootDir, asyncLayer: h.layer, projectId: h.layer.projectId }); + try { + const engineer = await agents.createAgent({ name: "Explicit intake engineer", role: "engineer" }); + const reviewer = await agents.createAgent({ name: "Override intake reviewer", role: "reviewer" }); + const ordinary = await h.store.createTask({ + description: "ordinary explicit engineer intake owner", + assignedAgentId: engineer.id, + }); + const reserved = await h.store.createTaskWithReservedId( + { + description: "reserved explicit override intake owner", + assignedAgentId: reviewer.id, + source: { sourceType: "cli", sourceMetadata: { executorRoleOverride: true } }, + }, + { taskId: "FN-OWNER-RESERVED-OVERRIDE", applyDefaultWorkflowSteps: false }, + ); + + expect(ordinary.assignedAgentId).toBe(engineer.id); + expect((await h.store.getTask(ordinary.id))?.assignedAgentId).toBe(engineer.id); + expect(reserved.assignedAgentId).toBe(reviewer.id); + expect((await h.store.getTask(reserved.id))?.assignedAgentId).toBe(reviewer.id); + } finally { + agents.close(); + await teardown(); + } + }); + it("fails rejected owner outcomes before either gateway inserts a row", async () => { const h = await makeHarness(); const agents = new AgentStore({ rootDir: h.rootDir, asyncLayer: h.layer, projectId: h.layer.projectId }); diff --git a/packages/core/src/__tests__/task-intake-owner-resolver.test.ts b/packages/core/src/__tests__/task-intake-owner-resolver.test.ts index 264733a76d..b8f712d859 100644 --- a/packages/core/src/__tests__/task-intake-owner-resolver.test.ts +++ b/packages/core/src/__tests__/task-intake-owner-resolver.test.ts @@ -31,8 +31,40 @@ describe("resolveTaskIntakeOwner", () => { expect(resolveTaskIntakeOwner({ workflow, explicitAssigneeId: "explicit", agents: [agent("bound"), agent("explicit")] })).toEqual({ status: "selected", agentId: "explicit", source: "explicit" }); }); - it("rejects an ineligible explicit owner and unavailable named execute binding", () => { - expect(resolveTaskIntakeOwner({ workflow, explicitAssigneeId: "triage", agents: [agent("triage", { roles: ["triage"], role: "triage" })] })).toEqual({ status: "rejected", reason: "explicit-assignee-ineligible" }); + it("uses the canonical explicit-routing policy for engineers and operator overrides", () => { + const engineer = agent("engineer", { roles: ["engineer"], role: "engineer" }); + const deprecatedEngineer = agent("deprecated-engineer", { roles: [], role: "engineer" }); + const multiRoleEngineer = agent("multi-role-engineer", { roles: ["reviewer", "engineer"], role: "reviewer" }); + const reviewer = agent("reviewer", { roles: ["reviewer"], role: "reviewer" }); + for (const selected of [engineer, deprecatedEngineer, multiRoleEngineer]) { + expect(resolveTaskIntakeOwner({ workflow, explicitAssigneeId: selected.id, agents: [selected] })) + .toEqual({ status: "selected", agentId: selected.id, source: "explicit" }); + } + expect(resolveTaskIntakeOwner({ workflow, explicitAssigneeId: reviewer.id, agents: [reviewer] })) + .toEqual({ status: "rejected", reason: "explicit-assignee-ineligible" }); + expect(resolveTaskIntakeOwner({ workflow, explicitAssigneeId: reviewer.id, explicitAssigneeRoleOverride: true, agents: [reviewer] })) + .toEqual({ status: "selected", agentId: reviewer.id, source: "explicit" }); + }); + + it("keeps non-role explicit eligibility gates hard even with an override", () => { + const rejected = [ + agent("policy-none", { runtimeConfig: { assignmentPolicy: "none" } }), + agent("ephemeral", { metadata: { internal: true } }), + agent("disabled", { runtimeConfig: { enabled: false } }), + agent("paused", { state: "paused" }), + agent("error", { state: "error" }), + ]; + for (const candidate of rejected) { + expect(resolveTaskIntakeOwner({ + workflow, + explicitAssigneeId: candidate.id, + explicitAssigneeRoleOverride: true, + agents: [candidate], + })).toEqual({ status: "rejected", reason: "explicit-assignee-ineligible" }); + } + }); + + it("rejects an unavailable named execute binding", () => { expect(resolveTaskIntakeOwner({ workflow, agents: [agent("pool")] })).toEqual({ status: "rejected", reason: "named-execute-binding-unavailable" }); }); @@ -92,19 +124,30 @@ describe("resolveTaskIntakeOwner", () => { expect(resolveTaskIntakeOwner({ workflow: "no-workflow-context", agents: [] })).toEqual({ status: "unowned", reason: "no-eligible-executor" }); }); - it("keeps automatic selection role-safe and orders its pool by load, creation time, then id", () => { + it("keeps automatic selection executor-only and orders its pool by load, creation time, then id", () => { const explicitOnly = agent("explicit-only", { runtimeConfig: { assignmentPolicy: "explicit-only" } }); + const engineer = agent("engineer", { roles: ["engineer"], role: "engineer" }); const paused = agent("paused", { state: "paused" }); const policyDenied = agent("denied", { runtimeConfig: { assignmentPolicy: "none" } }); const olderBusy = agent("older-busy", { createdAt: "2025-01-01T00:00:00.000Z" }); const newerIdle = agent("newer-idle", { createdAt: "2026-01-01T00:00:00.000Z" }); expect(resolveTaskIntakeOwner({ workflow: "no-workflow-context", - agents: [explicitOnly, paused, policyDenied, olderBusy, newerIdle], + agents: [explicitOnly, engineer, paused, policyDenied, olderBusy, newerIdle], activeSessions: new Map([["older-busy", 1]]), })).toEqual({ status: "selected", agentId: "newer-idle", source: "executor-pool" }); expect(resolveTaskIntakeOwner({ workflow: "no-workflow-context", explicitAssigneeId: "explicit-only", agents: [explicitOnly] })) .toEqual({ status: "selected", agentId: "explicit-only", source: "explicit" }); + expect(resolveTaskIntakeOwner({ workflow: "no-workflow-context", agents: [engineer] })) + .toEqual({ status: "unowned", reason: "no-eligible-executor" }); + const engineerBinding = { + ...workflow, + columns: workflow.columns.map((column) => column.id === "work" + ? { ...column, agent: { agentId: engineer.id, mode: "defer" as const } } + : column), + }; + expect(resolveTaskIntakeOwner({ workflow: engineerBinding, agents: [engineer] })) + .toEqual({ status: "rejected", reason: "named-execute-binding-unavailable" }); }); it("finds a reachable executor nested inside a container template", () => { diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index bc310ceb29..9356744000 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -546,6 +546,13 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta // explicit owner". Normalize it to omission so it follows pool/binding // resolution rather than becoming an ineligible named assignee. explicitAssigneeId: input.assignedAgentId ?? undefined, + /* + FNXC:IntakeOwnership 2026-08-11-02:04: + sourceMetadata.executorRoleOverride is the only create-time role override channel. Explicit operator/tool + override:true writes it (CLI extension.ts and engine agent-tools.ts), so intake honors the same contract as + every binding surface without exposing a public create-input opt-out. + */ + explicitAssigneeRoleOverride: input.source?.sourceMetadata?.executorRoleOverride === true, agents, enabledWorkflowSteps: resolvedWorkflowSteps, activeSessions, diff --git a/packages/core/src/tasks/task-intake-owner-resolver.ts b/packages/core/src/tasks/task-intake-owner-resolver.ts index a32c2c9862..de347e3b69 100644 --- a/packages/core/src/tasks/task-intake-owner-resolver.ts +++ b/packages/core/src/tasks/task-intake-owner-resolver.ts @@ -3,7 +3,12 @@ import type { WorkflowIr, WorkflowIrNode } from "../workflows/workflow-ir-types. import { classifyWorkflowAgentNode } from "../workflows/workflow-ir-types.js"; import { instanceNodeId, resolveColumnAgentBinding, resolveEffectiveAgent } from "../agents/column-agent-resolver.js"; import { isEphemeralAgent } from "../types.js"; -import { canAgentReceiveImplementationTasks, isAgentAutoAssignable } from "../agents/agent-role-policy.js"; +import { + canAgentReceiveImplementationTasks, + canAgentTakeImplementationTaskForExplicitRouting, + isAgentAutoAssignable, + isExecutorRoleAgent, +} from "../agents/agent-role-policy.js"; export type TaskIntakeOwnerResolution = | { status: "selected"; agentId: string; source: "explicit" | "execute-binding" | "executor-pool" } @@ -71,6 +76,11 @@ export function getInternalIntakeOwnershipExemptionReason(value: unknown): Intak export interface ResolveTaskIntakeOwnerInput { workflow: WorkflowIr | "no-workflow-context" | "unresolvable"; explicitAssigneeId?: string; + /** + * Bypasses only the explicit assignee's role check. It never bypasses policy "none", + * ephemeral, disabled-runtime, paused, or error eligibility gates, and cannot affect automatic routing. + */ + explicitAssigneeRoleOverride?: boolean; agents?: readonly Agent[]; /** Internal plumbing supplies this only after validating an opaque exemption capability. */ ownershipExemptionReason?: IntakeOwnershipExemptionReason; @@ -93,22 +103,34 @@ export function resolveTaskIntakeOwner(input: ResolveTaskIntakeOwnerInput): Task if (!input.agents) return { status: "rejected", reason: "agent-backend-unavailable" }; if (input.workflow === "unresolvable") return { status: "rejected", reason: "workflow-unresolvable" }; - const eligible = (agent: Agent | undefined, automatic: boolean): agent is Agent => Boolean( + const meetsNonRoleEligibility = (agent: Agent | undefined): agent is Agent => Boolean( agent && !isEphemeralAgent(agent) - && agent.roles.includes("executor") && agent.runtimeConfig?.enabled !== false && agent.state !== "paused" && agent.state !== "error" - && canAgentReceiveImplementationTasks(agent) + && canAgentReceiveImplementationTasks(agent), + ); + const automaticallyEligible = (agent: Agent | undefined): agent is Agent => Boolean( + meetsNonRoleEligibility(agent) // `explicit-only` is valid only for a caller's deliberate assignee. A binding // or pool choice is automatic routing and must retain that policy boundary. - && (!automatic || isAgentAutoAssignable(agent)), + && isAgentAutoAssignable(agent) + && isExecutorRoleAgent(agent), ); const byId = new Map(input.agents.map((agent) => [agent.id, agent])); if (input.explicitAssigneeId !== undefined) { const explicit = byId.get(input.explicitAssigneeId); - return eligible(explicit, false) + /* + FNXC:IntakeOwnership 2026-08-11-02:04: + FN-8843 duplicated an executor-only role check here, contradicting explicit-routing policy and the + executorRoleOverride contract so every explicitly assigned engineer or operator override failed before insert. + The shared policy now owns the explicit role decision; binding and pool routing remain executor-only. + */ + const explicitRoleEligible = explicit !== undefined + && (input.explicitAssigneeRoleOverride === true + || canAgentTakeImplementationTaskForExplicitRouting(explicit, { column: "todo" })); + return meetsNonRoleEligibility(explicit) && explicitRoleEligible ? { status: "selected", agentId: explicit.id, source: "explicit" } : { status: "rejected", reason: "explicit-assignee-ineligible" }; } @@ -123,7 +145,7 @@ export function resolveTaskIntakeOwner(input: ResolveTaskIntakeOwnerInput): Task }); if (effective.source === "column-agent") { const bound = byId.get(effective.agentId); - return eligible(bound, true) + return automaticallyEligible(bound) ? { status: "selected", agentId: bound.id, source: "execute-binding" } : { status: "rejected", reason: "named-execute-binding-unavailable" }; } @@ -131,7 +153,7 @@ export function resolveTaskIntakeOwner(input: ResolveTaskIntakeOwnerInput): Task } const activeSessions = input.activeSessions ?? new Map(); - const pool = input.agents.filter((agent) => eligible(agent, true)).sort((a, b) => + const pool = input.agents.filter((agent) => automaticallyEligible(agent)).sort((a, b) => (activeSessions.get(a.id) ?? 0) - (activeSessions.get(b.id) ?? 0) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),