fix: allow freeform chat task create without mission lineage (#2406)

## Summary

Chat-driven `fn_task_create` rejected freeform intake with `Approved
mission_lineage is required` even though the tool schema marks
`mission_lineage` as optional. That was FN-8307 mission admission
over-applied beyond autonomous heartbeat patrol.

This restores freeform chat/board-equivalent creates while keeping
idle-heartbeat mission-lineage enforcement.

## What changed

- **`fn_task_create` / `fn_delegate_task`**: omit `mission_lineage`
succeeds for user-directed surfaces; hard-require only when the tool is
registered with `requireMissionLineage` (idle heartbeat patrol).
- **Gates**: missing lineage is policy-governed (`allow` /
`require-approval` / `block`) instead of a hard pre-block, so
permanent-agent chat can create freeform tasks under normal policy.
- **Heartbeat no-task delegate**: also sets `requireMissionLineage:
true` so freeform off-mission work cannot bypass admission via
`fn_delegate_task`.
- Supplied lineage is still fully validated (Feature → Slice → Milestone
→ Mission) on every surface.
- Parent inheritance still applies when not in require mode.

## Test plan

- [x] Unit: freeform `fn_task_create` without lineage creates a task
with no `missionId`/`sliceId`
- [x] Unit: freeform `fn_delegate_task` without lineage succeeds
- [x] Unit: `requireMissionLineage: true` still hard-fails without
lineage
- [x] Unit: gates treat missing lineage as policy disposition, not hard
block
- [ ] CI gate green

## Symptom

**Original:** chat tool call `{ description: "Create a red button",
priority: "high" }` → `ERROR: Approved mission_lineage is required; no
task was created.`

**Expected after fix:** task is created freeform without mission fields.


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

## Summary by CodeRabbit

- **Bug Fixes**
- Freeform chat task creation and delegation can now proceed without
`mission_lineage`.
- Permission policies continue to govern these actions, including
approval requirements.
- Autonomous idle patrols still require approved mission lineage before
creating or delegating tasks.
- Task creation no longer receives mission-specific metadata when no
lineage is provided.

- **Tests**
- Expanded coverage for freeform and mission-linked task creation,
delegation, and policy-gating scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-22 13:36:23 -07:00
committed by GitHub
parent d194290a75
commit f63047871c
10 changed files with 211 additions and 95 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Allow freeform chat task creation without mission lineage.
category: fix
dev: `fn_task_create` / `fn_delegate_task` only hard-require approved `mission_lineage` when registered with `requireMissionLineage` (idle heartbeat patrol). User-directed chat/create paths may omit lineage; gates no longer pre-block missing lineage so freeform intake remains policy-governed.

View File

@@ -536,10 +536,16 @@ describe("agent-action-gate", () => {
"fn_task_import_gitlab_group_issues", "fn_task_import_gitlab_group_issues",
"fn_task_import_gitlab_merge_requests", "fn_task_import_gitlab_merge_requests",
] as const)("governs task creation/import tool %s as task_agent_mutation", (toolName) => { ] as const)("governs task creation/import tool %s as task_agent_mutation", (toolName) => {
const args = toolName === "fn_task_create" || toolName === "fn_delegate_task" /*
? { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } } FNXC:EngineTests 2026-07-22-13:07:
: {}; Freeform creates omit mission_lineage and still follow policy disposition
for (const argsValue of [args, args]) { (require-approval / block) rather than a hard mission-admission pre-block.
*/
const argVariants =
toolName === "fn_task_create" || toolName === "fn_delegate_task"
? [{}, { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }]
: [{}];
for (const argsValue of argVariants) {
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: argsValue, permissionPolicy: approvalPolicy })).toMatchObject({ expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: argsValue, permissionPolicy: approvalPolicy })).toMatchObject({
category: "task_agent_mutation", category: "task_agent_mutation",
disposition: "require-approval", disposition: "require-approval",

View File

@@ -492,6 +492,67 @@ describe("createDelegateTaskTool", () => {
expect(taskStore.createTask).not.toHaveBeenCalled(); expect(taskStore.createTask).not.toHaveBeenCalled();
}); });
/*
FNXC:EngineTests 2026-07-22-13:07:
Chat/user-directed freeform intake omits mission_lineage. Schema marks it optional;
the tool factory must create the task without mission fields rather than hard-fail.
*/
it("creates freeform chat-style tasks when mission_lineage is omitted", async () => {
const tool = createTaskCreateTool(taskStore, { sourceType: "api" }, { rootDir: "/project" });
const result = await tool.execute(
"call-1",
{ description: "Create a red button", priority: "high" },
undefined as any,
undefined as any,
undefined as any,
);
expect(result).not.toMatchObject({ isError: true });
expect(taskStore.createTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Create a red button",
priority: "high",
source: expect.objectContaining({ sourceType: "api" }),
}),
expect.anything(),
);
const createInput = vi.mocked(taskStore.createTask).mock.calls[0]?.[0] as Record<string, unknown>;
expect(createInput.missionId).toBeUndefined();
expect(createInput.sliceId).toBeUndefined();
});
it("delegates freeform tasks when mission_lineage is omitted", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-060",
description: "Create a red button",
dependencies: [],
column: "todo" as const,
assignedAgentId: "agent-001",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as Task);
const tool = createDelegateTaskTool(agentStore, taskStore);
const result = await tool.execute(
"call-1",
{ agent_id: "agent-001", description: "Create a red button" },
undefined as any,
undefined as any,
undefined as any,
);
expect(result).not.toMatchObject({ isError: true });
const createInput = vi.mocked(taskStore.createTask).mock.calls[0]?.[0] as Record<string, unknown>;
expect(createInput.missionId).toBeUndefined();
expect(createInput.sliceId).toBeUndefined();
});
it("serializes three concurrent paraphrased creates from one parent", async () => { it("serializes three concurrent paraphrased creates from one parent", async () => {
const tasks: Task[] = []; const tasks: Task[] = [];
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks);

View File

@@ -244,25 +244,31 @@ describe("gating-classifications parity", () => {
recognized: true, recognized: true,
}); });
for (const [permissionPolicy, disposition] of policyMatrix) { /*
expect(resolvePermanentAgentToolDecision({ FNXC:EngineTests 2026-07-22-13:07:
toolName: "fn_task_create", Cover freeform (no lineage) and mission-linked args: both follow policy disposition.
args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, */
gating: { permissionPolicy }, for (const args of [{}, { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }]) {
})).toMatchObject({ for (const [permissionPolicy, disposition] of policyMatrix) {
category: "task_agent_mutation", expect(resolvePermanentAgentToolDecision({
disposition, toolName: "fn_task_create",
recognized: true, args,
}); gating: { permissionPolicy },
expect(evaluateAgentActionGate({ })).toMatchObject({
agentId: "a1", category: "task_agent_mutation",
toolName: "fn_task_create", disposition,
args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, recognized: true,
permissionPolicy, });
})).toMatchObject({ expect(evaluateAgentActionGate({
category: "task_agent_mutation", agentId: "a1",
disposition, toolName: "fn_task_create",
}); args,
permissionPolicy,
})).toMatchObject({
category: "task_agent_mutation",
disposition,
});
}
} }
}); });

View File

@@ -891,13 +891,17 @@ describe("wrapToolsWithPermanentAgentGating", () => {
const result = await (wrapped[0] as any).execute("t1", { description: "create" }); const result = await (wrapped[0] as any).execute("t1", { description: "create" });
expect((result as any).isError).toBe(true); expect((result as any).isError).toBe(true);
// FNXC:EngineTests 2026-07-20-23:55: governed fn_task_create without mission_lineage is hard-blocked (FN-8307) rather than approval-gated. /*
FNXC:EngineTests 2026-07-22-13:07:
Freeform chat creates omit mission_lineage and remain policy-governed (require-approval
here), not hard-blocked. Autonomous heartbeat still enforces lineage at the tool factory.
*/
expect((result as any).details).toEqual(expect.objectContaining({ expect((result as any).details).toEqual(expect.objectContaining({
category: "task_agent_mutation", category: "task_agent_mutation",
disposition: "block", disposition: "require-approval",
toolName: "fn_task_create", toolName: "fn_task_create",
})); }));
expect(createApprovalRequest).not.toHaveBeenCalled(); expect(createApprovalRequest).toHaveBeenCalledOnce();
expect(tool.execute).not.toHaveBeenCalled(); expect(tool.execute).not.toHaveBeenCalled();
}); });

View File

@@ -10,7 +10,6 @@ import {
COMMAND_EXECUTION_FN_TOOLS, COMMAND_EXECUTION_FN_TOOLS,
COORDINATION_EXEMPT_TOOLS, COORDINATION_EXEMPT_TOOLS,
FILE_SCOPE_FN_TOOLS, FILE_SCOPE_FN_TOOLS,
MISSION_LINEAGE_ADMISSION_TOOLS,
READONLY_BUILTIN_TOOLS, READONLY_BUILTIN_TOOLS,
REVIEW_GATE_BYPASS_FN_TOOLS, REVIEW_GATE_BYPASS_FN_TOOLS,
classifyGitCommand, classifyGitCommand,
@@ -97,16 +96,6 @@ const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS; const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS;
const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS;
const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS; const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS;
const MISSION_ADMISSION_TOOLS = MISSION_LINEAGE_ADMISSION_TOOLS;
function hasMissionLineageReference(args: Record<string, unknown>): boolean {
const lineage = args.mission_lineage;
if (!lineage || typeof lineage !== "object") return false;
const reference = lineage as Record<string, unknown>;
return ["mission_id", "slice_id", "feature_id"].every((key) =>
typeof reference[key] === "string" && reference[key].trim().length > 0,
);
}
function normalizeArgs(args: unknown): Record<string, unknown> { function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {}; return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -213,26 +202,21 @@ export function evaluateAgentActionGate(params: {
} }
/* /*
FNXC:MissionAdmission 2026-07-30-00:00: FNXC:MissionAdmission 2026-07-22-13:07:
FN-8307 blocks incomplete lineage before policy disposition. Approval cannot Freeform chat/user-directed creates omit mission_lineage and must remain policy-
authorize off-mission implementation work; agent-tools.ts is the authoritative governed (allow/require-approval/block), not hard-blocked at the gate. Autonomous
full-chain validator before any task row is written. heartbeat patrol still enforces lineage via createTaskCreateTool/createDelegateTaskTool
requireMissionLineage + resolveApprovedMissionLineage before any task row is written.
Supplied lineage is validated at the tool factory; the gate does not re-encode that
admission rule so chat freeform intake and heartbeat requirements can diverge safely.
*/ */
const missionAdmissionBlocked = MISSION_ADMISSION_TOOLS.has(params.toolName) && !hasMissionLineageReference(args);
if (missionAdmissionBlocked) {
category = "task_agent_mutation";
resourceType = "task";
operation = "mission-lineage-required";
}
/* /*
FNXC:ToolPermissions 2026-07-01-00:00: FNXC:ToolPermissions 2026-07-01-00:00:
Exact tool-name overrides must be resolved before category policy so operators can block a single governed tool such as `fn_task_create` without blocking every `task_agent_mutation` tool. Exempt coordination tools remain hard-bypassed to avoid heartbeat deadlocks. Exact tool-name overrides must be resolved before category policy so operators can block a single governed tool such as `fn_task_create` without blocking every `task_agent_mutation` tool. Exempt coordination tools remain hard-bypassed to avoid heartbeat deadlocks.
*/ */
const exactDisposition = category === "exempt" ? undefined : params.permissionPolicy.toolRules?.[params.toolName]; const exactDisposition = category === "exempt" ? undefined : params.permissionPolicy.toolRules?.[params.toolName];
const disposition: AgentPermissionPolicyDisposition | "allow" = missionAdmissionBlocked const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
? "block"
: category === "exempt"
? "allow" ? "allow"
: exactDisposition ?? params.permissionPolicy.rules[category]; : exactDisposition ?? params.permissionPolicy.rules[category];

View File

@@ -2528,7 +2528,17 @@ export class HeartbeatMonitor {
// Agent delegation tools // Agent delegation tools
heartbeatTools.push(createListAgentsTool(this.store)); heartbeatTools.push(createListAgentsTool(this.store));
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir, sourceAgentId: agentId })); /*
FNXC:MissionAdmission 2026-07-22-13:07:
Idle-patrol delegation has no parent task to inherit lineage from.
Keep the same requireMissionLineage contract as fn_task_create so
freeform off-mission delegation cannot slip past FN-8307 via delegate.
*/
heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, {
rootDir: this.rootDir,
sourceAgentId: agentId,
requireMissionLineage: true,
}));
heartbeatTools.push(createTaskAssignTool(this.store, taskStore)); heartbeatTools.push(createTaskAssignTool(this.store, taskStore));
heartbeatTools.push(createGetAgentConfigTool(this.store, agentId)); heartbeatTools.push(createGetAgentConfigTool(this.store, agentId));
heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId)); heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId));

View File

@@ -37,11 +37,25 @@ import { validateCodeNodeSources } from "./code-node-runner.js";
const TASK_CREATE_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const; const TASK_CREATE_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
const missionLineageParams = Type.Object({ /*
mission_id: Type.String({ description: "Approved mission ID for this implementation task" }), FNXC:MissionAdmission 2026-07-22-13:07:
slice_id: Type.String({ description: "Approved slice ID under the mission" }), Chat/user-directed freeform intake may omit mission_lineage (same as board Quick Entry).
feature_id: Type.String({ description: "Approved feature ID under the slice" }), Autonomous heartbeat surfaces pass requireMissionLineage and hard-require an approved chain.
}); When supplied, the full Feature → Slice → Milestone → Mission chain is always validated.
*/
const missionLineageParams = Type.Object(
{
mission_id: Type.String({ description: "Approved mission ID for this implementation task" }),
slice_id: Type.String({ description: "Approved slice ID under the mission" }),
feature_id: Type.String({ description: "Approved feature ID under the slice" }),
},
{
description:
"Optional approved Feature → Slice → Mission linkage. Omit for freeform intake (chat/board). " +
"Required only on autonomous heartbeat patrol creates. When omitted on a follow-up, may inherit " +
"from a mission-linked parent task. When supplied, the full active chain is validated.",
},
);
export const taskCreateParams = Type.Object({ export const taskCreateParams = Type.Object({
description: Type.String({ description: "What needs to be done" }), description: Type.String({ description: "What needs to be done" }),
@@ -395,6 +409,11 @@ export const delegateTaskParams = Type.Object({
"Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.",
}), }),
), ),
/*
FNXC:MissionAdmission 2026-07-22-13:07:
Same freeform-vs-autonomous contract as fn_task_create: optional for user-directed
delegation; required when the tool factory is registered with requireMissionLineage.
*/
mission_lineage: Type.Optional(missionLineageParams), mission_lineage: Type.Optional(missionLineageParams),
override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })),
}); });
@@ -964,22 +983,29 @@ type MissionLineageReference = {
/** /**
* FNXC:MissionAdmission 2026-07-30-00:00: * FNXC:MissionAdmission 2026-07-30-00:00:
* FN-8307 requires every autonomous implementation create/delegate operation to * FN-8307 requires autonomous implementation create/delegate (heartbeat patrol) to
* prove an active Feature → Slice → Milestone → Mission chain before persistence. * prove an active Feature → Slice → Milestone → Mission chain before persistence.
* Decision A records that proof on the new task without calling linkFeatureToTask: * Decision A records that proof on the new task without calling linkFeatureToTask:
* a feature's scalar taskId remains owned by its source task and cannot be stolen * a feature's scalar taskId remains owned by its source task and cannot be stolen
* by a follow-up task. * by a follow-up task.
*
* FNXC:MissionAdmission 2026-07-22-13:07:
* User-directed freeform intake (chat, board-equivalent agent creates) must remain
* allowed without mission_lineage. Only surfaces that pass `required: true` (idle
* heartbeat with requireMissionLineage) hard-fail on a missing lineage. When a
* lineage is supplied on any surface, the full approved chain is still validated.
* Missing lineage with inheritance disabled returns null so callers omit mission fields.
*/ */
async function resolveApprovedMissionLineage( async function resolveApprovedMissionLineage(
store: TaskStore, store: TaskStore,
requested: { mission_id: string; slice_id: string; feature_id: string } | undefined, requested: { mission_id: string; slice_id: string; feature_id: string } | undefined,
sourceTaskId: string | undefined, sourceTaskId: string | undefined,
): Promise<MissionLineageReference | { error: string }> { options?: { required?: boolean },
): Promise<MissionLineageReference | null | { error: string }> {
const missionStore = store.getMissionStore?.(); const missionStore = store.getMissionStore?.();
if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." };
let requestedLineage = requested; let requestedLineage = requested;
if (!requestedLineage && sourceTaskId) { if (!requestedLineage && sourceTaskId && missionStore) {
const sourceFeature = await missionStore.getFeatureByTaskId(sourceTaskId); const sourceFeature = await missionStore.getFeatureByTaskId(sourceTaskId);
if (sourceFeature) { if (sourceFeature) {
const sourceSlice = await missionStore.getSlice(sourceFeature.sliceId); const sourceSlice = await missionStore.getSlice(sourceFeature.sliceId);
@@ -993,7 +1019,13 @@ async function resolveApprovedMissionLineage(
} }
} }
} }
if (!requestedLineage) return { error: "Approved mission_lineage is required; no task was created." }; if (!requestedLineage) {
if (options?.required) {
return { error: "Approved mission_lineage is required; no task was created." };
}
return null;
}
if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." };
const [feature, slice, mission] = await Promise.all([ const [feature, slice, mission] = await Promise.all([
missionStore.getFeature(requestedLineage.feature_id), missionStore.getFeature(requestedLineage.feature_id),
@@ -1224,7 +1256,8 @@ export function createTaskCreateTool(
name: "fn_task_create", name: "fn_task_create",
label: "Create Task", label: "Create Task",
description: description:
"Create a new task for out-of-scope work discovered during execution. " + "Create a new task for out-of-scope work discovered during execution, or freeform " +
"intake from chat. " +
"The task enters the selected-or-default workflow's intake/planning column " + "The task enters the selected-or-default workflow's intake/planning column " +
"where it will be specified by the AI (a custom workflow with a non-triage " + "where it will be specified by the AI (a custom workflow with a non-triage " +
"intake column, e.g. Inbox, lands the card there instead and it stays inert " + "intake column, e.g. Inbox, lands the card there instead and it stays inert " +
@@ -1234,7 +1267,9 @@ export function createTaskCreateTool(
"Optionally set dependencies (e.g., the new task depends on the current one, " + "Optionally set dependencies (e.g., the new task depends on the current one, " +
"or the current task should wait for the new one). " + "or the current task should wait for the new one). " +
"Optionally pass workflow_id to select a workflow at creation time; use " + "Optionally pass workflow_id to select a workflow at creation time; use " +
"fn_workflow_list to discover valid IDs.", "fn_workflow_list to discover valid IDs. " +
"mission_lineage is optional for freeform intake; pass it only when linking to an " +
"approved Feature → Slice → Mission (required on autonomous heartbeat patrol).",
parameters: taskCreateParams, parameters: taskCreateParams,
execute: async (_id: string, params: Static<typeof taskCreateParams>) => { execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
try { try {
@@ -1266,12 +1301,19 @@ export function createTaskCreateTool(
} }
} }
const workflowId = params.workflow_id?.trim() || undefined; const workflowId = params.workflow_id?.trim() || undefined;
/*
FNXC:MissionAdmission 2026-07-22-13:07:
Freeform chat/user-directed creates omit mission_lineage and must succeed.
Only requireMissionLineage (idle heartbeat patrol) hard-requires an approved chain.
Supplied lineage is always validated; parent inheritance still applies when not required.
*/
const lineage = await resolveApprovedMissionLineage( const lineage = await resolveApprovedMissionLineage(
store, store,
params.mission_lineage, params.mission_lineage,
options?.requireMissionLineage ? undefined : options?.sourceTaskId ?? provenance?.sourceParentTaskId, options?.requireMissionLineage ? undefined : options?.sourceTaskId ?? provenance?.sourceParentTaskId,
{ required: options?.requireMissionLineage === true },
); );
if ("error" in lineage) { if (lineage && "error" in lineage) {
return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true };
} }
/* /*
@@ -1291,15 +1333,14 @@ export function createTaskCreateTool(
dependencies: params.dependencies, dependencies: params.dependencies,
priority: params.priority, priority: params.priority,
...(workflowId ? { workflowId } : {}), ...(workflowId ? { workflowId } : {}),
missionId: lineage.missionId, ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
sliceId: lineage.sliceId,
source: { source: {
sourceType: provenance?.sourceType ?? "api", sourceType: provenance?.sourceType ?? "api",
sourceAgentId: provenance?.sourceAgentId, sourceAgentId: provenance?.sourceAgentId,
sourceRunId: provenance?.sourceRunId, sourceRunId: provenance?.sourceRunId,
sourceParentTaskId: provenance?.sourceParentTaskId ?? options?.sourceTaskId, sourceParentTaskId: provenance?.sourceParentTaskId ?? options?.sourceTaskId,
// Decision A: lineage metadata is deliberately distinct from feature.taskId. // Decision A: lineage metadata is deliberately distinct from feature.taskId.
sourceMetadata: { missionLineage: lineage }, ...(lineage ? { sourceMetadata: { missionLineage: lineage } } : {}),
}, },
}, options); }, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
@@ -4519,8 +4560,18 @@ export function createDelegateTaskTool(
try { try {
const workflowId = params.workflow_id?.trim() || undefined; const workflowId = params.workflow_id?.trim() || undefined;
const lineage = await resolveApprovedMissionLineage(taskStore, params.mission_lineage, options?.sourceTaskId); /*
if ("error" in lineage) { FNXC:MissionAdmission 2026-07-22-13:07:
Freeform chat/user-directed delegation may omit mission_lineage.
requireMissionLineage (idle heartbeat patrol) still hard-requires an approved chain.
*/
const lineage = await resolveApprovedMissionLineage(
taskStore,
params.mission_lineage,
options?.requireMissionLineage ? undefined : options?.sourceTaskId,
{ required: options?.requireMissionLineage === true },
);
if (lineage && "error" in lineage) {
return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true };
} }
// Create task assigned to the target agent // Create task assigned to the target agent
@@ -4530,14 +4581,13 @@ export function createDelegateTaskTool(
column: "todo", column: "todo",
assignedAgentId: params.agent_id, assignedAgentId: params.agent_id,
...(workflowId ? { workflowId } : {}), ...(workflowId ? { workflowId } : {}),
missionId: lineage.missionId, ...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
sliceId: lineage.sliceId,
source: { source: {
sourceType: "api", sourceType: "api",
sourceParentTaskId: options?.sourceTaskId, sourceParentTaskId: options?.sourceTaskId,
sourceAgentId: options?.sourceAgentId, sourceAgentId: options?.sourceAgentId,
sourceMetadata: { sourceMetadata: {
missionLineage: lineage, ...(lineage ? { missionLineage: lineage } : {}),
...(override ? { executorRoleOverride: true } : {}), ...(override ? { executorRoleOverride: true } : {}),
}, },
}, },

View File

@@ -50,11 +50,13 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = new Set([
/* /*
FNXC:MissionAdmission 2026-07-30-00:00: FNXC:MissionAdmission 2026-07-30-00:00:
FN-8307 treats autonomous implementation creation and delegation as one admission FN-8307 treats autonomous implementation creation and delegation as one admission
class in both gate paths. They must never fall through as permanent-agent class at the tool factory (requireMissionLineage + resolveApprovedMissionLineage).
coordination, because agent-tools.ts validates the referenced active lineage
before it can persist the task. FNXC:MissionAdmission 2026-07-22-13:07:
Gates no longer hard-block missing lineage so freeform chat/user-directed creates
remain policy-governed. Lineage enforcement for idle heartbeat patrol lives in
agent-tools.ts (requireMissionLineage), not a gate pre-check.
*/ */
export const MISSION_LINEAGE_ADMISSION_TOOLS: ReadonlySet<string> = new Set(["fn_task_create", "fn_delegate_task"]);
const PERMANENT_AND_ACTION_TASK_AGENT_TOOLS = ["fn_task_create", "fn_delegate_task"] as const; const PERMANENT_AND_ACTION_TASK_AGENT_TOOLS = ["fn_task_create", "fn_delegate_task"] as const;
const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [
...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS, ...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS,

View File

@@ -9,7 +9,6 @@ import {
FILE_SCOPE_FN_TOOLS, FILE_SCOPE_FN_TOOLS,
FILE_WRITE_BUILTIN_TOOLS, FILE_WRITE_BUILTIN_TOOLS,
FILE_WRITE_DELETE_FN_TOOLS, FILE_WRITE_DELETE_FN_TOOLS,
MISSION_LINEAGE_ADMISSION_TOOLS,
NETWORK_API_TOOLS, NETWORK_API_TOOLS,
PERMANENT_AGENT_TASK_MUTATION_TOOLS, PERMANENT_AGENT_TASK_MUTATION_TOOLS,
READONLY_BUILTIN_TOOLS, READONLY_BUILTIN_TOOLS,
@@ -40,17 +39,6 @@ const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS;
// FNXC:ToolGovernance 2026-07-09-08:30: FN-7737 — mirror agent-action-gate.ts's file_scope classification here so the permanent-agent gate resolves fn_task_file_scope_add identically (no two-path drift). // FNXC:ToolGovernance 2026-07-09-08:30: FN-7737 — mirror agent-action-gate.ts's file_scope classification here so the permanent-agent gate resolves fn_task_file_scope_add identically (no two-path drift).
const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS; const FILE_SCOPE_TOOLS = FILE_SCOPE_FN_TOOLS;
const MISSION_ADMISSION_TOOLS = MISSION_LINEAGE_ADMISSION_TOOLS;
function hasMissionLineageReference(args: unknown): boolean {
if (!args || typeof args !== "object") return false;
const lineage = (args as Record<string, unknown>).mission_lineage;
if (!lineage || typeof lineage !== "object") return false;
const reference = lineage as Record<string, unknown>;
return ["mission_id", "slice_id", "feature_id"].every((key) =>
typeof reference[key] === "string" && reference[key].trim().length > 0,
);
}
function normalizeArgs(args: unknown): Record<string, unknown> { function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {}; return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -176,14 +164,12 @@ export function resolvePermanentAgentToolDecision(input: {
const classification = classifyPermanentAgentToolCall(input.toolName, input.args); const classification = classifyPermanentAgentToolCall(input.toolName, input.args);
/* /*
FNXC:MissionAdmission 2026-07-30-00:00: FNXC:MissionAdmission 2026-07-22-13:07:
Keep the permanent-agent result in lockstep with evaluateAgentActionGate: Freeform chat creates omit mission_lineage and must honor policy disposition
incomplete lineage is a hard off-mission block, not a policy-approvable task (allow/require-approval/block), not a hard gate block. Autonomous heartbeat
mutation. The tool factory performs the full persistence-time validation. patrol still enforces lineage at the tool factory via requireMissionLineage.
Keep permanent-agent results in lockstep with evaluateAgentActionGate.
*/ */
if (MISSION_ADMISSION_TOOLS.has(input.toolName) && !hasMissionLineageReference(input.args)) {
return { ...classification, toolName: input.toolName, disposition: "block" };
}
if (!input.gating?.permissionPolicy) { if (!input.gating?.permissionPolicy) {
return { return {