From 0fc6f3d849789b663af1d7bcc1be6206454b9883 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 10 Aug 2026 07:30:42 -0700 Subject: [PATCH] FN-8946: enable attributed agent mission status updates Enable authorized agents to update feature and mission statuses with transactional, attributed audit events. - Add mission and feature status tools to the Fusion extension and engine allowlists. - Record bounded actor, reason, and hierarchy metadata for status transitions across every writer. - Guard linked feature transitions and document the agent-facing workflow. Files changed: .changeset/fn-8946-mission-status-writes.md | 7 + docs/missions.md | 7 +- packages/cli/skill/fusion/SKILL.md | 2 +- .../cli/skill/fusion/references/extension-tools.md | 20 +++ .../skill/fusion/references/fusion-capabilities.md | 2 + packages/cli/src/__tests__/extension.test.ts | 46 ++++++ packages/cli/src/extension.ts | 27 +++ .../mission-status-event-metadata.test.ts | 50 ++++++ .../__tests__/postgres/mission-store.pg.test.ts | 160 +++++++++++++++++- .../core/src/async-stores/async-mission-store.ts | 182 +++++++++++++++------ packages/core/src/index.ts | 5 + packages/core/src/missions/mission-store.ts | 7 +- packages/core/src/missions/mission-types.ts | 99 +++++++++-- .../src/__tests__/chat-toolset-permissions.test.ts | 24 +++ packages/dashboard/src/mission-routes.ts | 3 +- .../src/__tests__/agent-mission-tools.test.ts | 42 ++++- .../src/__tests__/heartbeat-executor.test.ts | 4 +- .../workflow-step-readonly-allowlist.test.ts | 2 + packages/engine/src/agent-tools.ts | 18 ++ .../engine/src/execution/gating-classifications.ts | 2 + 20 files changed, 635 insertions(+), 74 deletions(-) Fusion-Task-Id: FN-8946 Fusion-Task-Lineage: 473cc0e0-e632-48b3-ac84-adaaeb81db4b Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8946-mission-status-writes.md | 7 + docs/missions.md | 7 +- packages/cli/skill/fusion/SKILL.md | 2 +- .../fusion/references/extension-tools.md | 20 ++ .../fusion/references/fusion-capabilities.md | 2 + packages/cli/src/__tests__/extension.test.ts | 46 +++++ packages/cli/src/extension.ts | 27 +++ .../mission-status-event-metadata.test.ts | 50 +++++ .../postgres/mission-store.pg.test.ts | 160 ++++++++++++++- .../src/async-stores/async-mission-store.ts | 182 +++++++++++++----- packages/core/src/index.ts | 5 + packages/core/src/missions/mission-store.ts | 7 +- packages/core/src/missions/mission-types.ts | 99 +++++++++- .../chat-toolset-permissions.test.ts | 24 +++ packages/dashboard/src/mission-routes.ts | 3 +- .../src/__tests__/agent-mission-tools.test.ts | 42 +++- .../src/__tests__/heartbeat-executor.test.ts | 4 +- .../workflow-step-readonly-allowlist.test.ts | 2 + packages/engine/src/agent-tools.ts | 18 ++ .../src/execution/gating-classifications.ts | 2 + 20 files changed, 635 insertions(+), 74 deletions(-) create mode 100644 .changeset/fn-8946-mission-status-writes.md create mode 100644 packages/core/src/__tests__/mission-status-event-metadata.test.ts diff --git a/.changeset/fn-8946-mission-status-writes.md b/.changeset/fn-8946-mission-status-writes.md new file mode 100644 index 0000000000..ed7429d316 --- /dev/null +++ b/.changeset/fn-8946-mission-status-writes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Let agents reconcile mission and feature status with attributed audit events. +category: feature +dev: Adds fn_feature_set_status and fn_mission_set_status, atomic actor-attributed feature_status_changed events across all status writers, a shared ids-only bounded metadata builder for both status events, and the linked-task guard. diff --git a/docs/missions.md b/docs/missions.md index 4b9f415a1c..589fb9ae6d 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -205,6 +205,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r | `fn_mission_unlink_goal` | Idempotently unlink a goal from a mission, including archived goals. | | `fn_mission_delete` | Delete a mission and its hierarchy. | | `fn_mission_update` | Update mission title/description using partial patches. | +| `fn_mission_set_status` | Set mission lifecycle status with an attributed audit event. | | `fn_milestone_add` | Add a milestone to a mission. | | `fn_milestone_update` | Update milestone fields using partial patches. | | `fn_slice_add` | Add a slice to a milestone. | @@ -213,6 +214,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r | `fn_feature_add` | Add a feature to a slice with optional acceptance criteria. | | `fn_feature_delete` | Delete a feature (with linked-task guard and optional `force`). | | `fn_feature_update` | Update feature fields using partial patches. | +| `fn_feature_set_status` | Set feature status; execution statuses require a linked task. | | `fn_feature_link_task` | Link a feature to a task for implementation. | | `fn_milestone_delete` | Delete a milestone (with linked-task guard and optional `force`). | @@ -740,7 +742,7 @@ See also: [Multi-Project](./multi-project.md) and [Task Management](./task-manag ## Agent and dashboard-chat tools -Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_delete`, and `fn_feature_link_task`. +Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_set_status`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_set_status`, `fn_feature_delete`, and `fn_feature_link_task`. `fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools. @@ -767,3 +769,6 @@ Every automatic suppression appends one visible `validation memoized` activity e ## Spec alignment A linked task may expose a separate spec alignment signal: `on-plan`, `diverged-needs-review`, `diverged-relocked-approved`, or `unavailable`. This signal is independent of feature delivery and validation status; it never marks a feature done, blocks a task, or substitutes for assertion validation. Archived tasks retain their task-visible lock history but follow the existing unlink behavior and do not recreate a feature projection. + + +`fn_feature_set_status` preserves the linked-task guard: `triaged`, `in-progress`, `done`, and `blocked` require a linked task; link an existing task with `fn_feature_link_task` or triage the feature first. Feature status writes emit `feature_status_changed` atomically with the row write at every production writer: engine and pi tools, dashboard REST repairs, scheduler work, linking/claiming, terminal-task reconciliation, validator reuse, and superseded-fix reconciliation. Feature and mission status events use one total, size-capped metadata builder, which persists only ids-only actor fields (`type`, `id`, `source`; never `displayName`) and an optional redacted, byte-bounded reason. diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 43ac50c368..78ff981a13 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -30,7 +30,7 @@ Mission → Milestone → Slice → Feature → Task - **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_logs_read`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_bypass_review`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan` - **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select`, `fn_workflow_step_resume` - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` -- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` +- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_set_status`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_set_status`, `fn_feature_update`, `fn_milestone_update` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` - **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_update`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Skills tools** — `fn_skills_search`, `fn_skills_install` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 012256acdb..beb5231763 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -420,6 +420,16 @@ Delete a mission and all its milestones, slices, and features. Cannot be undone. |-----------|------|----------|-------------| | `id` | string | ✓ | Mission ID to delete (e.g., M-001) | +### fn_mission_set_status + +Set a mission lifecycle status. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | ✓ | | +| `status` | union | ✓ | | +| `reason` | string | — | | + ### fn_mission_update Update an existing mission's title or description. Partial patches leave untouched fields intact. @@ -505,6 +515,16 @@ Link a feature to a fn task for implementation. Updates the feature status to 't | `featureId` | string | ✓ | Feature ID to link (e.g., F-001) | | `taskId` | string | ✓ | Task ID to link to (e.g., FN-001) | +### fn_feature_set_status + +Set a feature lifecycle status. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | string | ✓ | | +| `status` | union | ✓ | | +| `reason` | string | — | | + ### fn_feature_update Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 37514025d1..75e481f4b8 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -66,6 +66,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_mission_unlink_goal` | Unlink a goal from a mission. | | `fn_mission_backfill_assertions` | Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode. | | `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. | +| `fn_mission_set_status` | Set a mission lifecycle status. | | `fn_mission_update` | Update an existing mission's title or description. Partial patches leave untouched fields intact. | | `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. | | `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. | @@ -75,6 +76,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_milestone_delete` | Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true. | | `fn_slice_activate` | Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. | | `fn_feature_link_task` | Link a feature to a fn task for implementation. Updates the feature status to 'triaged' and associates it with the task. If the target task is not on the active board (for example archived, deleted, or never created), the tool returns a clear validation error indicating that only active tasks can be linked. | +| `fn_feature_set_status` | Set a feature lifecycle status. | | `fn_feature_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. | | `fn_milestone_update` | Update an existing milestone's title, description, or acceptance criteria (the structured pass/fail bar, distinct from verification's free-form how-to-confirm notes). Partial patches leave untouched fields intact. | | `fn_agent_stop` | Stop a running agent — pauses its execution without changing assigned task pause state. Transitions the agent from running/active to paused state. | diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 68b9665fa3..776efd9a85 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -296,6 +296,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => { "fn_mission_backfill_assertions", "fn_mission_delete", "fn_mission_update", + "fn_mission_set_status", "fn_milestone_add", "fn_slice_add", "fn_feature_add", @@ -305,6 +306,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => { "fn_slice_activate", "fn_feature_link_task", "fn_feature_update", + "fn_feature_set_status", "fn_milestone_update", "fn_agent_stop", "fn_agent_start", @@ -2893,6 +2895,50 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { expect(result.content[0].text).toContain(result.details.taskId); }); + it("executes the dedicated mission status tools with linked-feature protection", async () => { + const context = makeCtx(tmpDir); + const mission = await api.tools.get("fn_mission_create")!.execute("m", { title: "Mission" }, undefined, undefined, context); + const milestone = await api.tools.get("fn_milestone_add")!.execute("ms", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, context); + const slice = await api.tools.get("fn_slice_add")!.execute("sl", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, context); + const feature = await api.tools.get("fn_feature_add")!.execute("f", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, context); + const setFeatureStatus = api.tools.get("fn_feature_set_status")!; + + const unlinked = await setFeatureStatus.execute("unlinked", { id: feature.details.featureId, status: "done" }, undefined, undefined, context); + expect(unlinked.isError).toBe(true); + expect(unlinked.content[0].text).toContain("linked task"); + expect((await h.store().getMissionStore().getMissionEvents(mission.details.missionId, { limit: 10 })).events + .filter((event) => event.eventType === "feature_status_changed")).toHaveLength(0); + + const invalidFeature = await setFeatureStatus.execute("invalid-feature", { id: feature.details.featureId, status: "invalid" }, undefined, undefined, context); + expect(invalidFeature.isError).toBe(true); + expect(invalidFeature.content[0].text).toContain("Invalid status. Must be one of:"); + + const task = await api.tools.get("fn_task_create")!.execute("task", { description: "Linked delivery" }, undefined, undefined, context); + await api.tools.get("fn_feature_link_task")!.execute("link", { featureId: feature.details.featureId, taskId: task.details.taskId }, undefined, undefined, context); + const changed = await setFeatureStatus.execute("status", { id: feature.details.featureId, status: "done", reason: "completed" }, undefined, undefined, context); + expect(changed.isError).not.toBe(true); + expect((await h.store().getMissionStore().getFeature(feature.details.featureId))?.status).toBe("done"); + expect((await h.store().getMissionStore().getMissionEvents(mission.details.missionId, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "feature_status_changed", + metadata: expect.objectContaining({ reason: "completed", actor: { type: "operator", id: "cli-operator", source: "pi-extension" } }), + }), + ])); + + const missionChanged = await api.tools.get("fn_mission_set_status")!.execute("mission-status", { id: mission.details.missionId, status: "blocked", reason: "waiting" }, undefined, undefined, context); + expect(missionChanged.isError).not.toBe(true); + expect((await h.store().getMissionStore().getMission(mission.details.missionId))?.status).toBe("blocked"); + expect((await h.store().getMissionStore().getMissionEvents(mission.details.missionId, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "mission_status_changed", + metadata: expect.objectContaining({ reason: "waiting", actor: { type: "operator", id: "cli-operator", source: "pi-extension" } }), + }), + ])); + const invalidMission = await api.tools.get("fn_mission_set_status")!.execute("invalid-mission", { id: mission.details.missionId, status: "invalid" }, undefined, undefined, context); + expect(invalidMission.isError).toBe(true); + expect(invalidMission.content[0].text).toContain("Invalid status. Must be one of:"); + }); + describe("fn_task_list", () => { const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000; diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index c2f21bf11d..d2748d027a 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -4553,6 +4553,18 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + // ── fn_mission_set_status ─────────────────────────────────────── + pi.registerTool({ + name: "fn_mission_set_status", label: "fn: Set Mission Status", description: "Set a mission lifecycle status.", promptSnippet: "Set a mission status", promptGuidelines: ["Use lifecycle statuses only"], + parameters: Type.Object({ id: Type.String(), status: Type.Union(fusionCore.MISSION_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!fusionCore.MISSION_STATUSES.includes(params.status)) return { content: [{ type: "text", text: `Invalid status. Must be one of: ${fusionCore.MISSION_STATUSES.join(", ")}` }], isError: true, details: { error: "Invalid status" } }; + const missionStore = (await getStore(ctx.cwd)).getMissionStore(); + try { const mission = await missionStore.updateMission(params.id, { status: params.status }, { actor: missionTransitionActor(ctx), reason: params.reason }); return { content: [{ type: "text", text: `Set ${mission.id} status to ${mission.status}` }], details: { mission } }; } + catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message } }; } + }, + }); + // ── fn_mission_update ─────────────────────────────────────────── pi.registerTool({ @@ -4998,6 +5010,21 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + // ── fn_feature_set_status ─────────────────────────────────────── + /* FNXC:MissionStatusWrites 2026-08-10-12:47: Dedicated tools keep the linked-task execution-status guard unambiguous instead of widening generic updates. */ + pi.registerTool({ + name: "fn_feature_set_status", label: "fn: Set Feature Status", description: "Set a feature lifecycle status.", promptSnippet: "Set a feature status", promptGuidelines: ["Link a task before execution statuses"], + parameters: Type.Object({ id: Type.String(), status: Type.Union(fusionCore.FEATURE_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!fusionCore.FEATURE_STATUSES.includes(params.status)) return { content: [{ type: "text", text: `Invalid status. Must be one of: ${fusionCore.FEATURE_STATUSES.join(", ")}` }], isError: true, details: { error: "Invalid status" } }; + const missionStore = (await getStore(ctx.cwd)).getMissionStore(); const feature = await missionStore.getFeature(params.id); + if (!feature) return { content: [{ type: "text", text: `Feature ${params.id} not found` }], isError: true, details: { error: "Feature not found" } }; + if ((["triaged", "in-progress", "done", "blocked"] as const).includes(params.status) && !feature.taskId) return { content: [{ type: "text", text: `Cannot set status to '${params.status}' without a linked task. Use the triage endpoint to create and link a task first, or link an existing task via fn_feature_link_task.` }], isError: true, details: { error: "FEATURE_TASK_REQUIRED" } }; + try { const updated = await missionStore.updateFeatureStatus(params.id, params.status, { actor: missionTransitionActor(ctx), reason: params.reason }); return { content: [{ type: "text", text: `Set ${updated.id} status to ${updated.status}` }], details: { feature: updated } }; } + catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message } }; } + }, + }); + // ── fn_feature_update ───────────────────────────────────────────── pi.registerTool({ diff --git a/packages/core/src/__tests__/mission-status-event-metadata.test.ts b/packages/core/src/__tests__/mission-status-event-metadata.test.ts new file mode 100644 index 0000000000..3127c0b067 --- /dev/null +++ b/packages/core/src/__tests__/mission-status-event-metadata.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + MISSION_EVENT_METADATA_MAX_BYTES, + boundMissionEventReason, + buildMissionStatusEventMetadata, + normalizeMissionTransitionActorForEvent, +} from "../missions/mission-types.js"; + +describe("mission status event metadata", () => { + it("redacts, bounds, and rejects semantically empty untrusted reasons", () => { + expect(boundMissionEventReason("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef").value).toContain("[REDACTED]"); + expect(boundMissionEventReason("/private/secret/worktree/file.ts").value).toContain("[external path omitted]"); + expect(boundMissionEventReason(" ".repeat(600))).toEqual({}); + const bounded = boundMissionEventReason("ordinary prose ".repeat(100)); + expect(bounded.truncated).toBe(true); + expect(Buffer.byteLength(bounded.value!, "utf8")).toBeLessThanOrEqual(512); + }); + + it("persists only normalized actor identity", () => { + const actor = normalizeMissionTransitionActorForEvent({ + type: "not-a-real-actor", + id: 42, + source: "s".repeat(500), + displayName: "must not persist", + extra: "must not persist", + }); + expect(actor).toMatchObject({ type: "system", id: "mission-store" }); + expect(actor).not.toHaveProperty("displayName"); + expect(Buffer.byteLength(actor.source, "utf8")).toBeLessThanOrEqual(200); + }); + + it("is total and produces bounded JSON for hostile metadata", () => { + const circular: Record = {}; + circular.self = circular; + const hostile = Object.defineProperty({}, "id", { get: () => { throw new Error("getter"); } }); + expect(() => buildMissionStatusEventMetadata({ + entity: "feature", field: "status", from: "defined", to: "done", + ids: { featureId: "F-1", missing: undefined }, actor: hostile, + reason: circular, + })).not.toThrow(); + const metadata = buildMissionStatusEventMetadata({ + entity: "mission", field: "autopilotEnabled", from: false, to: true, + ids: {}, actor: { type: "agent", id: "agent-1", source: "tool", displayName: "Ignored" }, + reason: "r".repeat(900), + }); + expect(metadata).toMatchObject({ source: "tool", from: false, to: true }); + expect(metadata.actor).toEqual({ type: "agent", id: "agent-1", source: "tool" }); + expect(Buffer.byteLength(JSON.stringify(metadata), "utf8")).toBeLessThanOrEqual(MISSION_EVENT_METADATA_MAX_BYTES); + }); +}); diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index fa61babf24..26b340400c 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -18,6 +18,7 @@ import { eq, sql } from "drizzle-orm"; import { readFile } from "node:fs/promises"; import type { DbTransaction } from "../../postgres/data-layer.js"; import type { TaskCreateInput } from "../../types/task/task-core.js"; +import type { MissionEvent } from "../../missions/mission-types.js"; import { pgDescribe, @@ -44,6 +45,8 @@ const pgTest = pgDescribe; pgTest("MissionStore (PostgreSQL backend mode)", () => { const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_mission_store", + /* FNXC:MissionStatusWrites 2026-08-10-13:49: Defined-feature bootstrap validates task ownership through a bound project partition. */ + projectId: "mission-store-pg-test", }); beforeAll(h.beforeAll); @@ -145,15 +148,43 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { "autopilot_disabled", "mission_status_changed", "autopilot_enabled", "mission_status_changed", ]); const statusEvents = events.filter((event) => event.eventType === "mission_status_changed"); + const persistedActor = { type: "operator", id: "user-42", source: "dashboard" }; expect(statusEvents.map((event) => event.metadata)).toEqual(expect.arrayContaining([ - expect.objectContaining({ field: "status", from: "planning", to: "active", source: "dashboard", actor }), - expect.objectContaining({ field: "status", from: "active", to: "blocked", source: "dashboard", actor }), + expect.objectContaining({ field: "status", from: "planning", to: "active", source: "dashboard", actor: persistedActor }), + expect.objectContaining({ field: "status", from: "active", to: "blocked", source: "dashboard", actor: persistedActor }), ])); const autopilotEvents = events.filter((event) => event.eventType.startsWith("autopilot_")); expect(autopilotEvents.map((event) => event.metadata)).toEqual(expect.arrayContaining([ - expect.objectContaining({ field: "autopilotEnabled", from: false, to: true, actor }), - expect.objectContaining({ field: "autopilotEnabled", from: true, to: false, actor }), + expect.objectContaining({ field: "autopilotEnabled", from: false, to: true, actor: persistedActor }), + expect.objectContaining({ field: "autopilotEnabled", from: true, to: false, actor: persistedActor }), ])); + expect(events.every((event) => !(event.metadata?.actor as Record | undefined)?.displayName)).toBe(true); + }); + + it("atomically audits feature status changes from both public status seams", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Feature audit" }); + const milestone = await m.addMilestone(mission.id, { title: "Milestone" }); + const slice = await m.addSlice(milestone.id, { title: "Slice" }); + const first = await m.addFeature(slice.id, { title: "First" }); + const second = await m.addFeature(slice.id, { title: "Second" }); + const actor = { type: "agent" as const, id: "agent-42", source: "fn_feature_set_status", displayName: "Not persisted" }; + + await m.updateFeatureStatus(first.id, "done", { actor, reason: `Authorization: Bearer sk-live-ABCDEFG1234567890abcdef ${"ordinary prose ".repeat(100)}` }); + await m.updateFeature(second.id, { status: "done" }, { actor }); + await m.updateFeature(second.id, { title: "No status event" }, { actor }); + + const events = (await m.getMissionEvents(mission.id, { limit: 20 })).events + .filter((event) => event.eventType === "feature_status_changed"); + expect(events).toHaveLength(2); + expect(events.map((event) => event.metadata)).toEqual(expect.arrayContaining([ + expect.objectContaining({ featureId: first.id, sliceId: slice.id, from: "defined", to: "done", source: "fn_feature_set_status", actor: { type: "agent", id: "agent-42", source: "fn_feature_set_status" }, reasonTruncated: true }), + expect.objectContaining({ featureId: second.id, sliceId: slice.id, from: "defined", to: "done" }), + ])); + const reasonEvent = events.find((event) => (event.metadata as Record)?.featureId === first.id)!; + expect(JSON.stringify(reasonEvent.metadata)).toContain("[REDACTED]"); + expect(JSON.stringify(reasonEvent.metadata)).not.toContain("displayName"); + expect((await m.getSlice(slice.id))?.status).toBe("complete"); }); it("createMission → addMilestone → addSlice → addFeature assembles getMissionWithHierarchy tree", async () => { @@ -274,14 +305,57 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { const slice = await m.addSlice(milestone.id, { title: "SL" }); const feature = await m.addFeature(slice.id, { title: "F" }); const task = await h.store().createTask({ description: "delivery task" }); + const observedEvents: MissionEvent[] = []; + m.on("mission:event", (event) => observedEvents.push(event)); const linked = await m.linkFeatureToTask(feature.id, task.id); expect(linked.taskId).toBe(task.id); expect(linked.status).toBe("triaged"); + expect(observedEvents).toEqual(expect.arrayContaining([expect.objectContaining({ eventType: "feature_status_changed", metadata: expect.objectContaining({ source: "mission-link" }) })])); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "feature_status_changed", + metadata: expect.objectContaining({ featureId: feature.id, from: "defined", to: "triaged", source: "mission-link" }), + }), + ])); const unlinked = await m.unlinkFeatureFromTask(feature.id); expect(unlinked.taskId).toBeUndefined(); expect(unlinked.status).toBe("defined"); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "feature_status_changed", + metadata: expect.objectContaining({ featureId: feature.id, from: "triaged", to: "defined", source: "mission-store" }), + }), + ])); + }); + + it("audits defined-feature bootstrap claims inside their task transaction", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Claim audit" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Claimed feature" }); + + const task = await h.store().createTask({ + description: "bootstrap claim", + missionId: mission.id, + sliceId: slice.id, + afterTaskInsert: (tx: DbTransaction, inserted: { id: string }) => m.claimDefinedFeatureTaskInTransaction(tx, { + featureId: feature.id, + taskId: inserted.id, + missionId: mission.id, + sliceId: slice.id, + }), + } as TaskCreateInput & { afterTaskInsert: (tx: DbTransaction, task: { id: string }) => Promise }); + + expect(await m.getFeature(feature.id)).toMatchObject({ taskId: task.id, status: "triaged" }); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "feature_status_changed", + metadata: expect.objectContaining({ featureId: feature.id, from: "defined", to: "triaged", source: "defined-feature-claim" }), + }), + ])); }); it("does not overwrite an existing task directory on a creation collision", async () => { @@ -600,11 +674,27 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(await m.getMission(mission.id)).toMatchObject({ status: "planning", autopilotEnabled: false, autoAdvance: false }); expect(await h.store().getTask(task.id)).toMatchObject({ missionId: mission.id, sliceId: slice.id, column: "done" }); expect((await h.store().listTasks()).length).toBe(taskCount); + const statusEvents = (await m.getMissionEvents(mission.id, { limit: 10 })).events + .filter((event) => event.eventType === "feature_status_changed"); + expect(statusEvents).toEqual([expect.objectContaining({ + metadata: expect.objectContaining({ featureId: feature.id, from: "defined", to: "done", source: "terminal-task-reconcile" }), + })]); const firstUpdatedAt = reconciled.updatedAt; const idempotent = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id); expect(idempotent.updatedAt).toBe(firstUpdatedAt); expect(idempotent).toEqual(reconciled); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events + .filter((event) => event.eventType === "feature_status_changed")).toHaveLength(1); + + // A link-only repair has featureChanged=true but no status delta, so it stays silent. + const linkOnly = await m.addFeature(slice.id, { title: "Already done" }); + await m.updateFeature(linkOnly.id, { status: "done" }); + const linkOnlyTask = await h.store().createTask({ description: "done", column: "done" }); + await m.reconcileFeatureDoneWithTerminalTask(linkOnly.id, linkOnlyTask.id); + expect((await m.getMissionEvents(mission.id, { limit: 20 })).events + .filter((event) => event.eventType === "feature_status_changed" && (event.metadata as Record)?.featureId === linkOnly.id)) + .toHaveLength(1); const duplicate = await m.addFeature(slice.id, { title: "Corrupt duplicate" }); await m.updateFeature(duplicate.id, { taskId: task.id }); @@ -682,6 +772,8 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { expect(await m.getSlice(slice.id)).toMatchObject({ status: "pending" }); expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "planning" }); expect(await h.store().getTask(task.id)).toMatchObject({ missionId: undefined, sliceId: undefined }); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events + .filter((event) => event.eventType === "feature_status_changed")).toHaveLength(0); }); it("addContractAssertion appears in listContractAssertions", async () => { @@ -807,6 +899,66 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { }); }); + it("audits validator reuse-pass status promotion and skips repeat admission", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Reuse pass audit" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const feature = await m.addFeature(slice.id, { title: "Validated feature" }); + const fingerprint = "reuse-pass-fingerprint"; + const run = await m.startValidatorRun(feature.id, "scheduled", undefined, fingerprint); + await m.completeValidatorRun(run.id, "passed", "passed"); + + await expect(m.admitValidatorRun(feature.id, { + inputFingerprint: fingerprint, + failureBudget: 1, + reusePass: true, + })).resolves.toMatchObject({ outcome: "reuse-pass", run: { id: run.id } }); + expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + eventType: "feature_status_changed", + metadata: expect.objectContaining({ featureId: feature.id, from: "defined", to: "done", source: "validator-reuse-pass" }), + }), + ])); + + await m.admitValidatorRun(feature.id, { inputFingerprint: fingerprint, failureBudget: 1, reusePass: true }); + expect((await m.getMissionEvents(mission.id, { limit: 20 })).events + .filter((event) => event.eventType === "feature_status_changed" && (event.metadata as Record)?.featureId === feature.id)) + .toHaveLength(1); + }); + + it("audits each changed superseded generated fix without expanding the bulk write", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Superseded fix audit" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const root = await m.addFeature(slice.id, { title: "Root" }); + const failedRun = await m.startValidatorRun(root.id, "scheduled"); + await m.completeValidatorRun(failedRun.id, "failed", "needs fix"); + const firstFix = await m.createGeneratedFixFeature(root.id, failedRun.id, [], "repair"); + const secondRoot = await m.addFeature(slice.id, { title: "Second root" }); + const secondRun = await m.startValidatorRun(secondRoot.id, "scheduled"); + await m.completeValidatorRun(secondRun.id, "failed", "needs fix"); + const secondFix = await m.createGeneratedFixFeature(secondRoot.id, secondRun.id, [], "repair"); + await m.updateFeature(root.id, { lastValidatorStatus: "passed", loopState: "passed" }); + await m.updateFeature(secondRoot.id, { lastValidatorStatus: "passed", loopState: "passed" }); + + await expect(m.reconcileSupersededGeneratedFixFeatures(slice.id)).resolves.toMatchObject({ + supersededCount: 2, + featureIds: expect.arrayContaining([firstFix.id, secondFix.id]), + }); + const audited = (await m.getMissionEvents(mission.id, { limit: 20 })).events + .filter((event) => event.eventType === "feature_status_changed" && (event.metadata as Record)?.source === "superseded-fix-reconcile"); + expect(audited).toHaveLength(2); + expect(audited.map((event) => (event.metadata as Record)?.featureId)) + .toEqual(expect.arrayContaining([firstFix.id, secondFix.id])); + + await expect(m.reconcileSupersededGeneratedFixFeatures(slice.id)).resolves.toMatchObject({ supersededCount: 0, featureIds: [] }); + expect((await m.getMissionEvents(mission.id, { limit: 20 })).events + .filter((event) => event.eventType === "feature_status_changed" && (event.metadata as Record)?.source === "superseded-fix-reconcile")) + .toHaveLength(2); + }); + it("runs the validator/fix lifecycle and reaps stale runs in PostgreSQL", async () => { /* FNXC:PostgresMissionRuntime 2026-07-14-17:23: diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index c53434cf95..12d0f891bf 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -14,7 +14,7 @@ import { EventEmitter } from "node:events"; import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm"; import * as schema from "../postgres/schema/index.js"; import type { AsyncDataLayer } from "../postgres/data-layer.js"; -import { FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause, selectNextSerialMissionSlice } from "../missions/mission-types.js"; +import { buildMissionStatusEventMetadata, FEATURE_LOOP_TRANSITIONS, normalizeMissionAssertionType, renderValidationCause, selectNextSerialMissionSlice } from "../missions/mission-types.js"; import type { Mission, Milestone, @@ -585,12 +585,12 @@ export class AsyncMissionStore extends EventEmitter { createdAt: mission.createdAt, updatedAt: new Date().toISOString(), }; - const transitions: Array<{ eventType: MissionEventType; description: string; metadata: Record }> = []; + const transitions: Array<{ eventType: MissionEventType; description: string; metadataInput: Parameters[0] }> = []; if (mission.status !== updated.status) { transitions.push({ eventType: "mission_status_changed", description: `Mission status changed from ${mission.status} to ${updated.status}`, - metadata: { source: actor.source, actor, field: "status", from: mission.status, to: updated.status }, + metadataInput: { entity: "mission", field: "status", from: mission.status, to: updated.status, ids: {}, actor, reason: options.reason }, }); } // FNXC:MissionAutonomyAudit 2026-07-23-14:20: Legacy rows may omit this @@ -602,16 +602,22 @@ export class AsyncMissionStore extends EventEmitter { transitions.push({ eventType: isAutopilotEnabled ? "autopilot_enabled" : "autopilot_disabled", description: `Autopilot ${isAutopilotEnabled ? "enabled" : "disabled"}`, - metadata: { source: actor.source, actor, field: "autopilotEnabled", from: wasAutopilotEnabled, to: isAutopilotEnabled }, + metadataInput: { entity: "mission", field: "autopilotEnabled", from: wasAutopilotEnabled, to: isAutopilotEnabled, ids: {}, actor }, }); } await updateMission(tx, updated); if (transitions.length === 0) return { updated, events: [] as MissionEvent[] }; + /* + FNXC:MissionStatusWrites 2026-08-10-12:47: + Like feature transitions, mission audit metadata is built only after the row write. The + builder is total, so malformed caller-shaped actor or reason data cannot abort this same + transaction after a legitimate lifecycle repair has been applied. + */ let seq = await getMaxEventSeq(tx); const events = await Promise.all(transitions.map(async (transition) => { const event: MissionEvent = { id: this.generateId("ME"), missionId: id, eventType: transition.eventType, - description: transition.description, metadata: transition.metadata, + description: transition.description, metadata: buildMissionStatusEventMetadata(transition.metadataInput), timestamp: new Date().toISOString(), seq: ++seq, }; await insertMissionEvent(tx, event); @@ -1101,31 +1107,67 @@ export class AsyncMissionStore extends EventEmitter { return getFeatureByTaskId(this.db, taskId); } - async updateFeature(id: string, updates: Partial): Promise { - const feature = await getFeature(this.db, id); - if (!feature) throw new Error(`Feature ${id} not found`); - const updated: MissionFeature = { - ...feature, - ...updates, - id, - sliceId: feature.sliceId, - createdAt: feature.createdAt, - updatedAt: new Date().toISOString(), + /* + FNXC:MissionStatusWrites 2026-08-10-12:47: + Status events share the feature mutation transaction. The metadata builder is total, so it is + safe after the row write; missing hierarchy skips auditing rather than blocking a repair. + */ + private async recordFeatureStatusChange(tx: QueryHandle, feature: MissionFeature, toStatus: FeatureStatus, actor?: MissionTransitionActor, reason?: unknown, seq?: number): Promise { + if (feature.status === toStatus) return undefined; + const slice = await getSlice(tx, feature.sliceId); + const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined; + const mission = milestone ? await getMission(tx, milestone.missionId) : undefined; + if (!mission) return undefined; + const event: MissionEvent = { + id: this.generateId("ME"), missionId: mission.id, eventType: "feature_status_changed", + description: `Feature ${feature.id} status changed from ${feature.status} to ${toStatus}`, + metadata: buildMissionStatusEventMetadata({ entity: "feature", field: "status", from: feature.status, to: toStatus, ids: { featureId: feature.id, sliceId: slice?.id }, actor, reason }), + timestamp: new Date().toISOString(), seq: seq ?? (await getMaxEventSeq(tx)) + 1, }; - await updateFeature(this.db, updated); + await insertMissionEvent(tx, event); + return event; + } + + /** + * Locks the feature before reading its status pre-image. A concurrent writer must observe the + * prior committed transition before it can write the next one, so every audit `from` is exact. + */ + private async getFeatureForStatusWrite(tx: QueryHandle, id: string): Promise { + const locked = await tx.select({ id: schema.project.missionFeatures.id }) + .from(schema.project.missionFeatures) + .where(eq(schema.project.missionFeatures.id, id)) + .for("update"); + return locked.length > 0 ? getFeature(tx, id) : undefined; + } + + async updateFeature(id: string, updates: Partial, options: MissionUpdateOptions = {}): Promise { + const { updated, event, taskIdChanged, statusChanged } = await this.layer.transactionImmediate(async (tx) => { + const feature = await this.getFeatureForStatusWrite(tx, id); + if (!feature) throw new Error(`Feature ${id} not found`); + const updated: MissionFeature = { ...feature, ...updates, id, sliceId: feature.sliceId, createdAt: feature.createdAt, updatedAt: new Date().toISOString() }; + await updateFeature(tx, updated); + const event = updates.status !== undefined ? await this.recordFeatureStatusChange(tx, feature, updates.status, options.actor, options.reason) : undefined; + // FNXC:MissionStatusWrites 2026-08-10-13:32: Preserve no-op PATCH behavior: + // unchanged optional fields must not trigger a post-commit rollup solely because present. + return { + updated, + event, + taskIdChanged: updates.taskId !== undefined && updates.taskId !== feature.taskId, + statusChanged: updates.status !== undefined && updates.status !== feature.status, + }; + }); this.emit("feature:updated", updated); - const taskIdChanged = updates.taskId !== undefined && updates.taskId !== feature.taskId; - const statusChanged = updates.status !== undefined && updates.status !== feature.status; + if (event) this.emit("mission:event", event); if (taskIdChanged || statusChanged) await this.recomputeSliceStatus(updated.sliceId); - const shouldSyncAssertion = - updates.title !== undefined || updates.description !== undefined || updates.acceptanceCriteria !== undefined; - if (shouldSyncAssertion) { - await this.ensureFeatureAssertion(updated); - return (await getFeature(this.db, updated.id)) ?? updated; - } + const shouldSyncAssertion = updates.title !== undefined || updates.description !== undefined || updates.acceptanceCriteria !== undefined; + if (shouldSyncAssertion) { await this.ensureFeatureAssertion(updated); return (await getFeature(this.db, updated.id)) ?? updated; } return updated; } + async updateFeatureStatus(featureId: string, status: FeatureStatus, options: MissionUpdateOptions = {}): Promise { + return this.updateFeature(featureId, { status }, options); + } + /* FNXC:WorkflowResolvedColumns 2026-07-30-12:50 (batch-core): "Is this linked task ARCHIVED?" for the two mission guards below, resolved from the task's own @@ -1184,13 +1226,6 @@ export class AsyncMissionStore extends EventEmitter { await this.recomputeSliceStatus(sliceId); } - async updateFeatureStatus(featureId: string, status: FeatureStatus): Promise { - const feature = await getFeature(this.db, featureId); - if (!feature) throw new Error(`Feature ${featureId} not found`); - const updated = await this.updateFeature(featureId, { status }); - await this.recomputeSliceStatus(updated.sliceId); - return updated; - } /** * FNXC:MissionReconciliation 2026-07-20-08:34: @@ -1198,7 +1233,7 @@ export class AsyncMissionStore extends EventEmitter { */ async reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise { const outcome = await this.layer.transactionImmediate(async (tx) => { - const feature = await getFeature(tx, featureId); + const feature = await this.getFeatureForStatusWrite(tx, featureId); if (!feature) { throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Feature ${featureId} not found`); } @@ -1266,6 +1301,9 @@ export class AsyncMissionStore extends EventEmitter { ? { ...feature, taskId, status: "done", updatedAt: now } : feature; if (featureChanged) await updateFeature(tx, reconciledFeature); + const event = feature.status !== "done" + ? await this.recordFeatureStatusChange(tx, feature, "done", { type: "system", id: "mission-store", source: "terminal-task-reconcile" }) + : undefined; if (evidence.kind === "done") { await setTaskMissionLinkage(tx, taskId, mission.id, slice.id); @@ -1285,12 +1323,14 @@ export class AsyncMissionStore extends EventEmitter { feature: reconciledFeature, featureChanged, linked: feature.taskId !== taskId, + event, slice: reconciledSlice !== slice ? reconciledSlice : undefined, milestone: reconciledMilestone !== milestone ? reconciledMilestone : undefined, }; }); if (outcome.featureChanged) this.emit("feature:updated", outcome.feature); + if (outcome.event) this.emit("mission:event", outcome.event); if (outcome.linked) this.emit("feature:linked", { feature: outcome.feature, taskId }); if (outcome.slice) this.emit("slice:updated", outcome.slice); if (outcome.milestone) this.emit("milestone:updated", outcome.milestone); @@ -1305,7 +1345,7 @@ export class AsyncMissionStore extends EventEmitter { */ async claimDefinedFeatureTaskInTransaction( tx: import("../postgres/data-layer.js").DbTransaction, - input: { featureId: string; taskId: string; missionId: string; sliceId: string; requireExistingFeatureLink?: boolean }, + input: { featureId: string; taskId: string; missionId: string; sliceId: string; requireExistingFeatureLink?: boolean; statusEvent?: { value?: MissionEvent } }, ): Promise { /* FNXC:MissionAdmission 2026-07-23-15:30: @@ -1376,6 +1416,8 @@ export class AsyncMissionStore extends EventEmitter { updatedAt: now, }; await updateFeature(tx, updated); + const event = await this.recordFeatureStatusChange(tx, feature, "triaged", { type: "system", id: "mission-store", source: "defined-feature-claim" }); + if (input.statusEvent) input.statusEvent.value = event; // The inserted task already carries this verified linkage; retain this write // for retry parity when the same canonical is claimed again. await setTaskMissionLinkage(tx, input.taskId, input.missionId, input.sliceId); @@ -1383,8 +1425,10 @@ export class AsyncMissionStore extends EventEmitter { } async claimDefinedFeatureTask(input: { featureId: string; taskId: string; missionId: string; sliceId: string }): Promise { - const feature = await this.layer.transactionImmediate((tx) => this.claimDefinedFeatureTaskInTransaction(tx, { ...input, requireExistingFeatureLink: true })); + const statusEvent: { value?: MissionEvent } = {}; + const feature = await this.layer.transactionImmediate((tx) => this.claimDefinedFeatureTaskInTransaction(tx, { ...input, requireExistingFeatureLink: true, statusEvent })); this.emit("feature:updated", feature); + if (statusEvent.value) this.emit("mission:event", statusEvent.value); this.emit("feature:linked", { feature, taskId: input.taskId }); await this.recomputeSliceStatus(feature.sliceId); return feature; @@ -1472,7 +1516,7 @@ export class AsyncMissionStore extends EventEmitter { existing taskId: retries may reuse only that same canonical task. */ const outcome = await this.layer.transactionImmediate(async (tx) => { - const feature = await getFeature(tx, featureId); + const feature = await this.getFeatureForStatusWrite(tx, featureId); if (!feature) throw new Error(`Feature ${featureId} not found`); if (feature.taskId && feature.taskId !== taskId) { throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`); @@ -1500,13 +1544,15 @@ export class AsyncMissionStore extends EventEmitter { updatedAt: now, }; await updateFeature(tx, updated); + const event = await this.recordFeatureStatusChange(tx, feature, "triaged", { type: "system", id: "mission-store", source: "mission-link" }); await setTaskMissionLinkage(tx, taskId, milestone.missionId, slice.id); - return updated; + return { feature: updated, event }; }); - this.emit("feature:updated", outcome); - this.emit("feature:linked", { feature: outcome, taskId }); - await this.recomputeSliceStatus(outcome.sliceId); - return outcome; + this.emit("feature:updated", outcome.feature); + if (outcome.event) this.emit("mission:event", outcome.event); + this.emit("feature:linked", { feature: outcome.feature, taskId }); + await this.recomputeSliceStatus(outcome.feature.sliceId); + return outcome.feature; } async unlinkFeatureFromTask(featureId: string): Promise { @@ -1560,7 +1606,8 @@ export class AsyncMissionStore extends EventEmitter { * holding a database transaction while a model session executes. */ async admitValidatorRun(featureId: string, input: ValidatorRunAdmissionInput): Promise { - return this.layer.transactionImmediate(async (tx) => { + let statusEvent: MissionEvent | undefined; + const admission = await this.layer.transactionImmediate(async (tx) => { const locked = await tx.select().from(schema.project.missionFeatures).where(and( eq(schema.project.missionFeatures.projectId, missionProjectId()), eq(schema.project.missionFeatures.id, featureId), @@ -1588,6 +1635,7 @@ export class AsyncMissionStore extends EventEmitter { if (running) { await append("running", running); return { outcome: "running", run: running }; } if (terminal?.status === "passed" && input.reusePass) { await updateFeature(tx, { ...feature, status: "done", loopState: "passed", lastValidatorStatus: "passed", lastValidatorRunId: terminal.id, updatedAt: new Date().toISOString() }); + statusEvent = await this.recordFeatureStatusChange(tx, feature, "done", { type: "system", id: "mission-store", source: "validator-reuse-pass" }); await append("reuse-pass", terminal); return { outcome: "reuse-pass", run: terminal }; } @@ -1609,6 +1657,10 @@ export class AsyncMissionStore extends EventEmitter { await updateFeature(tx, { ...feature, validatorAttemptCount: run.validatorAttempt, lastValidatorRunId: run.id, loopState: "validating", validationBudgetFingerprint: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetFingerprint, validationBudgetRunId: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetRunId, validationBudgetBlockedAt: feature.validationBudgetFingerprint !== input.inputFingerprint ? undefined : feature.validationBudgetBlockedAt, updatedAt: now }); return { outcome: "start", run }; }); + // FNXC:MissionStatusWrites 2026-08-10-13:45: Emit only after commit so observers never + // receive a transition for a transaction that subsequently rolls back. + if (statusEvent) this.emit("mission:event", statusEvent); + return admission; } async getValidatorRun(id: string): Promise { @@ -1901,20 +1953,48 @@ export class AsyncMissionStore extends EventEmitter { FNXC:PostgresMissionStatusReconciliation 2026-07-14-17:55: Superseded generated fixes are one reconciliation set. Update their terminal status in one statement instead of routing every ID through updateFeature/getFeature/cascade reads; emit the same per-feature observable events after persistence. */ - await this.db.update(schema.project.missionFeatures).set({ - status: "done", - taskId: null, - loopState: "passed", - lastValidatorStatus: "passed", - updatedAt: now, - }).where(inArray(schema.project.missionFeatures.id, ids)); - for (const id of ids) { - const feature = byId.get(id)!; + const { events, updatedFeatures } = await this.layer.transactionImmediate(async (tx) => { + /* + FNXC:MissionStatusWrites 2026-08-10-13:21: + The bulk reconciliation must lock and re-read its candidates inside this transaction. + Using the earlier discovery snapshot would let a concurrent link/status writer overwrite + a newer row and emit an event with a stale `from` status. + */ + const locked = await tx.select({ id: schema.project.missionFeatures.id }) + .from(schema.project.missionFeatures) + .where(inArray(schema.project.missionFeatures.id, ids)) + .for("update"); + const lockedIds = locked.map((row) => row.id); + const preImages = lockedIds.length > 0 ? await listFeaturesByIds(tx, lockedIds) : []; + const changed = preImages.filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed" || feature.taskId); + if (changed.length === 0) return { events: [] as MissionEvent[], updatedFeatures: [] as MissionFeature[] }; + + await tx.update(schema.project.missionFeatures).set({ + status: "done", + taskId: null, + loopState: "passed", + lastValidatorStatus: "passed", + updatedAt: now, + }).where(inArray(schema.project.missionFeatures.id, changed.map((feature) => feature.id))); + // One sequence read preserves contiguous ordering for the bulk statement without + // expanding its write into per-feature updates. + let seq = await getMaxEventSeq(tx); + const events: MissionEvent[] = []; + for (const feature of changed) { + if (feature.status !== "done") { + const event = await this.recordFeatureStatusChange(tx, feature, "done", { type: "system", id: "mission-store", source: "superseded-fix-reconcile" }, undefined, ++seq); + if (event) events.push(event); + } + } + return { events, updatedFeatures: changed }; + }); + for (const event of events) this.emit("mission:event", event); + for (const feature of updatedFeatures) { const updated = { ...feature, status: "done" as const, taskId: undefined, loopState: "passed" as const, lastValidatorStatus: "passed" as const, updatedAt: now }; this.emit("feature:updated", updated); if (feature.taskId) await clearTaskMissionLinkage(this.db, feature.taskId); } - await this.recomputeSliceStatus(sliceId); + if (updatedFeatures.length > 0) await this.recomputeSliceStatus(sliceId); } return { supersededCount: ids.length, featureIds: ids }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd781a03ea..dcf551b255 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1759,6 +1759,11 @@ export { VALIDATOR_RUN_STATUSES, VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION, VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES, + MISSION_EVENT_REASON_MAX_BYTES, + MISSION_EVENT_METADATA_MAX_BYTES, + boundMissionEventReason, + normalizeMissionTransitionActorForEvent, + buildMissionStatusEventMetadata, selectNextSerialMissionSlice, normalizeValidationDiagnostics, renderValidationFailureDescription, diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index 2fe5fa63a9..be0cc837c9 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -2280,7 +2280,8 @@ export class MissionStore extends EventEmitter { * @returns The updated feature * @throws Error if feature not found */ - updateFeature(id: string, updates: Partial): MissionFeature { + /* FNXC:MissionStatusWrites 2026-08-10-12:47: The SQLite store is a non-production legacy mirror; retain options signature parity without adding a second audit implementation. */ + updateFeature(id: string, updates: Partial, _options: MissionUpdateOptions = {}): MissionFeature { const feature = this.getFeature(id); if (!feature) { throw new Error(`Feature ${id} not found`); @@ -2759,13 +2760,13 @@ export class MissionStore extends EventEmitter { * @returns The updated feature * @throws Error if feature not found */ - updateFeatureStatus(featureId: string, status: FeatureStatus): MissionFeature { + updateFeatureStatus(featureId: string, status: FeatureStatus, options: MissionUpdateOptions = {}): MissionFeature { const feature = this.getFeature(featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } - const updated = this.updateFeature(featureId, { status }); + const updated = this.updateFeature(featureId, { status }, options); // Recompute slice status this.recomputeSliceStatus(updated.sliceId); diff --git a/packages/core/src/missions/mission-types.ts b/packages/core/src/missions/mission-types.ts index 2e0062b0a1..1336f37862 100644 --- a/packages/core/src/missions/mission-types.ts +++ b/packages/core/src/missions/mission-types.ts @@ -63,6 +63,8 @@ export type ValidatorRunStatus = (typeof VALIDATOR_RUN_STATUSES)[number]; */ export const VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION = 16; export const VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES = 4096; +export const MISSION_EVENT_REASON_MAX_BYTES = 512; +export const MISSION_EVENT_METADATA_MAX_BYTES = 2048; export type ValidationAssertionVerdict = "pass" | "fail" | "blocked"; @@ -107,10 +109,16 @@ export interface ValidationDiagnosticsInput { projectRoot?: string; } -function boundValidationText(value: unknown, projectRoot?: string): { value?: string; truncated?: boolean } { +/* +FNXC:MissionStatusWrites 2026-08-10-12:47: +Mission status audit metadata is constructed at the store boundary because agent reasons and +identity strings are untrusted. The total builder runs after the row write in its transaction, +so a malformed caller value cannot roll back a legitimate status repair. +*/ +function boundMissionText(value: unknown, limit: number, projectRoot?: string): { value?: string; truncated?: boolean } { if (typeof value !== "string") return {}; - let text = redactSecrets(value); - // Paths from the project are useful evidence; disposable/external paths are not. + let text: string; + try { text = redactSecrets(value); } catch { return {}; } text = text.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`]+/g, (path) => { const normalizedRoot = projectRoot?.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedPath = path.replace(/\\/g, "/"); @@ -119,20 +127,88 @@ function boundValidationText(value: unknown, projectRoot?: string): { value?: st : "[external path omitted]"; }); const bytes = Buffer.byteLength(text, "utf8"); - if (bytes <= VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES) return { value: text }; + if (bytes <= limit) return { value: text }; const marker = "… [truncated]"; - const limit = VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES - Buffer.byteLength(marker, "utf8"); - let end = 0; - let used = 0; + const remaining = limit - Buffer.byteLength(marker, "utf8"); + let end = 0; let used = 0; for (const character of text) { const size = Buffer.byteLength(character, "utf8"); - if (used + size > limit) break; - used += size; - end += character.length; + if (used + size > remaining) break; + used += size; end += character.length; } return { value: `${text.slice(0, end)}${marker}`, truncated: true }; } +function boundValidationText(value: unknown, projectRoot?: string): { value?: string; truncated?: boolean } { + return boundMissionText(value, VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES, projectRoot); +} +/** Bounds untrusted agent/operator prose before it enters a mission-event row. */ +export function boundMissionEventReason(reason: unknown): { value?: string; truncated?: boolean } { + // Check semantic emptiness before adding the truncation marker: whitespace-only + // input must not become audit prose merely because it exceeds the byte limit. + if (typeof reason !== "string" || !reason.trim()) return {}; + return boundMissionText(reason, MISSION_EVENT_REASON_MAX_BYTES); +} + +export function normalizeMissionTransitionActorForEvent(actor: unknown): { type: MissionTransitionActorType; id: string; source: string } { + try { + const candidate = actor && typeof actor === "object" ? actor as Record : {}; + const type = MISSION_TRANSITION_ACTOR_TYPES.includes(candidate.type as MissionTransitionActorType) ? candidate.type as MissionTransitionActorType : "system"; + const id = boundMissionText(typeof candidate.id === "string" ? candidate.id : "mission-store", 200).value || "mission-store"; + const source = boundMissionText(typeof candidate.source === "string" ? candidate.source : "mission-store", 200).value || "mission-store"; + return { type, id, source }; + } catch { return { type: "system", id: "mission-store", source: "mission-store" }; } +} + +/** Builds the only persisted metadata shape for mission and feature transitions. */ +export function buildMissionStatusEventMetadata(input: { entity: "feature" | "mission"; field: string; from: unknown; to: unknown; ids: Record; actor: unknown; reason?: unknown }): Record { + /* + FNXC:MissionStatusWrites 2026-08-10-13:04: + This builder executes after a status row changes but before its transaction commits. It must + tolerate hostile values and bound even its required fields, so audit metadata can never roll + back a legitimate repair or exceed the event-row budget. + */ + const fallbackActor = { type: "system" as const, id: "mission-store", source: "mission-store" }; + const safeText = (value: unknown, fallback: string): string => { + try { return boundMissionText(typeof value === "string" ? value : fallback, 200).value || fallback; } catch { return fallback; } + }; + const safePrimitive = (value: unknown): string | number | boolean | null => { + try { + if (typeof value === "string") return safeText(value, ""); + if (typeof value === "number" || typeof value === "boolean" || value === null) return value; + return safeText(String(value ?? ""), ""); + } catch { return ""; } + }; + try { + const actor = normalizeMissionTransitionActorForEvent(input?.actor); + const minimal: Record = { + source: actor.source, + actor, + field: safeText(input?.field, "status"), + from: safePrimitive(input?.from), + to: safePrimitive(input?.to), + }; + const ids = Object.fromEntries(Object.entries(input?.ids ?? {}).flatMap(([key, value]) => + value === undefined ? [] : [[safeText(key, "id"), safeText(value, "mission-store")]], + )); + const reason = boundMissionEventReason(input?.reason); + const result: Record = { + ...minimal, + ...ids, + ...(reason.value !== undefined ? { reason: reason.value } : {}), + ...(reason.truncated ? { reasonTruncated: true } : {}), + }; + const fits = () => { + try { return Buffer.byteLength(JSON.stringify(result), "utf8") <= MISSION_EVENT_METADATA_MAX_BYTES; } catch { return false; } + }; + if (!fits()) { delete result.reason; result.metadataTrimmed = true; } + if (!fits()) { delete result.reasonTruncated; result.metadataTrimmed = true; } + if (!fits()) return { ...minimal, metadataTrimmed: true }; + return result; + } catch { + return { source: fallbackActor.source, actor: fallbackActor, field: "status", from: "", to: "", metadataTrimmed: true }; + } +} /** Normalize and redact validation evidence before any mission artifact persists it. */ export function normalizeValidationDiagnostics(input: ValidationDiagnosticsInput): ValidationDiagnostics { return { @@ -208,6 +284,7 @@ export const MISSION_EVENT_TYPES = [ "mission_completed", "mission_started", "mission_status_changed", + "feature_status_changed", "mission_paused", "mission_resumed", "autopilot_enabled", @@ -239,6 +316,8 @@ export interface MissionTransitionActor { /** Optional attribution supplied to a mission mutation that can arm autonomy. */ export interface MissionUpdateOptions { actor?: MissionTransitionActor; + /** Untrusted caller prose; the store redacts and bounds it before persistence. */ + reason?: string; } /** Autopilot status for a mission */ diff --git a/packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts b/packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts index 816edd7356..2fef4b2a89 100644 --- a/packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts +++ b/packages/dashboard/src/__tests__/chat-toolset-permissions.test.ts @@ -73,6 +73,30 @@ describe("createChatFusionToolset — permission-parity regression", () => { } }); + it("keeps mission status mutations behind missionMutationGated", async () => { + const taskStore = baseTaskStore(); + const ungated = await createChatFusionToolset({ + taskStore, + agentStore: baseAgentStore, + rootDir: "/project", + agentId: "agent-abc", + missionMutationGated: false, + }); + const gated = await createChatFusionToolset({ + taskStore, + agentStore: baseAgentStore, + rootDir: "/project", + agentId: "agent-abc", + missionMutationGated: true, + }); + const ungatedNames = new Set(ungated.map((tool) => tool.name)); + const gatedNames = new Set(gated.map((tool) => tool.name)); + for (const name of ["fn_feature_set_status", "fn_mission_set_status"]) { + expect(ungatedNames.has(name), `mission mutation leaked without gate: ${name}`).toBe(false); + expect(gatedNames.has(name), `missing gated mission mutation: ${name}`).toBe(true); + } + }); + it("never binds ambient-task tools in project-scoped chat (no ambient task id)", async () => { const taskStore = baseTaskStore(); for (const gate of [undefined, {} as any]) { diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index bfd41627ef..e9e519ae8a 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -2716,7 +2716,8 @@ export function createMissionRouter( } try { - const feature = await missionStore.updateFeature(featureId, updates); + /* FNXC:MissionStatusWrites 2026-08-10-12:47: Operator and agent repairs share the attributed status-event contract consumed by mission reconciliation. */ + const feature = await missionStore.updateFeature(featureId, updates, { actor: DASHBOARD_MISSION_ACTOR }); res.json(feature); } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts index aa477a84a3..5d10e80884 100644 --- a/packages/engine/src/__tests__/agent-mission-tools.test.ts +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -10,12 +10,50 @@ describe("createMissionTools", () => { it("exposes the complete hierarchy surface with read and mutation names", () => { const store = { getMissionStore: vi.fn() } as never; expect(createMissionTools(store).map((tool) => tool.name)).toEqual([ - "fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_delete", + "fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_set_status", "fn_mission_delete", "fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate", - "fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding", + "fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_set_status", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding", ]); }); + it("sets a linked feature status with attributed raw reason", async () => { + const updateFeatureStatus = vi.fn().mockResolvedValue({ id: "F-1", status: "done", taskId: "FN-1" }); + const store = { getMissionStore: () => ({ getFeature: vi.fn().mockResolvedValue({ id: "F-1", taskId: "FN-1", status: "defined" }), updateFeatureStatus }) } as never; + const tool = createMissionTools(store, { agentId: "agent-1" }).find((candidate) => candidate.name === "fn_feature_set_status")!; + await tool.execute("call", { id: "F-1", status: "done", reason: "raw reason" }); + expect(updateFeatureStatus).toHaveBeenCalledWith("F-1", "done", expect.objectContaining({ reason: "raw reason", actor: expect.objectContaining({ type: "agent", id: "agent-1" }) })); + }); + + it("rejects an unlinked feature execution status", async () => { + const updateFeatureStatus = vi.fn(); + const store = { getMissionStore: () => ({ getFeature: vi.fn().mockResolvedValue({ id: "F-1", status: "defined" }), updateFeatureStatus }) } as never; + const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_feature_set_status")!; + const result = await tool.execute("call", { id: "F-1", status: "done" }); + expect(result.isError).toBe(true); expect(result.content[0].text).toContain("linked task"); expect(updateFeatureStatus).not.toHaveBeenCalled(); + }); + + it("rejects invalid feature and mission statuses before reaching the store", async () => { + const updateFeatureStatus = vi.fn(); + const updateMission = vi.fn(); + const store = { getMissionStore: () => ({ getFeature: vi.fn(), updateFeatureStatus, updateMission }) } as never; + const featureTool = createMissionTools(store).find((candidate) => candidate.name === "fn_feature_set_status")!; + const missionTool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_set_status")!; + await expect(featureTool.execute("call", { id: "F-1", status: "invalid" })).resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining("Invalid status. Must be one of:") }] }); + await expect(missionTool.execute("call", { id: "M-1", status: "invalid" })).resolves.toMatchObject({ isError: true, content: [{ text: expect.stringContaining("Invalid status. Must be one of:") }] }); + expect(updateFeatureStatus).not.toHaveBeenCalled(); + expect(updateMission).not.toHaveBeenCalled(); + }); + + it("delegates mission status through updateMission with the attributed raw reason", async () => { + const updateMission = vi.fn().mockResolvedValue({ id: "M-1", status: "blocked" }); + const store = { getMissionStore: () => ({ updateMission }) } as never; + const tool = createMissionTools(store, { agentId: "agent-1" }).find((candidate) => candidate.name === "fn_mission_set_status")!; + await tool.execute("call", { id: "M-1", status: "blocked", reason: "raw reason" }); + expect(updateMission).toHaveBeenCalledWith("M-1", { status: "blocked" }, expect.objectContaining({ + reason: "raw reason", actor: expect.objectContaining({ type: "agent", id: "agent-1" }), + })); + }); + it("delegates feature linkage to MissionStore without a second task update", async () => { const linkFeatureToTask = vi.fn().mockResolvedValue({ id: "F-1", taskId: "FN-1", status: "triaged" }); const store = { getMissionStore: () => ({ linkFeatureToTask }) } as never; diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index e81399b680..337c2409f7 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -3388,7 +3388,7 @@ describe("executeHeartbeat", () => { */ // fn_artifact_register/list/view, agent config/provisioning, mission hierarchy, ideation, goals/evaluations/identity, // task read discovery (incl. logs_read), workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. - expect(callArgs.customTools).toHaveLength(64); + expect(callArgs.customTools).toHaveLength(66); expect(callArgs.customTools!.map((tool) => tool.name)).toEqual([ "fn_task_create", "fn_task_log", @@ -3409,6 +3409,7 @@ describe("executeHeartbeat", () => { "fn_mission_show", "fn_mission_create", "fn_mission_update", + "fn_mission_set_status", "fn_mission_delete", "fn_milestone_add", "fn_milestone_update", @@ -3418,6 +3419,7 @@ describe("executeHeartbeat", () => { "fn_slice_delete", "fn_feature_add", "fn_feature_update", + "fn_feature_set_status", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding", diff --git a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts index 4ff20cf78c..80bcffd8b5 100644 --- a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts +++ b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts @@ -43,6 +43,7 @@ describe("workflow-step readonly allowlist policy", () => { "fn_mission_create", "fn_mission_delete", "fn_mission_update", + "fn_mission_set_status", "fn_mission_backfill_assertions", "fn_milestone_add", "fn_slice_add", @@ -53,6 +54,7 @@ describe("workflow-step readonly allowlist policy", () => { "fn_slice_activate", "fn_feature_link_task", "fn_feature_update", + "fn_feature_set_status", "fn_milestone_update", "fn_agent_stop", "fn_agent_start", diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 8d868a514d..50f1928817 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -4376,6 +4376,7 @@ export const missionCreateParams = Type.Object({ }); export const missionUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()) }); export const missionDeleteParams = Type.Object({ id: Type.String() }); +export const missionSetStatusParams = Type.Object({ id: Type.String(), status: Type.Union(fusionCore.MISSION_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) }); export const milestoneAddParams = Type.Object({ missionId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()) }); export const milestoneUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) }); export const milestoneDeleteParams = Type.Object({ milestoneId: Type.String(), force: Type.Optional(Type.Boolean()) }); @@ -4385,6 +4386,7 @@ export const sliceDeleteParams = Type.Object({ sliceId: Type.String(), force: Ty export const featureAddParams = Type.Object({ sliceId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) }); export const featureUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) }); export const featureDeleteParams = Type.Object({ featureId: Type.String(), force: Type.Optional(Type.Boolean()) }); +export const featureSetStatusParams = Type.Object({ id: Type.String(), status: Type.Union(fusionCore.FEATURE_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) }); export const featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() }); export const researchFindingPromoteParams = Type.Object({ runId: Type.String(), @@ -4516,6 +4518,11 @@ export function createMissionTools(store: TaskStore, context: MissionToolActorCo tool("fn_mission_show", "Show Mission", "Show a mission with its full milestone, slice, and feature hierarchy.", missionShowParams, async ({ id }) => { const mission = await store.getMissionStore().getMissionWithHierarchy(id); return mission ? missionToolResult(formatMissionHierarchy(mission), { mission }) : missionToolResult(`Mission ${id} not found`, { code: "MISSION_NOT_FOUND", missionId: id }, true); }), tool("fn_mission_create", "Create Mission", "Create a high-level mission.", missionCreateParams, async (p) => { const ms = store.getMissionStore(); const mission = await ms.createMission({ title: p.title.trim(), description: optionalText(p.description), baseBranch: optionalText(p.baseBranch) }); const updated = p.autoAdvance === undefined ? mission : await ms.updateMission(mission.id, { autoAdvance: p.autoAdvance }, { actor }); return missionToolResult(`Created ${updated.id}: ${updated.title}`, { mission: updated }); }), tool("fn_mission_update", "Update Mission", "Partially update a mission.", missionUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const mission = await store.getMissionStore().updateMission(p.id, updates, { actor }); return missionToolResult(`Updated ${mission.id}: ${mission.title}`, { mission }); }), + tool("fn_mission_set_status", "Set Mission Status", "Set a mission lifecycle status.", missionSetStatusParams, async (p) => { + if (!fusionCore.MISSION_STATUSES.includes(p.status)) return missionToolResult(`Invalid status. Must be one of: ${fusionCore.MISSION_STATUSES.join(", ")}`, {}, true); + const mission = await store.getMissionStore().updateMission(p.id, { status: p.status }, { actor, reason: p.reason }); + return missionToolResult(`Set ${mission.id} status to ${mission.status}`, { mission }); + }), tool("fn_mission_delete", "Delete Mission", "Delete a mission and its hierarchy.", missionDeleteParams, async ({ id }) => { await store.getMissionStore().deleteMission(id); return missionToolResult(`Deleted ${id}`, { missionId: id }); }), tool("fn_milestone_add", "Add Milestone", "Add a milestone to a mission.", milestoneAddParams, async (p) => { const milestone = await store.getMissionStore().addMilestone(p.missionId, { title: p.title.trim(), description: optionalText(p.description) }); return missionToolResult(`Added ${milestone.id}`, { milestone }); }), tool("fn_milestone_update", "Update Milestone", "Partially update a milestone.", milestoneUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const milestone = await store.getMissionStore().updateMilestone(p.id, updates); return missionToolResult(`Updated ${milestone.id}`, { milestone }); }), @@ -4525,6 +4532,17 @@ export function createMissionTools(store: TaskStore, context: MissionToolActorCo tool("fn_slice_delete", "Delete Slice", "Delete a slice and descendants.", sliceDeleteParams, async (p) => { await store.getMissionStore().deleteSlice(p.sliceId, p.force === true); return missionToolResult(`Deleted ${p.sliceId}`, { sliceId: p.sliceId }); }), tool("fn_feature_add", "Add Feature", "Add a feature to a slice.", featureAddParams, async (p) => { const feature = await store.getMissionStore().addFeature(p.sliceId, { title: p.title.trim(), description: optionalText(p.description), acceptanceCriteria: optionalText(p.acceptanceCriteria) }); return missionToolResult(`Added ${feature.id}`, { feature }); }), tool("fn_feature_update", "Update Feature", "Partially update a feature.", featureUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const feature = await store.getMissionStore().updateFeature(p.id, updates); return missionToolResult(`Updated ${feature.id}`, { feature }); }), + /* FNXC:MissionStatusWrites 2026-08-10-12:47: Dedicated status tools preserve the linked-task guard; generic partial updates intentionally cannot bypass it. */ + tool("fn_feature_set_status", "Set Feature Status", "Set a feature lifecycle status.", featureSetStatusParams, async (p) => { + if (!fusionCore.FEATURE_STATUSES.includes(p.status)) return missionToolResult(`Invalid status. Must be one of: ${fusionCore.FEATURE_STATUSES.join(", ")}`, {}, true); + const missionStore = store.getMissionStore(); const feature = await missionStore.getFeature(p.id); + if (!feature) return missionToolResult(`Feature ${p.id} not found`, { code: "FEATURE_NOT_FOUND", featureId: p.id }, true); + if ((["triaged", "in-progress", "done", "blocked"] as const).includes(p.status) && !feature.taskId) { + return missionToolResult(`Cannot set status to '${p.status}' without a linked task. Use the triage endpoint to create and link a task first, or link an existing task via fn_feature_link_task.`, { error: "FEATURE_TASK_REQUIRED" }, true); + } + const updated = await missionStore.updateFeatureStatus(p.id, p.status, { actor, reason: p.reason }); + return missionToolResult(`Set ${updated.id} status to ${updated.status}`, { feature: updated }); + }), tool("fn_feature_delete", "Delete Feature", "Delete a feature, respecting linked-task guards.", featureDeleteParams, async (p) => { await store.getMissionStore().deleteFeature(p.featureId, p.force ===true); return missionToolResult(`Deleted ${p.featureId}`, { featureId: p.featureId }); }), tool("fn_feature_link_task", "Link Feature to Task", "Link a feature to a live project-scoped task.", featureLinkTaskParams, async (p) => { const feature = await store.getMissionStore().linkFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Linked ${feature.id} to ${p.taskId}`, { feature }); }), tool("fn_research_promote_finding", "Promote Research Finding", "Promote a completed research finding into a canonical mission feature.", researchFindingPromoteParams, async (p) => { diff --git a/packages/engine/src/execution/gating-classifications.ts b/packages/engine/src/execution/gating-classifications.ts index 039411d121..432fb2111f 100644 --- a/packages/engine/src/execution/gating-classifications.ts +++ b/packages/engine/src/execution/gating-classifications.ts @@ -94,6 +94,7 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [ "fn_mission_create", "fn_mission_delete", "fn_mission_update", + "fn_mission_set_status", "fn_mission_backfill_assertions", "fn_milestone_add", "fn_slice_add", @@ -104,6 +105,7 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [ "fn_slice_activate", "fn_feature_link_task", "fn_feature_update", + "fn_feature_set_status", "fn_milestone_update", /* FNXC:Ideation 2026-07-30-15:30: Persisted divergence/convergence writes require both action and permanent-agent policy recognition. */ "fn_ideation_start",