From 1f779353585571e1b5f291cb6efbce4a8d1e75ff Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 22:43:13 -0700 Subject: [PATCH] FN-8307: guard heartbeat task creation with mission lineage Require autonomous heartbeat work to prove and retain approved mission lineage. - Validate and persist mission lineage for created and delegated tasks - Block off-mission heartbeat actions and reconcile roadmap failures without completion - Renew and release mission symbol locks across workflow lifecycle transitions Files changed: .changeset/fn-8307-heartbeat-mission-guard.md | 7 ++ docs/missions.md | 4 + packages/core/src/__tests__/symbol-locks.test.ts | 12 +++ packages/core/src/task-store/task-update.ts | 17 +++- .../engine/src/__tests__/agent-action-gate.test.ts | 9 +- .../src/__tests__/agent-tools-delegation.test.ts | 41 +++++++- .../src/__tests__/gating-classifications.test.ts | 7 +- .../src/__tests__/mission-feature-sync.test.ts | 10 +- .../src/__tests__/mission-symbol-admission.test.ts | 25 +++++ .../src/__tests__/permanent-agent-gating.test.ts | 3 +- packages/engine/src/agent-action-gate.ts | 28 +++++- packages/engine/src/agent-heartbeat-prompts.ts | 14 +-- packages/engine/src/agent-heartbeat.ts | 34 +++++-- packages/engine/src/agent-tools.ts | 103 +++++++++++++++++++-- packages/engine/src/executor.ts | 2 +- packages/engine/src/gating-classifications.ts | 11 ++- packages/engine/src/mission-feature-sync.ts | 39 +++----- packages/engine/src/mission-symbol-admission.ts | 35 ++++++- packages/engine/src/permanent-agent-gating.ts | 22 +++++ packages/engine/src/scheduler.ts | 21 +++-- packages/engine/src/step-session-executor.ts | 2 +- packages/engine/src/triage.ts | 2 +- 22 files changed, 369 insertions(+), 79 deletions(-) Fusion-Task-Id: FN-8307 Fusion-Task-Lineage: 4ca2ccac-db6e-4e5c-9cc6-d62a40d4f30a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8307-heartbeat-mission-guard.md | 7 ++ docs/missions.md | 4 + .../core/src/__tests__/symbol-locks.test.ts | 12 ++ packages/core/src/task-store/task-update.ts | 17 ++- .../src/__tests__/agent-action-gate.test.ts | 9 +- .../__tests__/agent-tools-delegation.test.ts | 41 ++++++- .../__tests__/gating-classifications.test.ts | 7 +- .../__tests__/mission-feature-sync.test.ts | 10 +- .../mission-symbol-admission.test.ts | 25 +++++ .../__tests__/permanent-agent-gating.test.ts | 3 +- packages/engine/src/agent-action-gate.ts | 28 ++++- .../engine/src/agent-heartbeat-prompts.ts | 14 +-- packages/engine/src/agent-heartbeat.ts | 34 ++++-- packages/engine/src/agent-tools.ts | 103 ++++++++++++++++-- packages/engine/src/executor.ts | 2 +- packages/engine/src/gating-classifications.ts | 11 +- packages/engine/src/mission-feature-sync.ts | 39 +++---- .../engine/src/mission-symbol-admission.ts | 35 +++++- packages/engine/src/permanent-agent-gating.ts | 22 ++++ packages/engine/src/scheduler.ts | 21 ++-- packages/engine/src/step-session-executor.ts | 2 +- packages/engine/src/triage.ts | 2 +- 22 files changed, 369 insertions(+), 79 deletions(-) create mode 100644 .changeset/fn-8307-heartbeat-mission-guard.md diff --git a/.changeset/fn-8307-heartbeat-mission-guard.md b/.changeset/fn-8307-heartbeat-mission-guard.md new file mode 100644 index 0000000000..dbc8d6f8e6 --- /dev/null +++ b/.changeset/fn-8307-heartbeat-mission-guard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Require approved mission lineage for autonomous task creation and delegation. +category: feature +dev: Heartbeat creation and delegation now preserve source feature links while reconciling non-completion outcomes safely. diff --git a/docs/missions.md b/docs/missions.md index bdc5b67f20..4285c7cdeb 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -697,3 +697,7 @@ For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" } ## Research-derived features A completed cited research finding may become a normal Mission Feature. Its feature retains research run, stable finding, and source-URL provenance; optional triage uses the normal feature task flow. Linked task changes reconcile through the existing feature → slice → milestone → mission rollups, and task completion remains subject to assertion validation. + +### Autonomous mission admission + +Heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link. Missing or invalid lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops. diff --git a/packages/core/src/__tests__/symbol-locks.test.ts b/packages/core/src/__tests__/symbol-locks.test.ts index 78b4015ed4..7bad5853ff 100644 --- a/packages/core/src/__tests__/symbol-locks.test.ts +++ b/packages/core/src/__tests__/symbol-locks.test.ts @@ -76,6 +76,18 @@ pgDescribe("TaskStore durable symbol locks", () => { expect((await store.releaseSymbolLocks(["pkg/a.ts#A"], "FN-owner")).released).toEqual([]); }); + it("releases an active task's symbols when it is failed in place", async () => { + const store = h.store(); + const owner = await store.createTask({ description: "failed symbol lock owner", declaredSymbols: ["pkg/failed.ts#A"] }); + await store.moveTask(owner.id, "todo"); + await store.moveTask(owner.id, "in-progress"); + await store.acquireSymbolLocks(["pkg/failed.ts#A"], { ownerTaskId: owner.id }, 60_000); + + await store.updateTask(owner.id, { status: "failed", error: "terminal execution failure" }); + + expect(await store.inspectSymbolLockConflicts(["pkg/failed.ts#A"])).toEqual([]); + }); + it("allows expired locks to be acquired and reconciles terminal owners", async () => { const store = h.store(); const layer = h.layer(); const projectId = layer.projectId?.trim() || "__legacy_unscoped__"; await store.acquireSymbolLocks(["pkg/expired.ts#A"], { ownerTaskId: "FN-dead" }, 60_000); diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 93b871a649..9da3b51b7a 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -22,7 +22,7 @@ import {validateFileScopeInPromptContent} from "../task-store/file-scope.js"; import {__setTaskActivityLogLimitsForTesting, isBootstrapPromptStub, rewriteHeadingLine, rewriteMissionSection} from "../task-store/comments.js"; import {applyOriginalDescription} from "../original-description-policy.js"; import {normalizeTaskReviewState} from "../task-store/review-state.js"; -import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt} from "../task-symbol-resolution.js"; +import {hasOwnDeclaredSymbols, normalizeDeclaredSymbols, extractDeclaredSymbolsFromPrompt, resolveTaskSymbolsForTask} from "../task-symbol-resolution.js"; export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updates: Parameters[1], runContext?: RunMutationContext,): Promise { { @@ -37,6 +37,7 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat const dir = store.taskDir(id); const task = await store.readTaskJson(dir); + const wasFailed = task.status === "failed"; // Capture title/description before mutation so the PROMPT.md stub // detector below can compare against the exact wrapper bytes that the @@ -759,6 +760,20 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat await store.atomicWriteTaskJson(dir, task); } + /* + FNXC:MissionSymbolAdmission 2026-08-01-00:00: + A workflow failure may park in the current in-progress column rather than + move out of it. Release that task's durable symbols on the status edge as + well as moveTask's column-exit path, allowing engine reconciliation to + record failure provenance without incorrectly completing the feature. + */ + if (store.backendMode && !wasFailed && task.status === "failed") { + const symbols = resolveTaskSymbolsForTask(task); + if (symbols.resolvable) { + await store.releaseSymbolLocks(symbols.symbols, id); + } + } + // Update cache if watcher is active if (store.isWatching) store.taskCache.set(id, { ...task }); diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 5e13bca762..d2e7022c30 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -535,12 +535,15 @@ 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) => { - for (const args of [{}, undefined]) { - expect(evaluateAgentActionGate({ agentId: "a1", toolName, args, permissionPolicy: approvalPolicy })).toMatchObject({ + 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]) { + expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: argsValue, permissionPolicy: approvalPolicy })).toMatchObject({ category: "task_agent_mutation", disposition: "require-approval", }); - expect(evaluateAgentActionGate({ agentId: "a1", toolName, args, permissionPolicy: lockedDownPolicy })).toMatchObject({ + expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: argsValue, permissionPolicy: lockedDownPolicy })).toMatchObject({ category: "task_agent_mutation", disposition: "block", }); diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 682d4f2792..b5fc3f71a3 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -11,8 +11,18 @@ function createMockAgentStore(overrides: Partial = {}): AgentStore { } as unknown as AgentStore; } +const APPROVED_LINEAGE = { mission_id: "M-001", slice_id: "SL-001", feature_id: "F-001" }; + function createMockTaskStore(overrides: Partial = {}): TaskStore { + const missionStore = { + getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }), + getFeatureByTaskId: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }), + getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }), + getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }), + getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }), + }; return { + getMissionStore: vi.fn().mockReturnValue(missionStore), getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }), getRootDir: vi.fn().mockReturnValue("/project"), searchTasks: vi.fn().mockResolvedValue([]), @@ -216,6 +226,7 @@ describe("createDelegateTaskTool", () => { vi.mocked(taskStore.createTask).mockResolvedValue({ id: "FN-050", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, dependencies: [], column: "todo" as const, steps: [], @@ -229,6 +240,7 @@ describe("createDelegateTaskTool", () => { const result = await tool.execute("session-1", { agent_id: "agent-001", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ @@ -250,6 +262,7 @@ describe("createDelegateTaskTool", () => { const existing = { id: "FN-duplicate", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, dependencies: [], column: "triage" as const, assignedAgentId: "agent-001", @@ -269,6 +282,7 @@ describe("createDelegateTaskTool", () => { const result = await createDelegateTaskTool(agentStore, taskStore).execute("session-1", { agent_id: "agent-002", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect(taskStore.updateTask).toHaveBeenCalledWith("FN-duplicate", { assignedAgentId: "agent-002" }); @@ -282,6 +296,7 @@ describe("createDelegateTaskTool", () => { const existing = { id: "FN-duplicate", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, dependencies: [], column: "todo" as const, assignedAgentId: "agent-001", @@ -295,6 +310,7 @@ describe("createDelegateTaskTool", () => { const result = await createAgentTask(taskStore, { description: "Write tests", + mission_lineage: APPROVED_LINEAGE, column: "todo", assignedAgentId: "agent-001", }); @@ -458,12 +474,24 @@ describe("createDelegateTaskTool", () => { it("persists option-based parent provenance on the step-session fn_task_create surface", async () => { const tool = createTaskCreateTool(taskStore, undefined, { sourceTaskId: "FN-PARENT", sourceAgentId: "agent-worker" }); - await tool.execute("call-1", { description: "Capture optional report screenshots" }, undefined as any, undefined as any, undefined as any); + await tool.execute("call-1", { description: "Capture optional report screenshots", mission_lineage: APPROVED_LINEAGE }, undefined as any, undefined as any, undefined as any); expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ sourceType: "api", sourceAgentId: "agent-worker", sourceParentTaskId: "FN-PARENT" }), }), expect.anything()); }); + it("requires an explicit lineage when a no-task heartbeat cannot inherit one", async () => { + const tool = createTaskCreateTool(taskStore, undefined, { + sourceTaskId: "FN-PARENT", + requireMissionLineage: true, + }); + + const result = await tool.execute("call-1", { description: "Capture optional report screenshots" }, undefined as any, undefined as any, undefined as any); + + expect(result).toMatchObject({ isError: true, details: { rule: "mission-lineage-required" } }); + expect(taskStore.createTask).not.toHaveBeenCalled(); + }); + it("serializes three concurrent paraphrased creates from one parent", async () => { const tasks: Task[] = []; vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks); @@ -517,6 +545,7 @@ describe("createDelegateTaskTool", () => { const created = { id: "FN-new", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, dependencies: [], column: "todo" as const, steps: [], currentStep: 0, log: [], @@ -538,6 +567,7 @@ describe("createDelegateTaskTool", () => { const result = await createAgentTask(taskStore, { description: "Write tests", + mission_lineage: APPROVED_LINEAGE, column: "todo", assignedAgentId: "agent-002", }); @@ -554,6 +584,7 @@ describe("createDelegateTaskTool", () => { vi.mocked(taskStore.createTask).mockResolvedValue({ id: "FN-051", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, dependencies: [], column: "todo" as const, steps: [], @@ -567,6 +598,7 @@ describe("createDelegateTaskTool", () => { const result = await tool.execute("session-1", { agent_id: "agent-001", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); const text = (result.content[0] as { text: string }).text; @@ -616,6 +648,7 @@ describe("createDelegateTaskTool", () => { const result = await tool.execute("session-1", { agent_id: "agent-001", description: "Write tests", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect((result as { isError?: boolean }).isError).toBe(true); @@ -642,6 +675,7 @@ describe("createDelegateTaskTool", () => { await tool.execute("session-1", { agent_id: "agent-009", description: "Do something", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ @@ -685,6 +719,7 @@ describe("createDelegateTaskTool", () => { await tool.execute("session-1", { agent_id: "agent-002", description: "Do something", + mission_lineage: APPROVED_LINEAGE, override: true, }, undefined as any, undefined as any, undefined as any); @@ -736,6 +771,7 @@ describe("createDelegateTaskTool", () => { await tool.execute("session-1", { agent_id: "agent-explicit", description: "Do something", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect(taskStore.createTask).toHaveBeenCalledWith( @@ -750,6 +786,7 @@ describe("createDelegateTaskTool", () => { vi.mocked(taskStore.createTask).mockResolvedValue({ id: "FN-052", description: "Integration test", + mission_lineage: APPROVED_LINEAGE, dependencies: ["FN-010"], column: "todo" as const, steps: [], @@ -763,6 +800,7 @@ describe("createDelegateTaskTool", () => { const result = await tool.execute("session-1", { agent_id: "agent-001", description: "Integration test", + mission_lineage: APPROVED_LINEAGE, dependencies: ["FN-010"], }, undefined as any, undefined as any, undefined as any); @@ -797,6 +835,7 @@ describe("createDelegateTaskTool", () => { const result = await tool.execute("session-1", { agent_id: "agent-001", description: "Simple task", + mission_lineage: APPROVED_LINEAGE, }, undefined as any, undefined as any, undefined as any); expect(taskStore.createTask).toHaveBeenCalledWith( diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 3bf81e4a95..454aff99fa 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -103,7 +103,6 @@ const gitCases = [ ] as const; const ACTION_MUTATION_PERMANENT_READONLY_TOOLS = new Set([ - "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", "fn_task_import_gitlab_project_issues", @@ -118,7 +117,6 @@ const policyMatrix = [ ] as const; const permanentReadonlySiblingTaskCreationTools = [ - "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", "fn_task_import_gitlab_project_issues", @@ -162,6 +160,7 @@ describe("gating-classifications parity", () => { "fn_task_logs_read", "fn_task_search", "fn_task_show", + "fn_task_verification_status", "fn_trait_list", "fn_update_identity", "fn_workflow_get", @@ -231,7 +230,7 @@ describe("gating-classifications parity", () => { for (const [permissionPolicy, disposition] of policyMatrix) { expect(resolvePermanentAgentToolDecision({ toolName: "fn_task_create", - args: {}, + args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, gating: { permissionPolicy }, })).toMatchObject({ category: "task_agent_mutation", @@ -241,7 +240,7 @@ describe("gating-classifications parity", () => { expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", - args: {}, + args: { mission_lineage: { mission_id: "M-1", slice_id: "SL-1", feature_id: "F-1" } }, permissionPolicy, })).toMatchObject({ category: "task_agent_mutation", diff --git a/packages/engine/src/__tests__/mission-feature-sync.test.ts b/packages/engine/src/__tests__/mission-feature-sync.test.ts index ac0e9b2dcf..af47a74c21 100644 --- a/packages/engine/src/__tests__/mission-feature-sync.test.ts +++ b/packages/engine/src/__tests__/mission-feature-sync.test.ts @@ -12,9 +12,17 @@ describe("reconcileMissionFeatureState", () => { expect(decision).toEqual(expect.objectContaining({ kind: "noop" })); }); - it("moves a linked feature through canonical triage and in-progress states", async () => { + it("reconciles return and active board states without fabricating completion", async () => { const taskStore = { getTask: async () => undefined } as never; await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "todo", status: "pending" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toMatchObject({ kind: "update", status: "triaged" }); + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "triage" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toMatchObject({ kind: "update", status: "triaged" }); await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "in-review", status: "in-progress" } as never, { id: "F-1", status: "triaged" } as never)).resolves.toMatchObject({ kind: "update", status: "in-progress" }); + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "in-progress" } as never, { id: "F-1", status: "defined" } as never)).resolves.toMatchObject({ kind: "update", status: "in-progress" }); + }); + + it("keeps archived and failed task outcomes as idempotent non-completion", async () => { + const taskStore = { getTask: async () => undefined } as never; + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "archived" } as never, { id: "F-1", status: "in-progress" } as never)).resolves.toEqual({ kind: "noop" }); + await expect(reconcileMissionFeatureState(taskStore, { id: "FN-1", column: "todo", status: "failed", error: "BLOCKED" } as never, { id: "F-1", status: "triaged" } as never)).resolves.toMatchObject({ kind: "failure" }); }); }); diff --git a/packages/engine/src/__tests__/mission-symbol-admission.test.ts b/packages/engine/src/__tests__/mission-symbol-admission.test.ts index 5994ab0fda..f64d1d5300 100644 --- a/packages/engine/src/__tests__/mission-symbol-admission.test.ts +++ b/packages/engine/src/__tests__/mission-symbol-admission.test.ts @@ -15,6 +15,7 @@ function store(overrides: Partial<{ mission: Mission | undefined; milestone: Mil const values = { mission, milestone, slice, feature, ...overrides }; return { getFeatureByTaskId: async () => values.feature, + getFeature: async (id: string) => id === feature.id ? values.feature : undefined, getSlice: async () => values.slice, getMilestone: async () => values.milestone, getMission: async () => values.mission, @@ -28,6 +29,30 @@ describe("decideMissionSymbolAdmission", () => { }); }); + it("resolves Decision-A follow-up metadata without replacing source feature ownership", async () => { + const followUp = task({ + id: "FN-2", + declaredSymbols: ["pkg/a.ts#A"], + sourceMetadata: { missionLineage: { missionId: mission.id, sliceId: slice.id, featureId: feature.id } }, + }); + + await expect(decideMissionSymbolAdmission(followUp, store())).resolves.toMatchObject({ + kind: "symbol-lock", feature: { id: feature.id, taskId: "FN-1" }, + }); + }); + + it("blocks malformed Decision-A metadata rather than falling back to another feature", async () => { + const followUp = task({ + id: "FN-2", + declaredSymbols: ["pkg/a.ts#A"], + sourceMetadata: { missionLineage: { missionId: mission.id, sliceId: slice.id, featureId: "F-missing" } }, + }); + + await expect(decideMissionSymbolAdmission(followUp, store())).resolves.toEqual({ + kind: "lineage-blocked", reason: "missing-feature", + }); + }); + it("uses coarse fallback for non-mission and approved empty-symbol work", async () => { await expect(decideMissionSymbolAdmission(task({ missionId: undefined, sliceId: undefined }), store({ feature: undefined }))).resolves.toEqual({ kind: "coarse-fallback", reason: "non-mission" }); await expect(decideMissionSymbolAdmission(task({ declaredSymbols: [] }), store())).resolves.toEqual({ kind: "coarse-fallback", reason: "symbols-unresolvable" }); diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 38265cb3e5..433cc9238d 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -68,7 +68,6 @@ const FN_3548_COORDINATION_TOOLS = [ "fn_artifact_register", "fn_artifact_list", "fn_artifact_view", - "fn_delegate_task", "fn_list_agents", "fn_agent_show", "fn_agent_org_chart", @@ -96,7 +95,7 @@ describe("permanent-agent-gating", () => { it("classifies shared fn tools by behavior", () => { expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("task_agent_mutation"); - expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("none"); + expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("task_agent_mutation"); expect(classifyPermanentAgentToolCall("fn_update_agent_config").category).toBe("task_agent_mutation"); expect(classifyPermanentAgentToolCall("fn_task_import_github").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_task_import_github_issue").category).toBe("none"); diff --git a/packages/engine/src/agent-action-gate.ts b/packages/engine/src/agent-action-gate.ts index 774361fe58..05fba9cd88 100644 --- a/packages/engine/src/agent-action-gate.ts +++ b/packages/engine/src/agent-action-gate.ts @@ -10,6 +10,7 @@ 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, @@ -96,6 +97,16 @@ 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): boolean { + const lineage = args.mission_lineage; + if (!lineage || typeof lineage !== "object") return false; + const reference = lineage as Record; + return ["mission_id", "slice_id", "feature_id"].every((key) => + typeof reference[key] === "string" && reference[key].trim().length > 0, + ); +} function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -201,12 +212,27 @@ export function evaluateAgentActionGate(params: { resourceType = params.toolName.startsWith("mcp__") ? "mcp" : "research"; } + /* + 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. + */ + 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" = category === "exempt" + const disposition: AgentPermissionPolicyDisposition | "allow" = missionAdmissionBlocked + ? "block" + : category === "exempt" ? "allow" : exactDisposition ?? params.permissionPolicy.rules[category]; diff --git a/packages/engine/src/agent-heartbeat-prompts.ts b/packages/engine/src/agent-heartbeat-prompts.ts index f5ebb72083..343cb16155 100644 --- a/packages/engine/src/agent-heartbeat-prompts.ts +++ b/packages/engine/src/agent-heartbeat-prompts.ts @@ -109,8 +109,8 @@ When you are woken by an incoming message (source includes "wake-on-message"), y - If the message requires a response, use fn_send_message to reply. - When replying, include 'reply_to_message_id' with the original message ID from fn_read_messages output. - If the message is informational, acknowledge it by logging with fn_task_log. - - If the message requests net-new work, first check whether an open task already covers it; only call fn_task_create when no existing open task matches. - - If ownership is clear and an agent is available, delegate using fn_delegate_task. + - If the message requests net-new work, first check whether an open task already covers it; idle/no-task heartbeats may create only with approved Feature → Slice → Milestone → Mission lineage. + - If ownership is clear and an agent is available, delegate only approved mission-linked work using fn_delegate_task. 4. If a Pending Room Messages section is present, review it too: - Use fn_post_room_message only when the room content is relevant to your role, soul, or identity. - If a Room Ambiguity Notices section is present, follow it exactly: echo resolved referents before acting, and under clarification notices do not create tasks. @@ -144,10 +144,10 @@ ${HEARTBEAT_CRITICAL_RULES} Your job: 1. Review your context — check messages, memory, and project state. -2. Do ONE useful action: analyze, create follow-up tasks, delegate work, or update memory. +2. Do ONE useful action: analyze, create approved mission-linked follow-up work, delegate approved mission work, or update memory. 3. Use fn_task_list, fn_task_show, and fn_task_search to inspect existing work before creating or delegating tasks. -4. Use fn_task_create to spawn follow-up work — but first scan the board/context for an existing open task covering the same work; do not duplicate. -5. Use fn_list_agents and fn_delegate_task to coordinate with other agents. +4. Use fn_task_create only with an approved Feature → Slice → Milestone → Mission reference; first scan the board/context for an existing open task covering the same work. +5. Use fn_list_agents and fn_delegate_task only for work carrying that approved mission lineage. 6. Use fn_get_agent_config and fn_update_agent_config to read/tune direct-report agents for better routing outcomes. 7. Call fn_heartbeat_done when finished with an optional summary of what was accomplished. @@ -179,8 +179,8 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo ## Triage and Routing Decisions Use this decision rule: -- **fn_task_create:** create executable work when ownership is not predetermined. -- **fn_delegate_task:** assign immediately when a specific agent should own the work now. +- **fn_task_create:** create executable work only when it carries an approved Feature → Slice → Milestone → Mission reference. +- **fn_delegate_task:** assign approved mission work immediately when a specific agent should own it now. - **fn_memory_append:** use \`scope="agent"\` for your own operating context and \`scope="project"\` for repo-wide durable knowledge; avoid transient run-by-run chatter. If unsure who should do the work, prefer fn_task_create and let scheduler routing happen naturally. diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 098f35a9f2..1eba33d601 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -2506,7 +2506,16 @@ export class HeartbeatMonitor { sourceType: "agent_heartbeat", sourceAgentId: agentId, sourceRunId: runContext?.runId, - }, { rootDir: this.rootDir })); + }, { + rootDir: this.rootDir, + /* + FNXC:MissionAdmission 2026-08-01-02:00: + Idle heartbeats have no source task whose approved lineage can be + inherited. Require a supplied lineage so the factory validates and + persists the same Feature → Slice → Milestone → Mission proof. + */ + requireMissionLineage: true, + })); /* FNXC:AgentTooling 2026-06-27-11:45: @@ -2518,7 +2527,7 @@ export class HeartbeatMonitor { // Agent delegation tools heartbeatTools.push(createListAgentsTool(this.store)); - heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir })); + heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir, sourceAgentId: agentId })); heartbeatTools.push(createTaskAssignTool(this.store, taskStore)); heartbeatTools.push(createGetAgentConfigTool(this.store, agentId)); heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId)); @@ -3131,8 +3140,13 @@ export class HeartbeatMonitor { : []; const noTaskActionGuidanceLines = plannerHeartbeatPatrolEnabled ? [ - "2. **Create new tasks** — Use fn_task_create for net-new executable work.", - " Prefer concrete tasks with clear outcomes; avoid vague placeholders.", + /* + FNXC:MissionAdmission 2026-07-30-00:00: + FN-8307 forbids idle heartbeats from inventing implementation work. + Creation/delegation is available only with a validated approved mission lineage. + */ + "2. **Mission-linked tasks only** — Use fn_task_create only with an approved Feature → Slice → Milestone → Mission reference.", + " Do not create off-mission implementation work; prefer safe coordination when no approved lineage exists.", "", ] : [ @@ -3143,7 +3157,7 @@ export class HeartbeatMonitor { const noTaskFlowGuidanceLines = plannerHeartbeatPatrolEnabled ? [ "5. **Monitor project flow** — Review board/project signals and surface issues", - " by creating or delegating follow-up work as appropriate.", + " by creating or delegating only approved mission-linked follow-up work as appropriate.", "", ] : [ @@ -3186,15 +3200,15 @@ export class HeartbeatMonitor { " If replying, use fn_send_message and include reply_to_message_id so threads stay linked.", "", ...noTaskActionGuidanceLines, - "3. **Delegate work** — Use fn_list_agents to find available specialists, then", - " fn_delegate_task when immediate ownership by a specific agent is beneficial.", + "3. **Delegate mission work** — Use fn_list_agents to find available specialists, then", + " fn_delegate_task only with an approved Feature → Slice → Milestone → Mission reference.", "", "4. **Update memory** — Use fn_memory_append for durable, reusable learnings", " (conventions, pitfalls, architecture constraints), not transient chatter.", "", ...noTaskFlowGuidanceLines, "When auto-claim relevant tasks is enabled, review Open Task Candidates above and", - "prioritize tasks that align with your role and soul before creating net-new tasks.", + "prioritize approved mission work that aligns with your role and soul before creating tasks.", ...candidateLines, ...pendingMessagesLines, ...pendingRoomMessagesLines, @@ -3750,7 +3764,7 @@ export class HeartbeatMonitor { sourceAgentId: agentId, sourceRunId: runContext?.runId, sourceParentTaskId: taskId, - }, { rootDir: this.rootDir }); + }, { rootDir: this.rootDir, sourceTaskId: taskId, sourceAgentId: agentId }); const trackedCreateTool: ToolDefinition = { ...baseCreateTool, execute: async (id: string, params: Static, signal, onUpdate, ctx) => { @@ -3807,7 +3821,7 @@ export class HeartbeatMonitor { tools.push(createArtifactViewTool(taskStore)); // Agent delegation tools — discover and delegate work to other agents tools.push(createListAgentsTool(this.store)); - tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir })); + tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir, sourceTaskId: taskId, sourceAgentId: agentId })); tools.push(createTaskAssignTool(this.store, taskStore)); tools.push(createGetAgentConfigTool(this.store, agentId)); tools.push(createUpdateAgentConfigTool(this.store, agentId)); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 847d0aea0e..73eb95b973 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -37,6 +37,12 @@ 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" }), +}); + export const taskCreateParams = Type.Object({ description: Type.String({ description: "What needs to be done" }), dependencies: Type.Optional( @@ -54,6 +60,7 @@ export const taskCreateParams = Type.Object({ "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), + mission_lineage: Type.Optional(missionLineageParams), }); export const taskLogParams = Type.Object({ @@ -384,6 +391,7 @@ export const delegateTaskParams = Type.Object({ "Omit to inherit the project default workflow. Use fn_workflow_list to discover valid IDs.", }), ), + mission_lineage: Type.Optional(missionLineageParams), override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), }); @@ -940,8 +948,66 @@ type AgentTaskCreationOptions = { messageStore?: MessageStore; sourceAgentId?: string; sourceTaskId?: string; + /** Require a caller-supplied lineage rather than inheriting a task-parent lineage. */ + requireMissionLineage?: boolean; }; +type MissionLineageReference = { + missionId: string; + sliceId: string; + featureId: string; +}; + +/** + * FNXC:MissionAdmission 2026-07-30-00:00: + * FN-8307 requires every autonomous implementation create/delegate operation 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. + */ +async function resolveApprovedMissionLineage( + store: TaskStore, + requested: { mission_id: string; slice_id: string; feature_id: string } | undefined, + sourceTaskId: string | undefined, +): Promise { + const missionStore = store.getMissionStore?.(); + if (!missionStore) return { error: "Mission lineage is unavailable; no task was created." }; + + let requestedLineage = requested; + if (!requestedLineage && sourceTaskId) { + const sourceFeature = await missionStore.getFeatureByTaskId(sourceTaskId); + if (sourceFeature) { + const sourceSlice = await missionStore.getSlice(sourceFeature.sliceId); + const sourceMilestone = sourceSlice ? await missionStore.getMilestone(sourceSlice.milestoneId) : undefined; + if (sourceSlice && sourceMilestone) { + requestedLineage = { + mission_id: sourceMilestone.missionId, + slice_id: sourceSlice.id, + feature_id: sourceFeature.id, + }; + } + } + } + if (!requestedLineage) return { error: "Approved mission_lineage is required; no task was created." }; + + const [feature, slice, mission] = await Promise.all([ + missionStore.getFeature(requestedLineage.feature_id), + missionStore.getSlice(requestedLineage.slice_id), + missionStore.getMission(requestedLineage.mission_id), + ]); + const milestone = slice ? await missionStore.getMilestone(slice.milestoneId) : undefined; + if (!feature || !slice || !milestone || !mission + || feature.sliceId !== slice.id || milestone.missionId !== mission.id) { + return { error: "mission_lineage must name one valid Feature → Slice → Milestone → Mission chain; no task was created." }; + } + const approval = fusionCore.evaluateMissionLineageApproval({ + feature, slice, milestone, mission, task: {}, planApprovalRequired: false, + }); + if (!approval.approved) return { error: `Mission lineage is not approved (${approval.reason}); no task was created.` }; + return { missionId: mission.id, sliceId: slice.id, featureId: feature.id }; +} + /* FNXC:AgentRouting 2026-07-29-00:00: FN-8207 requires deterministic-duplicate canonical tasks to honor an explicit delegate's owner and todo-column request. Carry both mutations in the engine task-creation seam so every canonical return path is truthful without changing the shared core duplicate-guard API. @@ -1196,6 +1262,14 @@ export function createTaskCreateTool( } } const workflowId = params.workflow_id?.trim() || undefined; + const lineage = await resolveApprovedMissionLineage( + store, + params.mission_lineage, + options?.requireMissionLineage ? undefined : options?.sourceTaskId ?? provenance?.sourceParentTaskId, + ); + if ("error" in lineage) { + return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; + } /* FNXC:Workflows 2026-07-05-00:00: fn_task_create must NOT hardcode column:"triage" here. TaskStore.createTask already @@ -1213,12 +1287,16 @@ export function createTaskCreateTool( dependencies: params.dependencies, priority: params.priority, ...(workflowId ? { workflowId } : {}), - source: provenance ? { - sourceType: provenance.sourceType, - sourceAgentId: provenance.sourceAgentId, - sourceRunId: provenance.sourceRunId, - sourceParentTaskId: provenance.sourceParentTaskId, - } : undefined, + 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 }, + }, }, options); const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const workflow = workflowId ? ` (workflow: ${workflowId})` : ""; @@ -4431,6 +4509,10 @@ 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) { + return { content: [{ type: "text" as const, text: `ERROR: ${lineage.error}` }], details: { rule: "mission-lineage-required" }, isError: true }; + } // Create task assigned to the target agent const { task, wasDuplicate } = await createAgentTask(taskStore, { description: params.description, @@ -4438,9 +4520,16 @@ export function createDelegateTaskTool( column: "todo", assignedAgentId: params.agent_id, ...(workflowId ? { workflowId } : {}), + missionId: lineage.missionId, + sliceId: lineage.sliceId, source: { sourceType: "api", - ...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}), + sourceParentTaskId: options?.sourceTaskId, + sourceAgentId: options?.sourceAgentId, + sourceMetadata: { + missionLineage: lineage, + ...(override ? { executorRoleOverride: true } : {}), + }, }, }, options); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index faeacf1555..8f8d5b6c17 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -11859,7 +11859,7 @@ export class TaskExecutor { // Agent delegation tools — discover and delegate work to other agents. ...(this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), - createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }), + createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir, sourceTaskId: task.id, sourceAgentId: assignedAgentId }), createTaskAssignTool(this.options.agentStore, this.store), ...(assignedAgentId ? [ createGetAgentConfigTool(this.options.agentStore, assignedAgentId), diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 2b72408a29..502972355b 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -47,7 +47,15 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet = new Set([ * FNXC:ToolGovernance 2026-06-27-16:51: * Identity reflection stays out of this action-gate mutation-only list because it is heartbeat-critical coordination, not a task-board mutation. Keep it in COORDINATION_EXEMPT_TOOLS and READONLY_FN_TOOLS so exported mutation sets do not contradict action-gate exemption semantics. */ -const PERMANENT_AND_ACTION_TASK_AGENT_TOOLS = ["fn_task_create"] as const; +/* +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. +*/ +export const MISSION_LINEAGE_ADMISSION_TOOLS: ReadonlySet = 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, "fn_delegate_task", @@ -161,7 +169,6 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_task_get", "fn_task_document_write", "fn_task_document_read", - "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", "fn_task_import_gitlab_project_issues", diff --git a/packages/engine/src/mission-feature-sync.ts b/packages/engine/src/mission-feature-sync.ts index a9fd8f8d35..02bd8d8d1a 100644 --- a/packages/engine/src/mission-feature-sync.ts +++ b/packages/engine/src/mission-feature-sync.ts @@ -19,10 +19,16 @@ export async function reconcileMissionFeatureState( feature: Pick, context: MissionFeatureSyncContext = {}, ): Promise { - if (task.status === "failed" && feature.status === "in-progress") { + /* + FNXC:MissionReconciliation 2026-07-30-00:00: + FN-8307 makes failure a provenance-preserving withheld outcome regardless of + the feature's current state. A released scheduler symbol lock permits this + reconciliation but never proves implementation completion. + */ + if (task.status === "failed" || task.error) { return { kind: "failure", - reason: `task ${task.id} failed while feature ${feature.id} is in-progress`, + reason: `task ${task.id} failed; feature ${feature.id} remains ${feature.status}`, }; } @@ -58,28 +64,13 @@ export async function reconcileMissionFeatureState( return { kind: "noop" }; } - if (task.column === "archived") { - if (hasUnvalidatedAssertions) { - if (feature.status !== "in-progress") { - return { - kind: "update", - status: "in-progress", - reason: `task ${task.id} archived; awaiting assertion validation`, - }; - } - return { kind: "noop" }; - } - - if (feature.status !== "done") { - return { - kind: "update", - status: "done", - reason: `task ${task.id} was archived after completion`, - }; - } - - return { kind: "noop" }; - } + /* + FNXC:MissionReconciliation 2026-07-30-00:00: + Archiving is retention, not a completion signal. Leave canonical feature + status untouched so a terminal/duplicate archive cannot fabricate roadmap + progress; callers may still recompute hierarchy idempotently. + */ + if (task.column === "archived") return { kind: "noop" }; if ( (task.column === "in-progress" || task.column === "in-review") diff --git a/packages/engine/src/mission-symbol-admission.ts b/packages/engine/src/mission-symbol-admission.ts index 2ccfe2bc09..8699b8809c 100644 --- a/packages/engine/src/mission-symbol-admission.ts +++ b/packages/engine/src/mission-symbol-admission.ts @@ -32,14 +32,39 @@ export interface MissionSymbolAdmissionOptions { type MissionReader = Pick< MissionStore | AsyncMissionStore, - "getMission" | "getMilestone" | "getSlice" | "getFeatureByTaskId" + "getMission" | "getMilestone" | "getSlice" | "getFeature" | "getFeatureByTaskId" >; +type PersistedMissionLineage = { missionId: string; sliceId: string; featureId: string }; + +function parsePersistedMissionLineage(task: Task): PersistedMissionLineage | undefined { + const candidate = task.sourceMetadata?.missionLineage; + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined; + const { missionId, sliceId, featureId } = candidate as Record; + return typeof missionId === "string" && typeof sliceId === "string" && typeof featureId === "string" + ? { missionId, sliceId, featureId } + : undefined; +} + /** - * Resolve the canonical feature link rather than title matching: scheduler - * admission must fail closed for mission work whose hierarchy cannot be proven. + * FNXC:MissionSymbolAdmission 2026-08-01-00:00: + * Decision-A follow-up tasks retain the source feature's scalar taskId and carry + * a separately validated sourceMetadata.missionLineage reference. Resolve that + * reference before the canonical link so scheduler admission and reconciliation + * preserve source ownership without treating a metadata-shaped value as proof. */ -async function resolveFeature(store: MissionReader, task: Task): Promise { +export async function resolveMissionFeatureForTask( + store: MissionReader, + task: Task, +): Promise { + const persisted = parsePersistedMissionLineage(task); + if (persisted) { + const feature = await store.getFeature(persisted.featureId); + if (feature?.sliceId === persisted.sliceId && task.sliceId === persisted.sliceId && task.missionId === persisted.missionId) { + return feature; + } + return undefined; + } return await store.getFeatureByTaskId(task.id); } @@ -65,7 +90,7 @@ export async function decideMissionSymbolAdmission( : { kind: "coarse-fallback", reason: "non-mission" }; } - const feature = await resolveFeature(missionStore, task); + const feature = await resolveMissionFeatureForTask(missionStore, task); const missionLinked = declaredMissionLink || Boolean(feature); if (!missionLinked) return { kind: "coarse-fallback", reason: "non-mission" }; // Resolve every stated lineage edge independently so diagnostics distinguish diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index c01bbc9fe8..12aad218d3 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -9,6 +9,7 @@ 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, @@ -39,6 +40,17 @@ 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).mission_lineage; + if (!lineage || typeof lineage !== "object") return false; + const reference = lineage as Record; + return ["mission_id", "slice_id", "feature_id"].every((key) => + typeof reference[key] === "string" && reference[key].trim().length > 0, + ); +} function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -163,6 +175,16 @@ export function resolvePermanentAgentToolDecision(input: { }): PermanentAgentToolDecision { 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. + */ + if (MISSION_ADMISSION_TOOLS.has(input.toolName) && !hasMissionLineageReference(input.args)) { + return { ...classification, toolName: input.toolName, disposition: "block" }; + } + if (!input.gating?.permissionPolicy) { return { ...classification, diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index b351572353..29e9b986bf 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -47,7 +47,7 @@ import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core"; import { runHoldReleaseSweep, isUnplannedForExecution, type SlotReservation } from "./hold-release.js"; import { moveTaskToReplanColumn } from "./replan-target.js"; import { evaluateParkedAgentTaskLink } from "./task-agent-sync.js"; -import { decideMissionSymbolAdmission } from "./mission-symbol-admission.js"; +import { decideMissionSymbolAdmission, resolveMissionFeatureForTask } from "./mission-symbol-admission.js"; const SYMBOL_LOCK_LEASE_MS = 10 * 60_000; @@ -857,8 +857,17 @@ export class Scheduler { this.options.snapshotManager?.invalidate("task:updated"); } // Track mission failure signals before moveTask clears failure metadata. - if (task.sliceId && task.column === "in-progress" && task.status === "failed") { - this.failedTaskIds.add(task.id); + if (task.sliceId && task.status === "failed") { + if (task.column === "in-progress") this.failedTaskIds.add(task.id); + /* + FNXC:MissionReconciliation 2026-08-01-00:00: + In-place failure parks do not emit task:moved, but they release the + task's durable symbol lock. Reconcile any mission-linked failure update + so the roadmap records withheld provenance without fabricating completion. + */ + if (this.options.missionStore) { + void this.handleMissionTaskMove(task.id, task.column); + } } else if (task.status !== "failed") { this.failedTaskIds.delete(task.id); } @@ -2285,10 +2294,6 @@ export class Scheduler { */ /** * FNXC:MissionSymbolAdmission 2026-07-19-22:04: - * Admission leases are intentionally short so crash recovery can reclaim them, - * but every active mission owner renews on the scheduler heartbeat. Renewal runs - * before pause handling because a paused scheduler still permits existing work to - * finish and must not let same-symbol work enter after its original lease expires. * Active implementation follows the workflow `countsTowardWip` trait, so renamed * and multi-WIP workflows keep their declared symbols exclusively held. */ @@ -3138,7 +3143,7 @@ export class Scheduler { } private async resolveMissionFeatureForTask(missionStore: MissionStore | AsyncMissionStore, task: Task): Promise { - const linkedFeature = await missionStore.getFeatureByTaskId(task.id); + const linkedFeature = await resolveMissionFeatureForTask(missionStore, task); if (linkedFeature) { return linkedFeature; } diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 3a443a3926..ff075941e2 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -1318,7 +1318,7 @@ export class StepSessionExecutor { const delegationTools = this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), - createDelegateTaskTool(this.options.agentStore, this.options.store!, { rootDir: this.options.rootDir }), + createDelegateTaskTool(this.options.agentStore, this.options.store!, { rootDir: this.options.rootDir, sourceTaskId: this.options.sourceTaskId ?? taskDetail.id, sourceAgentId: this.options.sourceAgentId ?? taskDetail.assignedAgentId }), createTaskAssignTool(this.options.agentStore, this.options.store!), ] : []; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 181e00d27d..e780b4098d 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -1324,7 +1324,7 @@ export class TriageProcessor { // Agent delegation tools — discover and delegate work to other agents. ...(this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), - createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }), + createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir, sourceTaskId: task.id, sourceAgentId: assignedAgent?.id }), createTaskAssignTool(this.options.agentStore, this.store), ] : []), ];