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:
7
.changeset/fn-8965-intake-owner-explicit-routing.md
Normal file
7
.changeset/fn-8965-intake-owner-explicit-routing.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user