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:
7
.changeset/fix-chat-freeform-task-create.md
Normal file
7
.changeset/fix-chat-freeform-task-create.md
Normal 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.
|
||||
@@ -536,10 +536,16 @@ describe("agent-action-gate", () => {
|
||||
"fn_task_import_gitlab_group_issues",
|
||||
"fn_task_import_gitlab_merge_requests",
|
||||
] 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" } }
|
||||
: {};
|
||||
for (const argsValue of [args, args]) {
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-22-13:07:
|
||||
Freeform creates omit mission_lineage and still follow policy disposition
|
||||
(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({
|
||||
category: "task_agent_mutation",
|
||||
disposition: "require-approval",
|
||||
|
||||
@@ -492,6 +492,67 @@ describe("createDelegateTaskTool", () => {
|
||||
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 () => {
|
||||
const tasks: Task[] = [];
|
||||
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks);
|
||||
|
||||
@@ -244,25 +244,31 @@ describe("gating-classifications parity", () => {
|
||||
recognized: true,
|
||||
});
|
||||
|
||||
for (const [permissionPolicy, disposition] of policyMatrix) {
|
||||
expect(resolvePermanentAgentToolDecision({
|
||||
toolName: "fn_task_create",
|
||||
args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } },
|
||||
gating: { permissionPolicy },
|
||||
})).toMatchObject({
|
||||
category: "task_agent_mutation",
|
||||
disposition,
|
||||
recognized: true,
|
||||
});
|
||||
expect(evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "fn_task_create",
|
||||
args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } },
|
||||
permissionPolicy,
|
||||
})).toMatchObject({
|
||||
category: "task_agent_mutation",
|
||||
disposition,
|
||||
});
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-22-13:07:
|
||||
Cover freeform (no lineage) and mission-linked args: both follow policy disposition.
|
||||
*/
|
||||
for (const args of [{}, { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }]) {
|
||||
for (const [permissionPolicy, disposition] of policyMatrix) {
|
||||
expect(resolvePermanentAgentToolDecision({
|
||||
toolName: "fn_task_create",
|
||||
args,
|
||||
gating: { permissionPolicy },
|
||||
})).toMatchObject({
|
||||
category: "task_agent_mutation",
|
||||
disposition,
|
||||
recognized: true,
|
||||
});
|
||||
expect(evaluateAgentActionGate({
|
||||
agentId: "a1",
|
||||
toolName: "fn_task_create",
|
||||
args,
|
||||
permissionPolicy,
|
||||
})).toMatchObject({
|
||||
category: "task_agent_mutation",
|
||||
disposition,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -891,13 +891,17 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
||||
|
||||
const result = await (wrapped[0] as any).execute("t1", { description: "create" });
|
||||
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({
|
||||
category: "task_agent_mutation",
|
||||
disposition: "block",
|
||||
disposition: "require-approval",
|
||||
toolName: "fn_task_create",
|
||||
}));
|
||||
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||
expect(createApprovalRequest).toHaveBeenCalledOnce();
|
||||
expect(tool.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
COMMAND_EXECUTION_FN_TOOLS,
|
||||
COORDINATION_EXEMPT_TOOLS,
|
||||
FILE_SCOPE_FN_TOOLS,
|
||||
MISSION_LINEAGE_ADMISSION_TOOLS,
|
||||
READONLY_BUILTIN_TOOLS,
|
||||
REVIEW_GATE_BYPASS_FN_TOOLS,
|
||||
classifyGitCommand,
|
||||
@@ -97,16 +96,6 @@ const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
|
||||
const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS;
|
||||
const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_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> {
|
||||
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:
|
||||
FN-8307 blocks incomplete lineage before policy disposition. Approval cannot
|
||||
authorize off-mission implementation work; agent-tools.ts is the authoritative
|
||||
full-chain validator before any task row is written.
|
||||
FNXC:MissionAdmission 2026-07-22-13:07:
|
||||
Freeform chat/user-directed creates omit mission_lineage and must remain policy-
|
||||
governed (allow/require-approval/block), not hard-blocked at the gate. Autonomous
|
||||
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:
|
||||
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 disposition: AgentPermissionPolicyDisposition | "allow" = missionAdmissionBlocked
|
||||
? "block"
|
||||
: category === "exempt"
|
||||
const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
|
||||
? "allow"
|
||||
: exactDisposition ?? params.permissionPolicy.rules[category];
|
||||
|
||||
|
||||
@@ -2528,7 +2528,17 @@ export class HeartbeatMonitor {
|
||||
|
||||
// Agent delegation tools
|
||||
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(createGetAgentConfigTool(this.store, agentId));
|
||||
heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId));
|
||||
|
||||
@@ -37,11 +37,25 @@ import { validateCodeNodeSources } from "./code-node-runner.js";
|
||||
|
||||
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" }),
|
||||
slice_id: Type.String({ description: "Approved slice ID under the mission" }),
|
||||
feature_id: Type.String({ description: "Approved feature ID under the slice" }),
|
||||
});
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-22-13:07:
|
||||
Chat/user-directed freeform intake may omit mission_lineage (same as board Quick Entry).
|
||||
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({
|
||||
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.",
|
||||
}),
|
||||
),
|
||||
/*
|
||||
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),
|
||||
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:
|
||||
* 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.
|
||||
* 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
|
||||
* 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(
|
||||
store: TaskStore,
|
||||
requested: { mission_id: string; slice_id: string; feature_id: string } | undefined,
|
||||
sourceTaskId: string | undefined,
|
||||
): Promise<MissionLineageReference | { error: string }> {
|
||||
options?: { required?: boolean },
|
||||
): Promise<MissionLineageReference | null | { error: string }> {
|
||||
const missionStore = store.getMissionStore?.();
|
||||
if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." };
|
||||
|
||||
let requestedLineage = requested;
|
||||
if (!requestedLineage && sourceTaskId) {
|
||||
if (!requestedLineage && sourceTaskId && missionStore) {
|
||||
const sourceFeature = await missionStore.getFeatureByTaskId(sourceTaskId);
|
||||
if (sourceFeature) {
|
||||
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([
|
||||
missionStore.getFeature(requestedLineage.feature_id),
|
||||
@@ -1224,7 +1256,8 @@ export function createTaskCreateTool(
|
||||
name: "fn_task_create",
|
||||
label: "Create Task",
|
||||
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 " +
|
||||
"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 " +
|
||||
@@ -1234,7 +1267,9 @@ export function createTaskCreateTool(
|
||||
"Optionally set dependencies (e.g., the new task depends on the current one, " +
|
||||
"or the current task should wait for the new one). " +
|
||||
"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,
|
||||
execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
|
||||
try {
|
||||
@@ -1266,12 +1301,19 @@ export function createTaskCreateTool(
|
||||
}
|
||||
}
|
||||
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(
|
||||
store,
|
||||
params.mission_lineage,
|
||||
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 };
|
||||
}
|
||||
/*
|
||||
@@ -1291,15 +1333,14 @@ export function createTaskCreateTool(
|
||||
dependencies: params.dependencies,
|
||||
priority: params.priority,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
missionId: lineage.missionId,
|
||||
sliceId: lineage.sliceId,
|
||||
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
|
||||
source: {
|
||||
sourceType: provenance?.sourceType ?? "api",
|
||||
sourceAgentId: provenance?.sourceAgentId,
|
||||
sourceRunId: provenance?.sourceRunId,
|
||||
sourceParentTaskId: provenance?.sourceParentTaskId ?? options?.sourceTaskId,
|
||||
// Decision A: lineage metadata is deliberately distinct from feature.taskId.
|
||||
sourceMetadata: { missionLineage: lineage },
|
||||
...(lineage ? { sourceMetadata: { missionLineage: lineage } } : {}),
|
||||
},
|
||||
}, options);
|
||||
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
|
||||
@@ -4519,8 +4560,18 @@ export function createDelegateTaskTool(
|
||||
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
// Create task assigned to the target agent
|
||||
@@ -4530,14 +4581,13 @@ export function createDelegateTaskTool(
|
||||
column: "todo",
|
||||
assignedAgentId: params.agent_id,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
missionId: lineage.missionId,
|
||||
sliceId: lineage.sliceId,
|
||||
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
|
||||
source: {
|
||||
sourceType: "api",
|
||||
sourceParentTaskId: options?.sourceTaskId,
|
||||
sourceAgentId: options?.sourceAgentId,
|
||||
sourceMetadata: {
|
||||
missionLineage: lineage,
|
||||
...(lineage ? { missionLineage: lineage } : {}),
|
||||
...(override ? { executorRoleOverride: true } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,11 +50,13 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = new Set([
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-30-00:00:
|
||||
FN-8307 treats autonomous implementation creation and delegation as one admission
|
||||
class in both gate paths. They must never fall through as permanent-agent
|
||||
coordination, because agent-tools.ts validates the referenced active lineage
|
||||
before it can persist the task.
|
||||
class at the tool factory (requireMissionLineage + resolveApprovedMissionLineage).
|
||||
|
||||
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 ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [
|
||||
...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
FILE_SCOPE_FN_TOOLS,
|
||||
FILE_WRITE_BUILTIN_TOOLS,
|
||||
FILE_WRITE_DELETE_FN_TOOLS,
|
||||
MISSION_LINEAGE_ADMISSION_TOOLS,
|
||||
NETWORK_API_TOOLS,
|
||||
PERMANENT_AGENT_TASK_MUTATION_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;
|
||||
// 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 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> {
|
||||
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);
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-30-00:00:
|
||||
Keep the permanent-agent result in lockstep with evaluateAgentActionGate:
|
||||
incomplete lineage is a hard off-mission block, not a policy-approvable task
|
||||
mutation. The tool factory performs the full persistence-time validation.
|
||||
FNXC:MissionAdmission 2026-07-22-13:07:
|
||||
Freeform chat creates omit mission_lineage and must honor policy disposition
|
||||
(allow/require-approval/block), not a hard gate block. Autonomous heartbeat
|
||||
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) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user