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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8307-heartbeat-mission-guard.md
Normal file
7
.changeset/fn-8307-heartbeat-mission-guard.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TaskStore["updateTask"]>[1], runContext?: RunMutationContext,): Promise<Task> {
|
||||
{
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -11,8 +11,18 @@ function createMockAgentStore(overrides: Partial<AgentStore> = {}): 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> = {}): 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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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>) : {};
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof taskCreateParams>, 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));
|
||||
|
||||
@@ -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<MissionLineageReference | { error: string }> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -47,7 +47,15 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = 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<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,
|
||||
"fn_delegate_task",
|
||||
@@ -161,7 +169,6 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = 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",
|
||||
|
||||
@@ -19,10 +19,16 @@ export async function reconcileMissionFeatureState(
|
||||
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus">,
|
||||
context: MissionFeatureSyncContext = {},
|
||||
): Promise<MissionFeatureSyncDecision> {
|
||||
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")
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<MissionFeature | undefined> {
|
||||
export async function resolveMissionFeatureForTask(
|
||||
store: MissionReader,
|
||||
task: Task,
|
||||
): Promise<MissionFeature | undefined> {
|
||||
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
|
||||
|
||||
@@ -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<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>) : {};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MissionFeature | undefined> {
|
||||
const linkedFeature = await missionStore.getFeatureByTaskId(task.id);
|
||||
const linkedFeature = await resolveMissionFeatureForTask(missionStore, task);
|
||||
if (linkedFeature) {
|
||||
return linkedFeature;
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -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),
|
||||
] : []),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user