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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 19:29:42 -07:00
parent f775039f5b
commit 4c9f14e21a
6 changed files with 121 additions and 13 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 });

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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<string, number>();
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),