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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8946-mission-status-writes.md
Normal file
7
.changeset/fn-8946-mission-status-writes.md
Normal file
@@ -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.
|
||||||
@@ -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_unlink_goal` | Idempotently unlink a goal from a mission, including archived goals. |
|
||||||
| `fn_mission_delete` | Delete a mission and its hierarchy. |
|
| `fn_mission_delete` | Delete a mission and its hierarchy. |
|
||||||
| `fn_mission_update` | Update mission title/description using partial patches. |
|
| `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_add` | Add a milestone to a mission. |
|
||||||
| `fn_milestone_update` | Update milestone fields using partial patches. |
|
| `fn_milestone_update` | Update milestone fields using partial patches. |
|
||||||
| `fn_slice_add` | Add a slice to a milestone. |
|
| `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_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_delete` | Delete a feature (with linked-task guard and optional `force`). |
|
||||||
| `fn_feature_update` | Update feature fields using partial patches. |
|
| `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_feature_link_task` | Link a feature to a task for implementation. |
|
||||||
| `fn_milestone_delete` | Delete a milestone (with linked-task guard and optional `force`). |
|
| `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
|
## 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.
|
`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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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`
|
- **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`
|
- **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`
|
- **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`
|
- **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`
|
- **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`
|
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||||
|
|||||||
@@ -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) |
|
| `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
|
### fn_mission_update
|
||||||
|
|
||||||
Update an existing mission's title or description. Partial patches leave untouched fields intact.
|
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) |
|
| `featureId` | string | ✓ | Feature ID to link (e.g., F-001) |
|
||||||
| `taskId` | string | ✓ | Task ID to link to (e.g., FN-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
|
### fn_feature_update
|
||||||
|
|
||||||
Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact.
|
Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact.
|
||||||
|
|||||||
@@ -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_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_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_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_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_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. |
|
| `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_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_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_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_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_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. |
|
| `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. |
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
|||||||
"fn_mission_backfill_assertions",
|
"fn_mission_backfill_assertions",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
|
"fn_mission_set_status",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_slice_add",
|
"fn_slice_add",
|
||||||
"fn_feature_add",
|
"fn_feature_add",
|
||||||
@@ -305,6 +306,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
|||||||
"fn_slice_activate",
|
"fn_slice_activate",
|
||||||
"fn_feature_link_task",
|
"fn_feature_link_task",
|
||||||
"fn_feature_update",
|
"fn_feature_update",
|
||||||
|
"fn_feature_set_status",
|
||||||
"fn_milestone_update",
|
"fn_milestone_update",
|
||||||
"fn_agent_stop",
|
"fn_agent_stop",
|
||||||
"fn_agent_start",
|
"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);
|
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", () => {
|
describe("fn_task_list", () => {
|
||||||
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
|
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
|
||||||
|
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────
|
// ── fn_mission_update ───────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
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 ─────────────────────────────────────────────
|
// ── fn_feature_update ─────────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|||||||
@@ -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<string, unknown> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import { eq, sql } from "drizzle-orm";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import type { DbTransaction } from "../../postgres/data-layer.js";
|
import type { DbTransaction } from "../../postgres/data-layer.js";
|
||||||
import type { TaskCreateInput } from "../../types/task/task-core.js";
|
import type { TaskCreateInput } from "../../types/task/task-core.js";
|
||||||
|
import type { MissionEvent } from "../../missions/mission-types.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
pgDescribe,
|
pgDescribe,
|
||||||
@@ -44,6 +45,8 @@ const pgTest = pgDescribe;
|
|||||||
pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
||||||
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||||
prefix: "fusion_mission_store",
|
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);
|
beforeAll(h.beforeAll);
|
||||||
@@ -145,15 +148,43 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
|||||||
"autopilot_disabled", "mission_status_changed", "autopilot_enabled", "mission_status_changed",
|
"autopilot_disabled", "mission_status_changed", "autopilot_enabled", "mission_status_changed",
|
||||||
]);
|
]);
|
||||||
const statusEvents = events.filter((event) => event.eventType === "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(statusEvents.map((event) => event.metadata)).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ field: "status", from: "planning", to: "active", 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 }),
|
expect.objectContaining({ field: "status", from: "active", to: "blocked", source: "dashboard", actor: persistedActor }),
|
||||||
]));
|
]));
|
||||||
const autopilotEvents = events.filter((event) => event.eventType.startsWith("autopilot_"));
|
const autopilotEvents = events.filter((event) => event.eventType.startsWith("autopilot_"));
|
||||||
expect(autopilotEvents.map((event) => event.metadata)).toEqual(expect.arrayContaining([
|
expect(autopilotEvents.map((event) => event.metadata)).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ field: "autopilotEnabled", from: false, to: true, actor }),
|
expect.objectContaining({ field: "autopilotEnabled", from: false, to: true, actor: persistedActor }),
|
||||||
expect.objectContaining({ field: "autopilotEnabled", from: true, to: false, actor }),
|
expect.objectContaining({ field: "autopilotEnabled", from: true, to: false, actor: persistedActor }),
|
||||||
]));
|
]));
|
||||||
|
expect(events.every((event) => !(event.metadata?.actor as Record<string, unknown> | 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<string, unknown>)?.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 () => {
|
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 slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||||
const feature = await m.addFeature(slice.id, { title: "F" });
|
const feature = await m.addFeature(slice.id, { title: "F" });
|
||||||
const task = await h.store().createTask({ description: "delivery task" });
|
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);
|
const linked = await m.linkFeatureToTask(feature.id, task.id);
|
||||||
expect(linked.taskId).toBe(task.id);
|
expect(linked.taskId).toBe(task.id);
|
||||||
expect(linked.status).toBe("triaged");
|
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);
|
const unlinked = await m.unlinkFeatureFromTask(feature.id);
|
||||||
expect(unlinked.taskId).toBeUndefined();
|
expect(unlinked.taskId).toBeUndefined();
|
||||||
expect(unlinked.status).toBe("defined");
|
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<void> });
|
||||||
|
|
||||||
|
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 () => {
|
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 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().getTask(task.id)).toMatchObject({ missionId: mission.id, sliceId: slice.id, column: "done" });
|
||||||
expect((await h.store().listTasks()).length).toBe(taskCount);
|
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 firstUpdatedAt = reconciled.updatedAt;
|
||||||
const idempotent = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
|
const idempotent = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
|
||||||
expect(idempotent.updatedAt).toBe(firstUpdatedAt);
|
expect(idempotent.updatedAt).toBe(firstUpdatedAt);
|
||||||
expect(idempotent).toEqual(reconciled);
|
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<string, unknown>)?.featureId === linkOnly.id))
|
||||||
|
.toHaveLength(1);
|
||||||
|
|
||||||
const duplicate = await m.addFeature(slice.id, { title: "Corrupt duplicate" });
|
const duplicate = await m.addFeature(slice.id, { title: "Corrupt duplicate" });
|
||||||
await m.updateFeature(duplicate.id, { taskId: task.id });
|
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.getSlice(slice.id)).toMatchObject({ status: "pending" });
|
||||||
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "planning" });
|
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "planning" });
|
||||||
expect(await h.store().getTask(task.id)).toMatchObject({ missionId: undefined, sliceId: undefined });
|
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 () => {
|
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<string, unknown>)?.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<string, unknown>)?.source === "superseded-fix-reconcile");
|
||||||
|
expect(audited).toHaveLength(2);
|
||||||
|
expect(audited.map((event) => (event.metadata as Record<string, unknown>)?.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<string, unknown>)?.source === "superseded-fix-reconcile"))
|
||||||
|
.toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("runs the validator/fix lifecycle and reaps stale runs in PostgreSQL", async () => {
|
it("runs the validator/fix lifecycle and reaps stale runs in PostgreSQL", async () => {
|
||||||
/*
|
/*
|
||||||
FNXC:PostgresMissionRuntime 2026-07-14-17:23:
|
FNXC:PostgresMissionRuntime 2026-07-14-17:23:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { EventEmitter } from "node:events";
|
|||||||
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
||||||
import * as schema from "../postgres/schema/index.js";
|
import * as schema from "../postgres/schema/index.js";
|
||||||
import type { AsyncDataLayer } from "../postgres/data-layer.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 {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
Milestone,
|
Milestone,
|
||||||
@@ -585,12 +585,12 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
createdAt: mission.createdAt,
|
createdAt: mission.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const transitions: Array<{ eventType: MissionEventType; description: string; metadata: Record<string, unknown> }> = [];
|
const transitions: Array<{ eventType: MissionEventType; description: string; metadataInput: Parameters<typeof buildMissionStatusEventMetadata>[0] }> = [];
|
||||||
if (mission.status !== updated.status) {
|
if (mission.status !== updated.status) {
|
||||||
transitions.push({
|
transitions.push({
|
||||||
eventType: "mission_status_changed",
|
eventType: "mission_status_changed",
|
||||||
description: `Mission status changed from ${mission.status} to ${updated.status}`,
|
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
|
// FNXC:MissionAutonomyAudit 2026-07-23-14:20: Legacy rows may omit this
|
||||||
@@ -602,16 +602,22 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
transitions.push({
|
transitions.push({
|
||||||
eventType: isAutopilotEnabled ? "autopilot_enabled" : "autopilot_disabled",
|
eventType: isAutopilotEnabled ? "autopilot_enabled" : "autopilot_disabled",
|
||||||
description: `Autopilot ${isAutopilotEnabled ? "enabled" : "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);
|
await updateMission(tx, updated);
|
||||||
if (transitions.length === 0) return { updated, events: [] as MissionEvent[] };
|
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);
|
let seq = await getMaxEventSeq(tx);
|
||||||
const events = await Promise.all(transitions.map(async (transition) => {
|
const events = await Promise.all(transitions.map(async (transition) => {
|
||||||
const event: MissionEvent = {
|
const event: MissionEvent = {
|
||||||
id: this.generateId("ME"), missionId: id, eventType: transition.eventType,
|
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,
|
timestamp: new Date().toISOString(), seq: ++seq,
|
||||||
};
|
};
|
||||||
await insertMissionEvent(tx, event);
|
await insertMissionEvent(tx, event);
|
||||||
@@ -1101,31 +1107,67 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return getFeatureByTaskId(this.db, taskId);
|
return getFeatureByTaskId(this.db, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFeature(id: string, updates: Partial<MissionFeature>): Promise<MissionFeature> {
|
/*
|
||||||
const feature = await getFeature(this.db, id);
|
FNXC:MissionStatusWrites 2026-08-10-12:47:
|
||||||
if (!feature) throw new Error(`Feature ${id} not found`);
|
Status events share the feature mutation transaction. The metadata builder is total, so it is
|
||||||
const updated: MissionFeature = {
|
safe after the row write; missing hierarchy skips auditing rather than blocking a repair.
|
||||||
...feature,
|
*/
|
||||||
...updates,
|
private async recordFeatureStatusChange(tx: QueryHandle, feature: MissionFeature, toStatus: FeatureStatus, actor?: MissionTransitionActor, reason?: unknown, seq?: number): Promise<MissionEvent | undefined> {
|
||||||
id,
|
if (feature.status === toStatus) return undefined;
|
||||||
sliceId: feature.sliceId,
|
const slice = await getSlice(tx, feature.sliceId);
|
||||||
createdAt: feature.createdAt,
|
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
|
||||||
updatedAt: new Date().toISOString(),
|
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<MissionFeature | undefined> {
|
||||||
|
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<MissionFeature>, options: MissionUpdateOptions = {}): Promise<MissionFeature> {
|
||||||
|
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);
|
this.emit("feature:updated", updated);
|
||||||
const taskIdChanged = updates.taskId !== undefined && updates.taskId !== feature.taskId;
|
if (event) this.emit("mission:event", event);
|
||||||
const statusChanged = updates.status !== undefined && updates.status !== feature.status;
|
|
||||||
if (taskIdChanged || statusChanged) await this.recomputeSliceStatus(updated.sliceId);
|
if (taskIdChanged || statusChanged) await this.recomputeSliceStatus(updated.sliceId);
|
||||||
const shouldSyncAssertion =
|
const shouldSyncAssertion = updates.title !== undefined || updates.description !== undefined || updates.acceptanceCriteria !== undefined;
|
||||||
updates.title !== undefined || updates.description !== undefined || updates.acceptanceCriteria !== undefined;
|
if (shouldSyncAssertion) { await this.ensureFeatureAssertion(updated); return (await getFeature(this.db, updated.id)) ?? updated; }
|
||||||
if (shouldSyncAssertion) {
|
|
||||||
await this.ensureFeatureAssertion(updated);
|
|
||||||
return (await getFeature(this.db, updated.id)) ?? updated;
|
|
||||||
}
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateFeatureStatus(featureId: string, status: FeatureStatus, options: MissionUpdateOptions = {}): Promise<MissionFeature> {
|
||||||
|
return this.updateFeature(featureId, { status }, options);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowResolvedColumns 2026-07-30-12:50 (batch-core):
|
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
|
"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<MissionStoreEvents> {
|
|||||||
await this.recomputeSliceStatus(sliceId);
|
await this.recomputeSliceStatus(sliceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFeatureStatus(featureId: string, status: FeatureStatus): Promise<MissionFeature> {
|
|
||||||
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:
|
* FNXC:MissionReconciliation 2026-07-20-08:34:
|
||||||
@@ -1198,7 +1233,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
*/
|
*/
|
||||||
async reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise<MissionFeature> {
|
async reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise<MissionFeature> {
|
||||||
const outcome = await this.layer.transactionImmediate(async (tx) => {
|
const outcome = await this.layer.transactionImmediate(async (tx) => {
|
||||||
const feature = await getFeature(tx, featureId);
|
const feature = await this.getFeatureForStatusWrite(tx, featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Feature ${featureId} not found`);
|
throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Feature ${featureId} not found`);
|
||||||
}
|
}
|
||||||
@@ -1266,6 +1301,9 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
? { ...feature, taskId, status: "done", updatedAt: now }
|
? { ...feature, taskId, status: "done", updatedAt: now }
|
||||||
: feature;
|
: feature;
|
||||||
if (featureChanged) await updateFeature(tx, reconciledFeature);
|
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") {
|
if (evidence.kind === "done") {
|
||||||
await setTaskMissionLinkage(tx, taskId, mission.id, slice.id);
|
await setTaskMissionLinkage(tx, taskId, mission.id, slice.id);
|
||||||
@@ -1285,12 +1323,14 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
feature: reconciledFeature,
|
feature: reconciledFeature,
|
||||||
featureChanged,
|
featureChanged,
|
||||||
linked: feature.taskId !== taskId,
|
linked: feature.taskId !== taskId,
|
||||||
|
event,
|
||||||
slice: reconciledSlice !== slice ? reconciledSlice : undefined,
|
slice: reconciledSlice !== slice ? reconciledSlice : undefined,
|
||||||
milestone: reconciledMilestone !== milestone ? reconciledMilestone : undefined,
|
milestone: reconciledMilestone !== milestone ? reconciledMilestone : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (outcome.featureChanged) this.emit("feature:updated", outcome.feature);
|
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.linked) this.emit("feature:linked", { feature: outcome.feature, taskId });
|
||||||
if (outcome.slice) this.emit("slice:updated", outcome.slice);
|
if (outcome.slice) this.emit("slice:updated", outcome.slice);
|
||||||
if (outcome.milestone) this.emit("milestone:updated", outcome.milestone);
|
if (outcome.milestone) this.emit("milestone:updated", outcome.milestone);
|
||||||
@@ -1305,7 +1345,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
*/
|
*/
|
||||||
async claimDefinedFeatureTaskInTransaction(
|
async claimDefinedFeatureTaskInTransaction(
|
||||||
tx: import("../postgres/data-layer.js").DbTransaction,
|
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<MissionFeature> {
|
): Promise<MissionFeature> {
|
||||||
/*
|
/*
|
||||||
FNXC:MissionAdmission 2026-07-23-15:30:
|
FNXC:MissionAdmission 2026-07-23-15:30:
|
||||||
@@ -1376,6 +1416,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await updateFeature(tx, updated);
|
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
|
// The inserted task already carries this verified linkage; retain this write
|
||||||
// for retry parity when the same canonical is claimed again.
|
// for retry parity when the same canonical is claimed again.
|
||||||
await setTaskMissionLinkage(tx, input.taskId, input.missionId, input.sliceId);
|
await setTaskMissionLinkage(tx, input.taskId, input.missionId, input.sliceId);
|
||||||
@@ -1383,8 +1425,10 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async claimDefinedFeatureTask(input: { featureId: string; taskId: string; missionId: string; sliceId: string }): Promise<MissionFeature> {
|
async claimDefinedFeatureTask(input: { featureId: string; taskId: string; missionId: string; sliceId: string }): Promise<MissionFeature> {
|
||||||
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);
|
this.emit("feature:updated", feature);
|
||||||
|
if (statusEvent.value) this.emit("mission:event", statusEvent.value);
|
||||||
this.emit("feature:linked", { feature, taskId: input.taskId });
|
this.emit("feature:linked", { feature, taskId: input.taskId });
|
||||||
await this.recomputeSliceStatus(feature.sliceId);
|
await this.recomputeSliceStatus(feature.sliceId);
|
||||||
return feature;
|
return feature;
|
||||||
@@ -1472,7 +1516,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
existing taskId: retries may reuse only that same canonical task.
|
existing taskId: retries may reuse only that same canonical task.
|
||||||
*/
|
*/
|
||||||
const outcome = await this.layer.transactionImmediate(async (tx) => {
|
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) throw new Error(`Feature ${featureId} not found`);
|
||||||
if (feature.taskId && feature.taskId !== taskId) {
|
if (feature.taskId && feature.taskId !== taskId) {
|
||||||
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
|
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
|
||||||
@@ -1500,13 +1544,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await updateFeature(tx, updated);
|
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);
|
await setTaskMissionLinkage(tx, taskId, milestone.missionId, slice.id);
|
||||||
return updated;
|
return { feature: updated, event };
|
||||||
});
|
});
|
||||||
this.emit("feature:updated", outcome);
|
this.emit("feature:updated", outcome.feature);
|
||||||
this.emit("feature:linked", { feature: outcome, taskId });
|
if (outcome.event) this.emit("mission:event", outcome.event);
|
||||||
await this.recomputeSliceStatus(outcome.sliceId);
|
this.emit("feature:linked", { feature: outcome.feature, taskId });
|
||||||
return outcome;
|
await this.recomputeSliceStatus(outcome.feature.sliceId);
|
||||||
|
return outcome.feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
|
async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
|
||||||
@@ -1560,7 +1606,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
* holding a database transaction while a model session executes.
|
* holding a database transaction while a model session executes.
|
||||||
*/
|
*/
|
||||||
async admitValidatorRun(featureId: string, input: ValidatorRunAdmissionInput): Promise<ValidatorRunAdmission> {
|
async admitValidatorRun(featureId: string, input: ValidatorRunAdmissionInput): Promise<ValidatorRunAdmission> {
|
||||||
return this.layer.transactionImmediate(async (tx) => {
|
let statusEvent: MissionEvent | undefined;
|
||||||
|
const admission = await this.layer.transactionImmediate<ValidatorRunAdmission>(async (tx) => {
|
||||||
const locked = await tx.select().from(schema.project.missionFeatures).where(and(
|
const locked = await tx.select().from(schema.project.missionFeatures).where(and(
|
||||||
eq(schema.project.missionFeatures.projectId, missionProjectId()),
|
eq(schema.project.missionFeatures.projectId, missionProjectId()),
|
||||||
eq(schema.project.missionFeatures.id, featureId),
|
eq(schema.project.missionFeatures.id, featureId),
|
||||||
@@ -1588,6 +1635,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
if (running) { await append("running", running); return { outcome: "running", run: running }; }
|
if (running) { await append("running", running); return { outcome: "running", run: running }; }
|
||||||
if (terminal?.status === "passed" && input.reusePass) {
|
if (terminal?.status === "passed" && input.reusePass) {
|
||||||
await updateFeature(tx, { ...feature, status: "done", loopState: "passed", lastValidatorStatus: "passed", lastValidatorRunId: terminal.id, updatedAt: new Date().toISOString() });
|
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);
|
await append("reuse-pass", terminal);
|
||||||
return { outcome: "reuse-pass", run: terminal };
|
return { outcome: "reuse-pass", run: terminal };
|
||||||
}
|
}
|
||||||
@@ -1609,6 +1657,10 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
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 });
|
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 };
|
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<MissionValidatorRun | undefined> {
|
async getValidatorRun(id: string): Promise<MissionValidatorRun | undefined> {
|
||||||
@@ -1901,20 +1953,48 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
FNXC:PostgresMissionStatusReconciliation 2026-07-14-17:55:
|
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.
|
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({
|
const { events, updatedFeatures } = await this.layer.transactionImmediate(async (tx) => {
|
||||||
status: "done",
|
/*
|
||||||
taskId: null,
|
FNXC:MissionStatusWrites 2026-08-10-13:21:
|
||||||
loopState: "passed",
|
The bulk reconciliation must lock and re-read its candidates inside this transaction.
|
||||||
lastValidatorStatus: "passed",
|
Using the earlier discovery snapshot would let a concurrent link/status writer overwrite
|
||||||
updatedAt: now,
|
a newer row and emit an event with a stale `from` status.
|
||||||
}).where(inArray(schema.project.missionFeatures.id, ids));
|
*/
|
||||||
for (const id of ids) {
|
const locked = await tx.select({ id: schema.project.missionFeatures.id })
|
||||||
const feature = byId.get(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 };
|
const updated = { ...feature, status: "done" as const, taskId: undefined, loopState: "passed" as const, lastValidatorStatus: "passed" as const, updatedAt: now };
|
||||||
this.emit("feature:updated", updated);
|
this.emit("feature:updated", updated);
|
||||||
if (feature.taskId) await clearTaskMissionLinkage(this.db, feature.taskId);
|
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 };
|
return { supersededCount: ids.length, featureIds: ids };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1759,6 +1759,11 @@ export {
|
|||||||
VALIDATOR_RUN_STATUSES,
|
VALIDATOR_RUN_STATUSES,
|
||||||
VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION,
|
VALIDATION_DIAGNOSTICS_MAX_EVIDENCE_PER_ASSERTION,
|
||||||
VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES,
|
VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES,
|
||||||
|
MISSION_EVENT_REASON_MAX_BYTES,
|
||||||
|
MISSION_EVENT_METADATA_MAX_BYTES,
|
||||||
|
boundMissionEventReason,
|
||||||
|
normalizeMissionTransitionActorForEvent,
|
||||||
|
buildMissionStatusEventMetadata,
|
||||||
selectNextSerialMissionSlice,
|
selectNextSerialMissionSlice,
|
||||||
normalizeValidationDiagnostics,
|
normalizeValidationDiagnostics,
|
||||||
renderValidationFailureDescription,
|
renderValidationFailureDescription,
|
||||||
|
|||||||
@@ -2280,7 +2280,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
* @returns The updated feature
|
* @returns The updated feature
|
||||||
* @throws Error if feature not found
|
* @throws Error if feature not found
|
||||||
*/
|
*/
|
||||||
updateFeature(id: string, updates: Partial<MissionFeature>): 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<MissionFeature>, _options: MissionUpdateOptions = {}): MissionFeature {
|
||||||
const feature = this.getFeature(id);
|
const feature = this.getFeature(id);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
throw new Error(`Feature ${id} not found`);
|
throw new Error(`Feature ${id} not found`);
|
||||||
@@ -2759,13 +2760,13 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
* @returns The updated feature
|
* @returns The updated feature
|
||||||
* @throws Error if feature not found
|
* @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);
|
const feature = this.getFeature(featureId);
|
||||||
if (!feature) {
|
if (!feature) {
|
||||||
throw new Error(`Feature ${featureId} not found`);
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = this.updateFeature(featureId, { status });
|
const updated = this.updateFeature(featureId, { status }, options);
|
||||||
|
|
||||||
// Recompute slice status
|
// Recompute slice status
|
||||||
this.recomputeSliceStatus(updated.sliceId);
|
this.recomputeSliceStatus(updated.sliceId);
|
||||||
|
|||||||
@@ -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_EVIDENCE_PER_ASSERTION = 16;
|
||||||
export const VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES = 4096;
|
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";
|
export type ValidationAssertionVerdict = "pass" | "fail" | "blocked";
|
||||||
|
|
||||||
@@ -107,10 +109,16 @@ export interface ValidationDiagnosticsInput {
|
|||||||
projectRoot?: string;
|
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 {};
|
if (typeof value !== "string") return {};
|
||||||
let text = redactSecrets(value);
|
let text: string;
|
||||||
// Paths from the project are useful evidence; disposable/external paths are not.
|
try { text = redactSecrets(value); } catch { return {}; }
|
||||||
text = text.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`]+/g, (path) => {
|
text = text.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`]+/g, (path) => {
|
||||||
const normalizedRoot = projectRoot?.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalizedRoot = projectRoot?.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const normalizedPath = path.replace(/\\/g, "/");
|
const normalizedPath = path.replace(/\\/g, "/");
|
||||||
@@ -119,20 +127,88 @@ function boundValidationText(value: unknown, projectRoot?: string): { value?: st
|
|||||||
: "[external path omitted]";
|
: "[external path omitted]";
|
||||||
});
|
});
|
||||||
const bytes = Buffer.byteLength(text, "utf8");
|
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 marker = "… [truncated]";
|
||||||
const limit = VALIDATION_DIAGNOSTICS_MAX_TEXT_BYTES - Buffer.byteLength(marker, "utf8");
|
const remaining = limit - Buffer.byteLength(marker, "utf8");
|
||||||
let end = 0;
|
let end = 0; let used = 0;
|
||||||
let used = 0;
|
|
||||||
for (const character of text) {
|
for (const character of text) {
|
||||||
const size = Buffer.byteLength(character, "utf8");
|
const size = Buffer.byteLength(character, "utf8");
|
||||||
if (used + size > limit) break;
|
if (used + size > remaining) break;
|
||||||
used += size;
|
used += size; end += character.length;
|
||||||
end += character.length;
|
|
||||||
}
|
}
|
||||||
return { value: `${text.slice(0, end)}${marker}`, truncated: true };
|
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<string, unknown> : {};
|
||||||
|
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<string, string | undefined>; actor: unknown; reason?: unknown }): Record<string, unknown> {
|
||||||
|
/*
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
...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. */
|
/** Normalize and redact validation evidence before any mission artifact persists it. */
|
||||||
export function normalizeValidationDiagnostics(input: ValidationDiagnosticsInput): ValidationDiagnostics {
|
export function normalizeValidationDiagnostics(input: ValidationDiagnosticsInput): ValidationDiagnostics {
|
||||||
return {
|
return {
|
||||||
@@ -208,6 +284,7 @@ export const MISSION_EVENT_TYPES = [
|
|||||||
"mission_completed",
|
"mission_completed",
|
||||||
"mission_started",
|
"mission_started",
|
||||||
"mission_status_changed",
|
"mission_status_changed",
|
||||||
|
"feature_status_changed",
|
||||||
"mission_paused",
|
"mission_paused",
|
||||||
"mission_resumed",
|
"mission_resumed",
|
||||||
"autopilot_enabled",
|
"autopilot_enabled",
|
||||||
@@ -239,6 +316,8 @@ export interface MissionTransitionActor {
|
|||||||
/** Optional attribution supplied to a mission mutation that can arm autonomy. */
|
/** Optional attribution supplied to a mission mutation that can arm autonomy. */
|
||||||
export interface MissionUpdateOptions {
|
export interface MissionUpdateOptions {
|
||||||
actor?: MissionTransitionActor;
|
actor?: MissionTransitionActor;
|
||||||
|
/** Untrusted caller prose; the store redacts and bounds it before persistence. */
|
||||||
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Autopilot status for a mission */
|
/** Autopilot status for a mission */
|
||||||
|
|||||||
@@ -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 () => {
|
it("never binds ambient-task tools in project-scoped chat (no ambient task id)", async () => {
|
||||||
const taskStore = baseTaskStore();
|
const taskStore = baseTaskStore();
|
||||||
for (const gate of [undefined, {} as any]) {
|
for (const gate of [undefined, {} as any]) {
|
||||||
|
|||||||
@@ -2716,7 +2716,8 @@ export function createMissionRouter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
res.json(feature);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -10,12 +10,50 @@ describe("createMissionTools", () => {
|
|||||||
it("exposes the complete hierarchy surface with read and mutation names", () => {
|
it("exposes the complete hierarchy surface with read and mutation names", () => {
|
||||||
const store = { getMissionStore: vi.fn() } as never;
|
const store = { getMissionStore: vi.fn() } as never;
|
||||||
expect(createMissionTools(store).map((tool) => tool.name)).toEqual([
|
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_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 () => {
|
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 linkFeatureToTask = vi.fn().mockResolvedValue({ id: "F-1", taskId: "FN-1", status: "triaged" });
|
||||||
const store = { getMissionStore: () => ({ linkFeatureToTask }) } as never;
|
const store = { getMissionStore: () => ({ linkFeatureToTask }) } as never;
|
||||||
|
|||||||
@@ -3388,7 +3388,7 @@ describe("executeHeartbeat", () => {
|
|||||||
*/
|
*/
|
||||||
// fn_artifact_register/list/view, agent config/provisioning, mission hierarchy, ideation, goals/evaluations/identity,
|
// 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.
|
// 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([
|
expect(callArgs.customTools!.map((tool) => tool.name)).toEqual([
|
||||||
"fn_task_create",
|
"fn_task_create",
|
||||||
"fn_task_log",
|
"fn_task_log",
|
||||||
@@ -3409,6 +3409,7 @@ describe("executeHeartbeat", () => {
|
|||||||
"fn_mission_show",
|
"fn_mission_show",
|
||||||
"fn_mission_create",
|
"fn_mission_create",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
|
"fn_mission_set_status",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_milestone_update",
|
"fn_milestone_update",
|
||||||
@@ -3418,6 +3419,7 @@ describe("executeHeartbeat", () => {
|
|||||||
"fn_slice_delete",
|
"fn_slice_delete",
|
||||||
"fn_feature_add",
|
"fn_feature_add",
|
||||||
"fn_feature_update",
|
"fn_feature_update",
|
||||||
|
"fn_feature_set_status",
|
||||||
"fn_feature_delete",
|
"fn_feature_delete",
|
||||||
"fn_feature_link_task",
|
"fn_feature_link_task",
|
||||||
"fn_research_promote_finding",
|
"fn_research_promote_finding",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("workflow-step readonly allowlist policy", () => {
|
|||||||
"fn_mission_create",
|
"fn_mission_create",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
|
"fn_mission_set_status",
|
||||||
"fn_mission_backfill_assertions",
|
"fn_mission_backfill_assertions",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_slice_add",
|
"fn_slice_add",
|
||||||
@@ -53,6 +54,7 @@ describe("workflow-step readonly allowlist policy", () => {
|
|||||||
"fn_slice_activate",
|
"fn_slice_activate",
|
||||||
"fn_feature_link_task",
|
"fn_feature_link_task",
|
||||||
"fn_feature_update",
|
"fn_feature_update",
|
||||||
|
"fn_feature_set_status",
|
||||||
"fn_milestone_update",
|
"fn_milestone_update",
|
||||||
"fn_agent_stop",
|
"fn_agent_stop",
|
||||||
"fn_agent_start",
|
"fn_agent_start",
|
||||||
|
|||||||
@@ -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 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 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 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 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()) });
|
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 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 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 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 featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() });
|
||||||
export const researchFindingPromoteParams = Type.Object({
|
export const researchFindingPromoteParams = Type.Object({
|
||||||
runId: Type.String(),
|
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_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_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_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_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_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 }); }),
|
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_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_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 }); }),
|
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_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_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) => {
|
tool("fn_research_promote_finding", "Promote Research Finding", "Promote a completed research finding into a canonical mission feature.", researchFindingPromoteParams, async (p) => {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
|||||||
"fn_mission_create",
|
"fn_mission_create",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
|
"fn_mission_set_status",
|
||||||
"fn_mission_backfill_assertions",
|
"fn_mission_backfill_assertions",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_slice_add",
|
"fn_slice_add",
|
||||||
@@ -104,6 +105,7 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
|||||||
"fn_slice_activate",
|
"fn_slice_activate",
|
||||||
"fn_feature_link_task",
|
"fn_feature_link_task",
|
||||||
"fn_feature_update",
|
"fn_feature_update",
|
||||||
|
"fn_feature_set_status",
|
||||||
"fn_milestone_update",
|
"fn_milestone_update",
|
||||||
/* FNXC:Ideation 2026-07-30-15:30: Persisted divergence/convergence writes require both action and permanent-agent policy recognition. */
|
/* FNXC:Ideation 2026-07-30-15:30: Persisted divergence/convergence writes require both action and permanent-agent policy recognition. */
|
||||||
"fn_ideation_start",
|
"fn_ideation_start",
|
||||||
|
|||||||
Reference in New Issue
Block a user