FN-8545: enforce mission lineage task admission
Protect feature bootstrap task creation with mission-lineage admission rules. - Restrict supervised agents to linked autonomous mission features - Atomically claim defined feature tasks and preserve duplicate ownership - Lock defined feature claims to prevent concurrent bootstrap inserts - Document admission behavior and add PostgreSQL and delegation coverage Files changed: .changeset/fn-8545-mission-admission.md | 7 + docs/agents.md | 6 + docs/missions.md | 12 +- .../__tests__/postgres/mission-store.pg.test.ts | 119 ++++++++++++ packages/core/src/async-mission-store.ts | 203 +++++++++++++++++++-- packages/core/src/duplicate-guard.ts | 14 +- packages/core/src/mission-store.ts | 15 ++ packages/core/src/task-store/task-creation.ts | 152 ++++++++++----- .../src/__tests__/agent-tools-delegation.test.ts | 152 ++++++++++++++- packages/engine/src/agent-tools.ts | 151 ++++++++++++++- 10 files changed, 753 insertions(+), 78 deletions(-) Fusion-Task-Id: FN-8545 Fusion-Task-Lineage: c6e1b46d-f434-4b07-93bc-27e1f6b491b1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8545-mission-admission.md
Normal file
7
.changeset/fn-8545-mission-admission.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix supervised task creation and defined-feature mission bootstrap admission.
|
||||
category: fix
|
||||
dev: No-task heartbeat creates still require approved lineage; first defined-feature tasks link and triage safely.
|
||||
@@ -83,6 +83,12 @@ printf "deploy report" | fn chat agent-abc123 --once --non-interactive
|
||||
|
||||
> Replies require a running engine for the same project (for example `fn` dashboard or `fn serve`).
|
||||
|
||||
## Mission lineage and task creation
|
||||
|
||||
`fn_task_create` and `fn_delegate_task` use two distinct controls. In an autonomous no-task heartbeat, the caller must provide approved `mission_lineage` (mission, slice, and feature); a rejection states that approved mission lineage is required, and no permission grant overrides it. In interactive/user-supervised and task-scoped sessions, lineage is optional and `task_agent_mutation` category rules and exact-tool overrides decide whether creation is allowed, requires approval, or is blocked.
|
||||
|
||||
A valid active lineage can bootstrap the first task for a hand-authored `defined` feature. The feature is linked to that exact task and promoted to `triaged`; later autonomous scheduler work still requires a `triaged` or `in-progress` feature.
|
||||
|
||||
## Agent configuration updates from agents
|
||||
|
||||
The `fn_agent_update` extension tool lets chat/extension callers update existing non-ephemeral agents in place instead of deleting and recreating them. It accepts `agent_id` plus any editable subset of:
|
||||
|
||||
@@ -23,6 +23,14 @@ Mission: Improve Reliability
|
||||
Task: FN-214
|
||||
```
|
||||
|
||||
## Agent task-creation admission
|
||||
|
||||
Mission lineage is an admission requirement only for **autonomous no-task heartbeat** creates and delegations. Those idle patrol calls must supply a valid active Mission → Milestone → Slice → Feature chain; an allow rule for `task_agent_mutation` cannot bypass this requirement. Missing or invalid lineage is rejected before a task is persisted with an explicit mission-lineage remedy.
|
||||
|
||||
Interactive/user-supervised, task-scoped heartbeat, executor, triage, and workflow-step calls may create or delegate freeform tasks without lineage. They remain governed by the normal `task_agent_mutation` permission policy, including category and exact-tool allow, approval, and block rules.
|
||||
|
||||
A valid active lineage may name a hand-authored `defined` feature only for its first task. Fusion atomically claims the feature, links that exact task, and promotes the feature to `triaged`; an already-linked feature rejects rather than overwriting its canonical task. This bootstrap exception does not make `defined` executable: later scheduler and symbol-lock admission still uses the stricter contract below.
|
||||
|
||||
## Canonical lineage approval for autonomous symbol locks
|
||||
|
||||
Before autonomous scheduler work may acquire a symbol lock, it resolves the task's Mission → Milestone → Slice → Feature lineage and evaluates the single `@fusion/core` contract: `evaluateMissionLineageApproval`. Resolution and lock acquisition remain scheduler responsibilities; downstream schedulers must not redefine the approval rule.
|
||||
@@ -32,7 +40,7 @@ Approval requires every one of these statuses:
|
||||
- Mission: `active`
|
||||
- Milestone: `active`
|
||||
- Slice: `active`
|
||||
- Feature: `triaged` or `in-progress`
|
||||
- Feature: `triaged` or `in-progress` (never `defined`; defined is only allowed at the first-task bootstrap boundary)
|
||||
|
||||
When the scheduler passes `planApprovalRequired: true`, the linked task must also have an `approvedPlanFingerprint` that is a non-empty string after trimming whitespace. The predicate does not recompute the fingerprint; `plan-approval.ts` owns its generation and validation. When plan approval is not required, the fingerprint is ignored.
|
||||
|
||||
@@ -722,4 +730,4 @@ A completed cited research finding may become a normal Mission Feature. Its feat
|
||||
|
||||
### Autonomous mission admission
|
||||
|
||||
Heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link. Missing or invalid lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops.
|
||||
Autonomous no-task heartbeat agents may create or delegate implementation work only with an approved Feature → Slice → Milestone → Mission lineage. Interactive and task-scoped calls remain governed by `task_agent_mutation` policy as described in [Agent task-creation admission](#agent-task-creation-admission). The created task stores that lineage as task metadata; it does not replace the canonical feature `taskId` link except at the documented `defined`-feature first-task bootstrap. Missing or invalid autonomous lineage is rejected before a task is persisted. Roadmap reconciliation marks done tasks done, returns cancelled/requeued tasks to triaged, keeps failed work non-complete, and treats archives as non-promoting no-ops.
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { DbTransaction } from "../../postgres/data-layer.js";
|
||||
import type { TaskCreateInput } from "../../types/task-core.js";
|
||||
|
||||
import {
|
||||
pgDescribe,
|
||||
@@ -244,6 +247,122 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
||||
expect(unlinked.status).toBe("defined");
|
||||
});
|
||||
|
||||
it("does not overwrite an existing task directory on a creation collision", async () => {
|
||||
const taskStore = h.store();
|
||||
const existing = await taskStore.createTask({ description: "the existing task must keep its prompt" });
|
||||
const existingDir = taskStore.taskDir(existing.id);
|
||||
const originalPrompt = await readFile(`${existingDir}/PROMPT.md`, "utf8");
|
||||
const allocator = {
|
||||
reserveDistributedTaskId: vi.fn().mockResolvedValue({ taskId: existing.id, reservationId: "duplicate-id-reservation" }),
|
||||
commitDistributedTaskIdReservation: vi.fn().mockResolvedValue(undefined),
|
||||
abortDistributedTaskIdReservation: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const allocatorSpy = vi.spyOn(taskStore, "getDistributedTaskIdAllocator").mockReturnValue(allocator as ReturnType<typeof taskStore.getDistributedTaskIdAllocator>);
|
||||
try {
|
||||
await expect(taskStore.createTask({ description: "a competing task must not overwrite files" }))
|
||||
.rejects.toThrow(`Task ID already exists: ${existing.id}`);
|
||||
|
||||
/* FNXC:MissionAdmission 2026-07-23-19:00: a task-row collision leaves the winner's final artifacts untouched because the loser wrote only its staging directory. */
|
||||
await expect(readFile(`${existingDir}/PROMPT.md`, "utf8")).resolves.toBe(originalPrompt);
|
||||
} finally {
|
||||
allocatorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not claim a defined feature when task-file materialization fails", async () => {
|
||||
const m = missions();
|
||||
const mission = await m.createMission({ title: "Bootstrap file failure" });
|
||||
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: "Target feature" });
|
||||
const taskStore = h.store();
|
||||
const claim = vi.fn(async (tx: DbTransaction, taskId: string) =>
|
||||
m.claimDefinedFeatureTaskInTransaction(tx, {
|
||||
featureId: feature.id,
|
||||
taskId,
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
}),
|
||||
);
|
||||
const writeTaskJson = vi.spyOn(taskStore, "writeTaskJsonFile").mockRejectedValueOnce(new Error("injected task-file failure"));
|
||||
|
||||
try {
|
||||
await expect(taskStore.createTask({
|
||||
description: "must not become a partial feature bootstrap",
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
afterTaskInsert: (tx: DbTransaction, task: { id: string }) => claim(tx, task.id),
|
||||
} as TaskCreateInput & { afterTaskInsert: (tx: DbTransaction, task: { id: string }) => Promise<void> })).rejects.toThrow("injected task-file failure");
|
||||
} finally {
|
||||
writeTaskJson.mockRestore();
|
||||
}
|
||||
|
||||
/* FNXC:MissionAdmission 2026-07-23-17:10: filesystem failure precedes the insert-and-claim transaction, so no feature promotion can survive a failed task create. */
|
||||
expect(claim).not.toHaveBeenCalled();
|
||||
expect(await m.getFeature(feature.id)).toMatchObject({ status: "defined", taskId: undefined });
|
||||
expect((await taskStore.listTasks()).some((task) => task.description === "must not become a partial feature bootstrap")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unlinked duplicate canonical even when its mission and slice match", async () => {
|
||||
const m = missions();
|
||||
const mission = await m.createMission({ title: "Bootstrap duplicate guard" });
|
||||
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: "Target feature" });
|
||||
const task = await h.store().createTask({
|
||||
description: "existing work for another feature",
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-17:20:
|
||||
A duplicate canonical does not inherit a feature merely because it shares a
|
||||
slice. Only the insert transaction may claim a defined feature for a new
|
||||
task; retry reconciliation requires an existing bidirectional link.
|
||||
*/
|
||||
await expect(m.claimDefinedFeatureTask({
|
||||
featureId: feature.id,
|
||||
taskId: task.id,
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
})).rejects.toThrow("is not linked to this feature");
|
||||
expect(await m.getFeature(feature.id)).toMatchObject({ status: "defined", taskId: undefined });
|
||||
});
|
||||
|
||||
it("preserves a late bootstrap duplicate already linked to another feature", async () => {
|
||||
const m = missions();
|
||||
const mission = await m.createMission({ title: "Bootstrap sibling ownership" });
|
||||
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||
const firstFeature = await m.addFeature(slice.id, { title: "First feature" });
|
||||
const siblingFeature = await m.addFeature(slice.id, { title: "Sibling feature" });
|
||||
const taskStore = h.store();
|
||||
const claimedTask = await taskStore.createTask({
|
||||
description: "same fingerprint work",
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
});
|
||||
await m.linkFeatureToTask(firstFeature.id, claimedTask.id);
|
||||
const siblingTask = await taskStore.createTask({
|
||||
description: "same fingerprint work",
|
||||
missionId: mission.id,
|
||||
sliceId: slice.id,
|
||||
});
|
||||
await m.linkFeatureToTask(siblingFeature.id, siblingTask.id);
|
||||
|
||||
await m.archiveDefinedFeatureBootstrapDuplicate({
|
||||
featureId: firstFeature.id,
|
||||
taskId: claimedTask.id,
|
||||
duplicateTaskId: siblingTask.id,
|
||||
});
|
||||
|
||||
/* FNXC:MissionAdmission 2026-07-23-21:10: a late same-fingerprint task claimed by another feature is not a duplicate eligible for archival. */
|
||||
expect(await taskStore.getTask(siblingTask.id)).toMatchObject({ id: siblingTask.id, column: "triage" });
|
||||
expect(await m.getFeature(siblingFeature.id)).toMatchObject({ taskId: siblingTask.id, status: "triaged" });
|
||||
expect(await m.getFeature(firstFeature.id)).toMatchObject({ taskId: claimedTask.id, status: "triaged" });
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MissionReconciliation 2026-07-20-08:34:
|
||||
Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails.
|
||||
|
||||
@@ -1022,27 +1022,198 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
return outcome.feature;
|
||||
}
|
||||
|
||||
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
|
||||
const feature = await getFeature(this.db, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
const liveTask = await getLiveTaskById(this.db, taskId);
|
||||
if (!liveTask) {
|
||||
throw new Error(
|
||||
`Cannot link feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
|
||||
);
|
||||
/**
|
||||
* Atomically claim a hand-authored defined feature for a task already inserted
|
||||
* in the caller's transaction. This is intentionally narrower than
|
||||
* linkFeatureToTask(): existing manual links may establish task lineage,
|
||||
* whereas a duplicate bootstrap canonical must already prove this lineage.
|
||||
*/
|
||||
async claimDefinedFeatureTaskInTransaction(
|
||||
tx: import("./postgres/data-layer.js").DbTransaction,
|
||||
input: { featureId: string; taskId: string; missionId: string; sliceId: string; requireExistingFeatureLink?: boolean },
|
||||
): Promise<MissionFeature> {
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-15:30:
|
||||
Defined is creation-admissible only at the first-task claim boundary. Check
|
||||
the project-scoped task's existing lineage before updating either record so
|
||||
a deterministic duplicate from another mission can never be repurposed.
|
||||
*/
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-08-10-00:00:
|
||||
Concurrent bootstrap requests must serialize on the defined Feature before
|
||||
either inserts its task. READ COMMITTED alone permits both readers to claim
|
||||
it; this row lock makes the second request re-read the committed taskId and
|
||||
reject, preserving one exclusive first-task claim.
|
||||
*/
|
||||
const lockedFeatures = await tx
|
||||
.select({ id: schema.project.missionFeatures.id })
|
||||
.from(schema.project.missionFeatures)
|
||||
.where(eq(schema.project.missionFeatures.id, input.featureId))
|
||||
.for("update");
|
||||
if (lockedFeatures.length === 0) throw new Error(`Feature ${input.featureId} not found`);
|
||||
const feature = await getFeature(tx, input.featureId);
|
||||
if (!feature) throw new Error(`Feature ${input.featureId} not found`);
|
||||
if (feature.sliceId !== input.sliceId) throw new Error(`Feature ${input.featureId} does not belong to slice ${input.sliceId}`);
|
||||
if (feature.taskId && feature.taskId !== input.taskId) {
|
||||
throw new Error(`Feature ${input.featureId} is already linked to task ${feature.taskId}`);
|
||||
}
|
||||
const linkage = await this.resolveTaskLinkage(feature.sliceId);
|
||||
if (feature.status !== "defined" && feature.taskId !== input.taskId) {
|
||||
throw new Error(`Feature ${input.featureId} is not available for first-task bootstrap`);
|
||||
}
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-17:20:
|
||||
A duplicate canonical was not inserted in this transaction. It may be
|
||||
reused only after this feature already owns it; allowing an arbitrary
|
||||
unlinked task from the same slice would silently assign another feature's
|
||||
work to this bootstrap request.
|
||||
*/
|
||||
if (input.requireExistingFeatureLink === true && feature.taskId !== input.taskId) {
|
||||
throw new Error(`Cannot bootstrap feature ${input.featureId}: pre-existing task ${input.taskId} is not linked to this feature`);
|
||||
}
|
||||
|
||||
const projectId = this.layer.projectId;
|
||||
if (!projectId) throw new Error("Defined-feature bootstrap requires a project-scoped data layer");
|
||||
const taskRows = await tx
|
||||
.select({ id: schema.project.tasks.id, missionId: schema.project.tasks.missionId, sliceId: schema.project.tasks.sliceId, column: schema.project.tasks.column })
|
||||
.from(schema.project.tasks)
|
||||
.where(and(
|
||||
eq(schema.project.tasks.projectId, projectId),
|
||||
eq(schema.project.tasks.id, input.taskId),
|
||||
sql`${schema.project.tasks.deletedAt} is null`,
|
||||
));
|
||||
const task = taskRows[0];
|
||||
if (!task || task.column === "archived") {
|
||||
throw new Error(`Cannot bootstrap feature ${input.featureId}: task ${input.taskId} is not active in this project`);
|
||||
}
|
||||
if (task.missionId !== input.missionId || task.sliceId !== input.sliceId) {
|
||||
throw new Error(`Cannot bootstrap feature ${input.featureId}: task ${input.taskId} has unrelated mission lineage`);
|
||||
}
|
||||
const conflict = await getConflictingFeatureByTaskId(tx, input.taskId, input.featureId);
|
||||
if (conflict) throw new Error(`Task ${input.taskId} is already linked to feature ${conflict.id}`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const shouldTransitionLoop = !feature.loopState || feature.loopState === "idle";
|
||||
const loopStateUpdates: Partial<MissionFeature> = shouldTransitionLoop
|
||||
? { loopState: "implementing", implementationAttemptCount: 1 }
|
||||
: {};
|
||||
const updated = await this.updateFeature(featureId, { taskId, status: "triaged", ...loopStateUpdates });
|
||||
await setTaskMissionLinkage(this.db, taskId, linkage.missionId, linkage.sliceId);
|
||||
await this.recomputeSliceStatus(updated.sliceId);
|
||||
this.emit("feature:linked", { feature: updated, taskId });
|
||||
const updated: MissionFeature = {
|
||||
...feature,
|
||||
taskId: input.taskId,
|
||||
status: "triaged",
|
||||
...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
await updateFeature(tx, updated);
|
||||
// The inserted task already carries this verified linkage; retain this write
|
||||
// for retry parity when the same canonical is claimed again.
|
||||
await setTaskMissionLinkage(tx, input.taskId, input.missionId, input.sliceId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
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 }));
|
||||
this.emit("feature:updated", feature);
|
||||
this.emit("feature:linked", { feature, taskId: input.taskId });
|
||||
await this.recomputeSliceStatus(feature.sliceId);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the task that atomically claimed a defined Feature as the sole live
|
||||
* deterministic-duplicate canonical. This compensates for a duplicate that
|
||||
* became visible only after the create preflight, without ever allowing the
|
||||
* generic intake path to archive feature.taskId.
|
||||
*/
|
||||
async archiveDefinedFeatureBootstrapDuplicate(input: { featureId: string; taskId: string; duplicateTaskId: string }): Promise<void> {
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-21:10:
|
||||
Project-agnostic legacy stores remain scoped to their reserved RLS
|
||||
partition, so reconciliation never falls back to an unscoped task ID.
|
||||
*/
|
||||
const projectId = this.layer.projectId || "__legacy_unscoped__";
|
||||
await this.layer.transactionImmediate(async (tx) => {
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-20:00:
|
||||
A late deterministic duplicate must not reverse the first-task claim and
|
||||
archive feature.taskId. Verify that the feature still owns the claimed,
|
||||
project-scoped live task, then archive only the competing live task in
|
||||
this transaction. `defined` remains scheduler-ineligible throughout.
|
||||
*/
|
||||
const feature = await getFeature(tx, input.featureId);
|
||||
if (!feature || feature.taskId !== input.taskId || feature.status !== "triaged") {
|
||||
throw new Error(`Cannot reconcile defined-feature bootstrap duplicate for ${input.featureId}`);
|
||||
}
|
||||
const claimed = await tx.select({ id: schema.project.tasks.id })
|
||||
.from(schema.project.tasks)
|
||||
.where(and(
|
||||
eq(schema.project.tasks.projectId, projectId),
|
||||
eq(schema.project.tasks.id, input.taskId),
|
||||
sql`${schema.project.tasks.deletedAt} is null`,
|
||||
sql`${schema.project.tasks.column} <> 'archived'`,
|
||||
));
|
||||
if (!claimed[0]) throw new Error(`Cannot reconcile defined-feature bootstrap duplicate: claimed task ${input.taskId} is not live`);
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-21:10:
|
||||
Fingerprint equality does not make work interchangeable across Features.
|
||||
A late sibling already claimed by another Feature remains live; archiving
|
||||
it here would corrupt that Feature's canonical task. Keep both tasks and
|
||||
let each feature retain its own transactional bootstrap claim.
|
||||
*/
|
||||
const duplicateFeature = await getConflictingFeatureByTaskId(tx, input.duplicateTaskId, input.featureId);
|
||||
if (duplicateFeature) return;
|
||||
await tx.update(schema.project.tasks)
|
||||
.set({ column: "archived", updatedAt: new Date().toISOString() })
|
||||
.where(and(
|
||||
eq(schema.project.tasks.projectId, projectId),
|
||||
eq(schema.project.tasks.id, input.duplicateTaskId),
|
||||
sql`${schema.project.tasks.deletedAt} is null`,
|
||||
sql`${schema.project.tasks.column} <> 'archived'`,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-12:00:
|
||||
First-task bootstrap must claim the feature, promote it, and backlink the
|
||||
exact project-scoped task as one transaction. Never overwrite a feature's
|
||||
existing taskId: retries may reuse only that same canonical task.
|
||||
*/
|
||||
const outcome = await this.layer.transactionImmediate(async (tx) => {
|
||||
const feature = await getFeature(tx, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
if (feature.taskId && feature.taskId !== taskId) {
|
||||
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
|
||||
}
|
||||
const liveTask = await getLiveTaskById(tx, taskId);
|
||||
if (!liveTask) {
|
||||
throw new Error(
|
||||
`Cannot link feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
|
||||
);
|
||||
}
|
||||
const conflictingFeature = await getConflictingFeatureByTaskId(tx, taskId, featureId);
|
||||
if (conflictingFeature) {
|
||||
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
|
||||
}
|
||||
const slice = await getSlice(tx, feature.sliceId);
|
||||
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
|
||||
if (!slice || !milestone) throw new Error(`Feature ${featureId} has incomplete mission hierarchy`);
|
||||
const shouldTransitionLoop = !feature.loopState || feature.loopState === "idle";
|
||||
const now = new Date().toISOString();
|
||||
const updated: MissionFeature = {
|
||||
...feature,
|
||||
taskId,
|
||||
status: "triaged",
|
||||
...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
await updateFeature(tx, updated);
|
||||
await setTaskMissionLinkage(tx, taskId, milestone.missionId, slice.id);
|
||||
return updated;
|
||||
});
|
||||
this.emit("feature:updated", outcome);
|
||||
this.emit("feature:linked", { feature: outcome, taskId });
|
||||
await this.recomputeSliceStatus(outcome.sliceId);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
|
||||
const feature = await getFeature(this.db, featureId);
|
||||
if (!feature) throw new Error(`Feature ${featureId} not found`);
|
||||
|
||||
@@ -139,8 +139,10 @@ export async function reconcileDeterministicDuplicate(
|
||||
windowMs?: number;
|
||||
sourceParentTaskId?: string | null;
|
||||
logger?: { warn(msg: string, data?: Record<string, unknown>): void };
|
||||
/** Handle a duplicate without archiving `createdTask` (e.g. claimed feature bootstrap). */
|
||||
onDuplicate?: (canonical: Task) => Promise<"keep-created" | "archive-created">;
|
||||
},
|
||||
): Promise<{ outcome: "kept" | "archived"; canonical: Task }> {
|
||||
): Promise<{ outcome: "kept" | "archived" | "kept-duplicate"; canonical: Task }> {
|
||||
if (!args.fingerprint) {
|
||||
return { outcome: "kept", canonical: args.createdTask };
|
||||
}
|
||||
@@ -160,6 +162,16 @@ export async function reconcileDeterministicDuplicate(
|
||||
return { outcome: "kept", canonical: args.createdTask };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-20:00:
|
||||
A defined-feature bootstrap has already transactionally made its inserted
|
||||
task feature.taskId. Let that caller reconcile the older sibling without
|
||||
routing the generic duplicate path through an archive of the claimed row.
|
||||
*/
|
||||
if (await args.onDuplicate?.(olderSibling) === "keep-created") {
|
||||
return { outcome: "kept-duplicate", canonical: args.createdTask };
|
||||
}
|
||||
|
||||
await store.updateTask(args.createdTask.id, {
|
||||
sourceMetadataPatch: {
|
||||
contentFingerprint: args.fingerprint,
|
||||
|
||||
@@ -2461,6 +2461,21 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
);
|
||||
}
|
||||
|
||||
if (feature.taskId && feature.taskId !== taskId) {
|
||||
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
|
||||
}
|
||||
const conflictingFeature = this.db
|
||||
.prepare(`SELECT id FROM mission_features WHERE taskId = ? AND id != ? LIMIT 1`)
|
||||
.get(taskId, featureId) as { id: string } | undefined;
|
||||
if (conflictingFeature) {
|
||||
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-12:00:
|
||||
Keep sync test-contract parity with PostgreSQL: a defined feature's first
|
||||
task claim is exclusive and must never overwrite another feature backlink.
|
||||
*/
|
||||
const linkage = this.resolveTaskLinkage(feature.sliceId);
|
||||
|
||||
// When first linking (loopState is idle or falsy), transition to implementing
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
import {TaskStore, storeLog} from "../store.js";
|
||||
import {InvalidFileScopeError, SelfDefeatingDependencyError, detectSelfDefeatingDependency, TombstonedTaskResurrectionError} from "./errors.js";
|
||||
import {mkdir, rm, writeFile} from "node:fs/promises";
|
||||
import {mkdir, rename, rm, writeFile} from "node:fs/promises";
|
||||
import {join} from "node:path";
|
||||
import {existsSync} from "node:fs";
|
||||
import {randomUUID} from "node:crypto";
|
||||
import type {Task, TaskCreateInput, Settings} from "../types.js";
|
||||
import "../builtin-traits.js";
|
||||
import {applyReviewLevelPreset} from "../review-level-preset.js";
|
||||
@@ -29,6 +30,17 @@ import {withTaskBranchContextInSourceMetadata} from "../task-store/branch-contex
|
||||
import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
|
||||
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
|
||||
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
|
||||
import type {DbTransaction} from "../postgres/data-layer.js";
|
||||
|
||||
type CreateTaskWithAfterInsert = TaskCreateInput & {
|
||||
/** Internal transaction hook; never persisted in task source metadata. */
|
||||
afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise<void>;
|
||||
/**
|
||||
* Internal bootstrap escape hatch. The caller supplies an equivalent
|
||||
* transactionally-safe duplicate reconciliation after the feature claim.
|
||||
*/
|
||||
skipSameAgentDuplicateIntake?: boolean;
|
||||
};
|
||||
|
||||
function ensureSqliteProposalClaimUniqueness(store: TaskStore): void {
|
||||
/*
|
||||
@@ -371,49 +383,44 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
|
||||
}
|
||||
|
||||
const dir = store.taskDir(id);
|
||||
const stagingDir = `${dir}.creating-${randomUUID()}`;
|
||||
let ownsStagingDirectory = false;
|
||||
let ownsPromotedTaskDirectory = false;
|
||||
const cleanupPreparedTaskFiles = async () => {
|
||||
if (store.isWatching) store.taskCache.delete(id);
|
||||
if (ownsStagingDirectory && existsSync(stagingDir)) {
|
||||
await rm(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
// A rollback after promotion removes only this create's final directory.
|
||||
// A conflicting existing row never reaches promotion, so its files survive.
|
||||
if (ownsPromotedTaskDirectory && existsSync(dir)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:30:
|
||||
// Insert the task row via async Drizzle insert inside a transaction.
|
||||
// A duplicate-ID collision raises a unique_violation (23505) which we
|
||||
// catch and surface as "Task ID already exists" (matching the SQLite path).
|
||||
const context = store.createTaskPersistSerializationContext(task);
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-17:10:
|
||||
Materialize task files before the transaction that inserts and claims a
|
||||
defined feature. Filesystem writes cannot join PostgreSQL; this ordering
|
||||
means a write failure cannot commit a triaged feature pointing at a deleted
|
||||
task, while the database transaction still makes task insert + feature claim
|
||||
indivisible.
|
||||
*/
|
||||
try {
|
||||
await layer.transactionImmediate(async (tx) => {
|
||||
// FNXC:MultiProjectIsolation 2026-07-10: stamp the bound projectId so the
|
||||
// new task row is attributed to (and later filtered under) this project.
|
||||
await insertTaskRowInTransaction(tx, task as unknown as Record<string, unknown>, context, layer.projectId);
|
||||
});
|
||||
} catch (error) {
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-18:30:
|
||||
Proposal creation retries can race after a creation lease is released while
|
||||
the original creator is still inserting. Both attempts deliberately use the
|
||||
same stable proposalClaimId, so the partial unique index is the at-most-once
|
||||
authority. A 23505 for that key returns the committed winner instead of
|
||||
treating it as an ID collision; no loser may continue into task-file or
|
||||
workflow materialization. Other unique violations remain task-ID errors.
|
||||
FNXC:MissionAdmission 2026-07-23-19:00:
|
||||
PostgreSQL ID/proposal collisions are discovered at row insert, while a
|
||||
defined-feature bootstrap needs all task artifacts to be writable before
|
||||
its transaction can commit. Materialize into a unique staging directory;
|
||||
only the successful insert atomically promotes it to the task directory.
|
||||
A losing proposal therefore cannot overwrite its winner's task files.
|
||||
*/
|
||||
if (input.proposalClaimId && isTaskIdConflictError(error)) {
|
||||
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) {
|
||||
options?.onProposalClaimConflict?.(existing);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
if (isTaskIdConflictError(error)) {
|
||||
throw new Error(`Task ID already exists: ${task.id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await mkdir(stagingDir, { recursive: true });
|
||||
ownsStagingDirectory = true;
|
||||
|
||||
// FNXC:ReservationAtomicity 2026-07-12-00:00:
|
||||
// Wrap post-insert filesystem/prompt work so any failure rolls back the
|
||||
// inserted row. Without this, a writeTaskJsonFile or prompt-validation throw
|
||||
// leaves a live row paired with an aborted reservation (FN-7074 invariant).
|
||||
try {
|
||||
// Write task.json for backward compatibility and debugging.
|
||||
if (store.isWatching) store.taskCache.set(id, { ...task });
|
||||
await store.writeTaskJsonFile(dir, task);
|
||||
await store.writeTaskJsonFile(stagingDir, task);
|
||||
|
||||
// Write PROMPT.md (same logic as SQLite path).
|
||||
/*
|
||||
@@ -443,20 +450,73 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
|
||||
throw new InvalidFileScopeError(id, validation.invalid);
|
||||
}
|
||||
}
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||
await writeFile(join(stagingDir, "PROMPT.md"), prompt);
|
||||
} catch (error) {
|
||||
// Rollback: soft-delete the inserted row and remove the directory.
|
||||
await softDeleteTaskRowAsync(layer, id, new Date().toISOString());
|
||||
if (store.isWatching) store.taskCache.delete(id);
|
||||
if (existsSync(dir)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await cleanupPreparedTaskFiles();
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
throw new Error(`Task ID already exists: ${task.id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Auto-archive dedup (best-effort, same as SQLite path but using async reads).
|
||||
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input);
|
||||
// FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:30:
|
||||
// Insert the task row via async Drizzle insert inside a transaction.
|
||||
// A duplicate-ID collision raises a unique_violation (23505) which we
|
||||
// catch and surface as "Task ID already exists" (matching the SQLite path).
|
||||
const context = store.createTaskPersistSerializationContext(task);
|
||||
try {
|
||||
await layer.transactionImmediate(async (tx) => {
|
||||
// FNXC:MultiProjectIsolation 2026-07-10: stamp the bound projectId so the
|
||||
// new task row is attributed to (and later filtered under) this project.
|
||||
await insertTaskRowInTransaction(tx, task as unknown as Record<string, unknown>, context, layer.projectId);
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-19:00:
|
||||
The row insert establishes this task as the sole winner before its staged
|
||||
artifacts replace any stale directory. Promotion remains inside the same
|
||||
transaction as the defined-feature claim, so a filesystem or claim
|
||||
failure rolls back the row and cleans only this attempt's files.
|
||||
*/
|
||||
if (existsSync(dir)) await rm(dir, { recursive: true, force: true });
|
||||
await rename(stagingDir, dir);
|
||||
ownsStagingDirectory = false;
|
||||
ownsPromotedTaskDirectory = true;
|
||||
await (input as CreateTaskWithAfterInsert).afterTaskInsert?.(tx, task);
|
||||
});
|
||||
} catch (error) {
|
||||
await cleanupPreparedTaskFiles();
|
||||
/*
|
||||
FNXC:EphemeralAgentTaskCreation 2026-07-30-18:30:
|
||||
Proposal creation retries can race after a creation lease is released while
|
||||
the original creator is still inserting. Both attempts deliberately use the
|
||||
same stable proposalClaimId, so the partial unique index is the at-most-once
|
||||
authority. A 23505 for that key returns the committed winner instead of
|
||||
treating it as an ID collision; no loser may continue into task-file or
|
||||
workflow materialization. Other unique violations remain task-ID errors.
|
||||
*/
|
||||
if (input.proposalClaimId && isTaskIdConflictError(error)) {
|
||||
const existing = (await store.listTasks()).find((candidate) => candidate.proposalClaimId === input.proposalClaimId);
|
||||
if (existing) {
|
||||
options?.onProposalClaimConflict?.(existing);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
if (isTaskIdConflictError(error)) {
|
||||
throw new Error(`Task ID already exists: ${task.id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-20:00:
|
||||
A defined-feature first task has already claimed feature.taskId in the insert
|
||||
transaction. The ordinary same-agent intake may archive that claimed row
|
||||
after commit, so this narrow internal opt-out delegates duplicate resolution
|
||||
to the bootstrap caller, which preserves the claimed canonical atomically.
|
||||
*/
|
||||
if (!(input as CreateTaskWithAfterInsert).skipSameAgentDuplicateIntake) {
|
||||
// Auto-archive dedup (best-effort, same as SQLite path but using async reads).
|
||||
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input);
|
||||
}
|
||||
|
||||
store.emitTaskLifecycleEventSafely("task:created", [task]);
|
||||
if (options?.invokeTaskCreatedHook !== false) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core";
|
||||
import type { Agent, AgentStore, TaskStore, Task, TaskCreateInput } from "@fusion/core";
|
||||
import { createAgentTask, createListAgentsTool, createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js";
|
||||
|
||||
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
|
||||
@@ -553,6 +553,142 @@ describe("createDelegateTaskTool", () => {
|
||||
expect(createInput.sliceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bootstraps a defined feature by linking and promoting its first created task", async () => {
|
||||
const missionStore = {
|
||||
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
|
||||
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
|
||||
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
|
||||
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
|
||||
claimDefinedFeatureTaskInTransaction: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-001", status: "triaged" }),
|
||||
claimDefinedFeatureTask: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-001", status: "triaged" }),
|
||||
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const store = createMockTaskStore({
|
||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||
createTask: vi.fn().mockImplementation(async (input) => {
|
||||
const task = { id: "FN-001", dependencies: [], column: "triage", steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" } as Task;
|
||||
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, task);
|
||||
return task;
|
||||
}),
|
||||
});
|
||||
const result = await createTaskCreateTool(store).execute(
|
||||
"call-1", { description: "Bootstrap the hand-authored feature", mission_lineage: APPROVED_LINEAGE },
|
||||
undefined as any, undefined as any, undefined as any,
|
||||
);
|
||||
|
||||
expect(result).not.toMatchObject({ isError: true });
|
||||
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledWith({}, { featureId: "F-001", taskId: "FN-001", missionId: "M-001", sliceId: "SL-001" });
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ missionId: "M-001", sliceId: "SL-001" }), expect.anything());
|
||||
});
|
||||
|
||||
it("keeps a claimed defined-feature task canonical when a late duplicate appears", async () => {
|
||||
const missionStore = {
|
||||
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
|
||||
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
|
||||
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
|
||||
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
|
||||
claimDefinedFeatureTaskInTransaction: vi.fn().mockResolvedValue({ id: "F-001", taskId: "FN-new", status: "triaged" }),
|
||||
claimDefinedFeatureTask: vi.fn(),
|
||||
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const created = { id: "FN-new", description: "Bootstrap feature", dependencies: [], column: "triage" as const, steps: [], currentStep: 0, log: [], createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z" } as Task;
|
||||
const older = { ...created, id: "FN-old", createdAt: "2026-01-01T00:00:00.000Z" };
|
||||
const store = createMockTaskStore({
|
||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||
createTask: vi.fn().mockImplementation(async (input) => {
|
||||
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, created);
|
||||
return created;
|
||||
}),
|
||||
findRecentTasksByContentFingerprint: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([older, created]),
|
||||
});
|
||||
|
||||
const result = await createTaskCreateTool(store).execute(
|
||||
"call-1", { description: "Bootstrap feature", mission_lineage: APPROVED_LINEAGE },
|
||||
undefined as any, undefined as any, undefined as any,
|
||||
);
|
||||
|
||||
expect(result).not.toMatchObject({ isError: true });
|
||||
expect((result.details as { taskId: string }).taskId).toBe("FN-new");
|
||||
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledOnce();
|
||||
/* FNXC:MissionAdmission 2026-07-23-19:00: a task that atomically claimed feature.taskId must never be archived by post-create duplicate reconciliation. */
|
||||
expect(store.findRecentTasksByContentFingerprint).toHaveBeenCalledTimes(2);
|
||||
expect(missionStore.archiveDefinedFeatureBootstrapDuplicate).toHaveBeenCalledWith({
|
||||
featureId: "F-001", taskId: "FN-new", duplicateTaskId: "FN-old",
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-new", "archived");
|
||||
});
|
||||
|
||||
it("rolls back a newly-created task when defined-feature bootstrap cannot link", async () => {
|
||||
const missionStore = {
|
||||
getFeature: vi.fn().mockResolvedValue({ id: "F-001", sliceId: "SL-001", status: "defined" }),
|
||||
getSlice: vi.fn().mockResolvedValue({ id: "SL-001", milestoneId: "MS-001", status: "active" }),
|
||||
getMilestone: vi.fn().mockResolvedValue({ id: "MS-001", missionId: "M-001", status: "active" }),
|
||||
getMission: vi.fn().mockResolvedValue({ id: "M-001", status: "active" }),
|
||||
claimDefinedFeatureTaskInTransaction: vi.fn().mockRejectedValue(new Error("Feature F-001 is already linked to task FN-OTHER")),
|
||||
claimDefinedFeatureTask: vi.fn(),
|
||||
archiveDefinedFeatureBootstrapDuplicate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const store = createMockTaskStore({
|
||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||
createTask: vi.fn().mockImplementation(async (input) => {
|
||||
await (input as { afterTaskInsert?: (tx: object, created: Task) => Promise<void> }).afterTaskInsert?.({}, { id: "FN-001" } as Task);
|
||||
throw new Error("bootstrap hook unexpectedly succeeded");
|
||||
}),
|
||||
});
|
||||
const result = createTaskCreateTool(store).execute(
|
||||
"call-1", { description: "Bootstrap conflicting feature", mission_lineage: APPROVED_LINEAGE },
|
||||
undefined as any, undefined as any, undefined as any,
|
||||
);
|
||||
|
||||
await expect(result).rejects.toThrow("Feature F-001 is already linked to task FN-OTHER");
|
||||
expect(missionStore.claimDefinedFeatureTaskInTransaction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects a pre-existing same-agent bootstrap duplicate before claiming or creating", async () => {
|
||||
const canonical = {
|
||||
id: "FN-existing", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature",
|
||||
sourceAgentId: "agent-001", dependencies: [], column: "triage" as const, steps: [], currentStep: 0,
|
||||
log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
const store = createMockTaskStore({ listTasks: vi.fn().mockResolvedValue([canonical]) });
|
||||
const validate = vi.fn().mockRejectedValue(new Error("pre-existing task is not linked to this feature"));
|
||||
|
||||
await expect(createAgentTask(store, {
|
||||
title: "Bootstrap feature",
|
||||
description: "Bootstrap the hand-authored feature",
|
||||
source: { sourceType: "api", sourceAgentId: "agent-001" },
|
||||
preflightSameAgentDuplicate: true,
|
||||
validateDuplicateCanonical: validate,
|
||||
} as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise<void> }))
|
||||
.rejects.toThrow("pre-existing task is not linked to this feature");
|
||||
|
||||
expect(validate).toHaveBeenCalledWith(canonical);
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not select an archived same-agent task as a defined-feature bootstrap canonical", async () => {
|
||||
const archived = {
|
||||
id: "FN-archived", title: "Bootstrap feature", description: "Bootstrap the hand-authored feature",
|
||||
sourceAgentId: "agent-001", dependencies: [], column: "archived" as const, steps: [], currentStep: 0,
|
||||
log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
} as Task;
|
||||
const store = createMockTaskStore({ listTasks: vi.fn().mockResolvedValue([archived]) });
|
||||
const validate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await createAgentTask(store, {
|
||||
title: "Bootstrap feature",
|
||||
description: "Bootstrap the hand-authored feature",
|
||||
source: { sourceType: "api", sourceAgentId: "agent-001" },
|
||||
preflightSameAgentDuplicate: true,
|
||||
validateDuplicateCanonical: validate,
|
||||
} as TaskCreateInput & { preflightSameAgentDuplicate: boolean; validateDuplicateCanonical: (task: Task) => Promise<void> });
|
||||
|
||||
/* FNXC:MissionAdmission 2026-07-23-21:10: archived tasks are not live bootstrap canonicals and must not block a valid first task. */
|
||||
expect(result.wasDuplicate).toBe(false);
|
||||
expect(validate).not.toHaveBeenCalled();
|
||||
expect(store.createTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("serializes three concurrent paraphrased creates from one parent", async () => {
|
||||
const tasks: Task[] = [];
|
||||
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockImplementation(async () => tasks);
|
||||
@@ -592,7 +728,11 @@ describe("createDelegateTaskTool", () => {
|
||||
return canonical;
|
||||
});
|
||||
|
||||
const result = await createAgentTask(taskStore, { description: "Add new support" }, { sourceTaskId: "fn-parent" });
|
||||
const validateDuplicateCanonical = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await createAgentTask(taskStore, {
|
||||
description: "Add new support",
|
||||
validateDuplicateCanonical,
|
||||
} as TaskCreateInput & { validateDuplicateCanonical: (task: Task) => Promise<void> }, { sourceTaskId: "fn-parent" });
|
||||
|
||||
expect(taskStore.findRecentTasksBySourceParentTaskId).toHaveBeenCalledWith("FN-PARENT");
|
||||
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -600,6 +740,8 @@ describe("createDelegateTaskTool", () => {
|
||||
proposalClaimId: expect.stringMatching(/^agent-parent-intent:FN-PARENT:/),
|
||||
}), expect.anything());
|
||||
expect(result).toMatchObject({ task: canonical, wasDuplicate: true });
|
||||
/* FNXC:MissionAdmission 2026-07-23-17:20: proposal-claim reuse must validate the final canonical, not only pre-create duplicate probes. */
|
||||
expect(validateDuplicateCanonical).toHaveBeenCalledWith(canonical);
|
||||
});
|
||||
|
||||
it("carries delegation routing onto the reconcile canonical task", async () => {
|
||||
@@ -626,17 +768,21 @@ describe("createDelegateTaskTool", () => {
|
||||
id === "FN-old" ? moved : { ...created, id, column },
|
||||
);
|
||||
|
||||
const validateDuplicateCanonical = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await createAgentTask(taskStore, {
|
||||
description: "Write tests",
|
||||
mission_lineage: APPROVED_LINEAGE,
|
||||
column: "todo",
|
||||
assignedAgentId: "agent-002",
|
||||
});
|
||||
validateDuplicateCanonical,
|
||||
} as TaskCreateInput & { validateDuplicateCanonical: (task: Task) => Promise<void> });
|
||||
|
||||
expect(result.wasDuplicate).toBe(true);
|
||||
expect(result.task).toBe(moved);
|
||||
expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" });
|
||||
expect(taskStore.moveTask).toHaveBeenCalledWith("FN-old", "todo");
|
||||
/* FNXC:MissionAdmission 2026-07-23-17:20: post-create archival reconciliation must validate its returned canonical before duplicate success. */
|
||||
expect(validateDuplicateCanonical).toHaveBeenCalledWith(moved);
|
||||
});
|
||||
|
||||
it("returns success message with task ID and agent name", async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate, MissionWithHierarchy } from "@fusion/core";
|
||||
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate, MissionWithHierarchy, DbTransaction } from "@fusion/core";
|
||||
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
|
||||
import { promoteHeldTask } from "./hold-release.js";
|
||||
import { computeCrossParentDiagnosticClaim, computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||
@@ -979,6 +979,8 @@ type MissionLineageReference = {
|
||||
missionId: string;
|
||||
sliceId: string;
|
||||
featureId: string;
|
||||
/** Defined features are admitted only to atomically claim their first task. */
|
||||
bootstrapDefinedFeature?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1040,14 +1042,108 @@ async function resolveApprovedMissionLineage(
|
||||
const approval = fusionCore.evaluateMissionLineageApproval({
|
||||
feature, slice, milestone, mission, task: {}, planApprovalRequired: false,
|
||||
});
|
||||
if (!approval.approved) return { error: `Mission lineage is not approved (${approval.reason}); no task was created.` };
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-12:00:
|
||||
A hand-authored defined Feature has no first task to link, so scheduler-only
|
||||
approval would dead-end task creation. Admit it solely as a bootstrap claim;
|
||||
symbol-lock admission remains triaged/in-progress in the core predicate.
|
||||
*/
|
||||
if (!approval.approved) {
|
||||
if (approval.reason === "feature-not-implementable" && feature.status === "defined" && !feature.taskId) {
|
||||
return { missionId: mission.id, sliceId: slice.id, featureId: feature.id, bootstrapDefinedFeature: true };
|
||||
}
|
||||
return { error: `Mission lineage is not approved (${approval.reason}); no task was created.` };
|
||||
}
|
||||
return { missionId: mission.id, sliceId: slice.id, featureId: feature.id };
|
||||
}
|
||||
|
||||
type DefinedFeatureBootstrapStore = {
|
||||
claimDefinedFeatureTaskInTransaction: (tx: DbTransaction, input: { featureId: string; taskId: string; missionId: string; sliceId: string }) => Promise<unknown>;
|
||||
claimDefinedFeatureTask: (input: { featureId: string; taskId: string; missionId: string; sliceId: string }) => Promise<unknown>;
|
||||
archiveDefinedFeatureBootstrapDuplicate: (input: { featureId: string; taskId: string; duplicateTaskId: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
type AgentTaskInputWithBootstrap = TaskCreateInput & {
|
||||
afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise<void>;
|
||||
validateDuplicateCanonical?: (task: Task) => Promise<void>;
|
||||
skipSameAgentDuplicateIntake?: boolean;
|
||||
preflightSameAgentDuplicate?: boolean;
|
||||
reconcileCreatedDuplicate?: (duplicate: Task, created: Task) => Promise<void>;
|
||||
};
|
||||
|
||||
function definedFeatureBootstrapInput(store: TaskStore, lineage: MissionLineageReference | null): Pick<AgentTaskInputWithBootstrap, "afterTaskInsert" | "validateDuplicateCanonical" | "skipSameAgentDuplicateIntake" | "preflightSameAgentDuplicate" | "reconcileCreatedDuplicate"> {
|
||||
if (!lineage?.bootstrapDefinedFeature) return {};
|
||||
const missionStore = store.getMissionStore() as Partial<DefinedFeatureBootstrapStore>;
|
||||
if (!missionStore.claimDefinedFeatureTaskInTransaction || !missionStore.claimDefinedFeatureTask || !missionStore.archiveDefinedFeatureBootstrapDuplicate) {
|
||||
throw new Error("Defined-feature bootstrap requires the PostgreSQL mission store; no task was created.");
|
||||
}
|
||||
const claim = (taskId: string) => ({ featureId: lineage.featureId, taskId, missionId: lineage.missionId, sliceId: lineage.sliceId });
|
||||
return {
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-15:30:
|
||||
The first defined-feature task and feature promotion are one PostgreSQL
|
||||
transaction. Do not replace this hook with create-then-link compensation:
|
||||
a failed claim must roll back the task row before any task is observable.
|
||||
*/
|
||||
afterTaskInsert: async (tx, task) => { await missionStore.claimDefinedFeatureTaskInTransaction!(tx, claim(task.id)); },
|
||||
validateDuplicateCanonical: async (task) => { await missionStore.claimDefinedFeatureTask!(claim(task.id)); },
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-20:00:
|
||||
The ordinary same-agent intake runs after task-row commit and could archive
|
||||
feature.taskId. Suppress only that path; deterministic reconciliation below
|
||||
retains the claimed task and atomically archives a late competing duplicate.
|
||||
*/
|
||||
skipSameAgentDuplicateIntake: true,
|
||||
preflightSameAgentDuplicate: true,
|
||||
reconcileCreatedDuplicate: async (duplicate, created) => {
|
||||
await missionStore.archiveDefinedFeatureBootstrapDuplicate!({
|
||||
featureId: lineage.featureId,
|
||||
taskId: created.id,
|
||||
duplicateTaskId: duplicate.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentRouting 2026-07-29-00:00:
|
||||
FN-8207 requires deterministic-duplicate canonical tasks to honor an explicit delegate's owner and todo-column request. Carry both mutations in the engine task-creation seam so every canonical return path is truthful without changing the shared core duplicate-guard API.
|
||||
*/
|
||||
async function findDefinedFeatureBootstrapDuplicate(
|
||||
store: TaskStore,
|
||||
input: TaskCreateInput,
|
||||
sourceAgentId: string | undefined,
|
||||
sourceParentTaskId: string | undefined,
|
||||
): Promise<Task | undefined> {
|
||||
if (!sourceAgentId && !sourceParentTaskId) return undefined;
|
||||
const candidates = await store.listTasks({ slim: true, includeArchived: true, includeDeleted: true });
|
||||
const byId = new Map(candidates.map((task) => [task.id, task]));
|
||||
const matches = findSameAgentDuplicates({
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
sourceParentTaskId,
|
||||
}, candidates.flatMap((task) => {
|
||||
const createdAt = Date.parse(task.createdAt);
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-21:10:
|
||||
Defined-feature retry preflight follows the normal duplicate guard's live
|
||||
task boundary. An archived sibling cannot be a bootstrap canonical because
|
||||
claimDefinedFeatureTask rejects non-live task rows.
|
||||
*/
|
||||
if (Number.isNaN(createdAt) || task.deletedAt || task.column === "archived") return [];
|
||||
return [{
|
||||
id: task.id,
|
||||
title: task.title ?? "",
|
||||
description: task.description,
|
||||
column: task.column,
|
||||
createdAt,
|
||||
sourceAgentId: task.sourceAgentId ?? null,
|
||||
sourceParentTaskId: task.sourceParentTaskId ?? null,
|
||||
}];
|
||||
}), { sourceAgentId: sourceAgentId ?? null });
|
||||
return matches[0] ? byId.get(matches[0].id) : undefined;
|
||||
}
|
||||
|
||||
async function carryCanonicalTaskRouting(
|
||||
store: TaskStore,
|
||||
canonical: Task,
|
||||
@@ -1071,6 +1167,7 @@ export async function createAgentTask(
|
||||
input: TaskCreateInput,
|
||||
options?: AgentTaskCreationOptions,
|
||||
): Promise<{ task: Awaited<ReturnType<TaskStore["createTask"]>>; wasDuplicate: boolean }> {
|
||||
const validateDuplicateCanonical = (input as AgentTaskInputWithBootstrap).validateDuplicateCanonical;
|
||||
const settings = typeof (store as { getSettings?: unknown }).getSettings === "function"
|
||||
? await store.getSettings()
|
||||
: {} as Settings;
|
||||
@@ -1106,6 +1203,7 @@ export async function createAgentTask(
|
||||
|
||||
try {
|
||||
if (guard.action === "duplicate" && guard.existing) {
|
||||
await validateDuplicateCanonical?.(guard.existing);
|
||||
return {
|
||||
task: await carryCanonicalTaskRouting(store, guard.existing, input),
|
||||
wasDuplicate: true,
|
||||
@@ -1130,6 +1228,7 @@ export async function createAgentTask(
|
||||
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
|
||||
const canonical = candidates[0];
|
||||
if (canonical) {
|
||||
await validateDuplicateCanonical?.(canonical);
|
||||
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1161,6 +1260,7 @@ export async function createAgentTask(
|
||||
const match = matches.find((candidate) => !acknowledged.has(candidate.id));
|
||||
const canonical = match ? candidates.find((candidate) => candidate.id === match.id) : undefined;
|
||||
if (canonical) {
|
||||
await validateDuplicateCanonical?.(canonical);
|
||||
return { task: await carryCanonicalTaskRouting(store, canonical, input), wasDuplicate: true };
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1172,6 +1272,21 @@ export async function createAgentTask(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-20:00:
|
||||
Probe same-agent duplicates before a defined Feature is claimed. The generic
|
||||
intake probe happens after commit and can archive feature.taskId; an existing
|
||||
canonical must already belong to this feature or creation fails with no new
|
||||
task, rather than silently repurposing unrelated work.
|
||||
*/
|
||||
if ((input as AgentTaskInputWithBootstrap).preflightSameAgentDuplicate && validateDuplicateCanonical) {
|
||||
const duplicate = await findDefinedFeatureBootstrapDuplicate(store, input, sourceAgentId, sourceParentTaskId);
|
||||
if (duplicate) {
|
||||
await validateDuplicateCanonical(duplicate);
|
||||
return { task: await carryCanonicalTaskRouting(store, duplicate, input), wasDuplicate: true };
|
||||
}
|
||||
}
|
||||
|
||||
const sourceMetadata = {
|
||||
...(effectiveSource?.sourceMetadata ?? {}),
|
||||
...(guard.fingerprint ? { contentFingerprint: guard.fingerprint } : {}),
|
||||
@@ -1220,21 +1335,35 @@ export async function createAgentTask(
|
||||
onProposalClaimConflict: () => { proposalClaimConflict = true; },
|
||||
});
|
||||
|
||||
const reconcileCreatedDuplicate = (input as AgentTaskInputWithBootstrap).reconcileCreatedDuplicate;
|
||||
const reconcile = await reconcileDeterministicDuplicate(store, {
|
||||
createdTask,
|
||||
fingerprint: guard.fingerprint,
|
||||
sourceParentTaskId,
|
||||
logger: log,
|
||||
onDuplicate: reconcileCreatedDuplicate
|
||||
? async (duplicate) => {
|
||||
await reconcileCreatedDuplicate(duplicate, createdTask);
|
||||
return "keep-created";
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
task: proposalClaimConflict
|
||||
? await carryCanonicalTaskRouting(store, createdTask, input)
|
||||
: reconcile.outcome === "archived"
|
||||
? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
|
||||
: reconcile.canonical,
|
||||
wasDuplicate: proposalClaimConflict || reconcile.outcome === "archived",
|
||||
};
|
||||
const wasDuplicate = proposalClaimConflict || reconcile.outcome === "archived" || reconcile.outcome === "kept-duplicate";
|
||||
const canonical = proposalClaimConflict
|
||||
? await carryCanonicalTaskRouting(store, createdTask, input)
|
||||
: reconcile.outcome === "archived"
|
||||
? await carryCanonicalTaskRouting(store, reconcile.canonical, input)
|
||||
: reconcile.canonical;
|
||||
/*
|
||||
FNXC:MissionAdmission 2026-07-23-17:20:
|
||||
A proposal-claim race and post-create reconciliation both select an existing
|
||||
canonical after createTask returns. Revalidate that canonical before reporting
|
||||
duplicate success so a defined feature cannot remain unlinked or claim an
|
||||
archived/unrelated loser.
|
||||
*/
|
||||
if (wasDuplicate) await validateDuplicateCanonical?.(canonical);
|
||||
return { task: canonical, wasDuplicate };
|
||||
} finally {
|
||||
guard.releaseLock();
|
||||
}
|
||||
@@ -1334,6 +1463,7 @@ export function createTaskCreateTool(
|
||||
priority: params.priority,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
|
||||
...definedFeatureBootstrapInput(store, lineage),
|
||||
source: {
|
||||
sourceType: provenance?.sourceType ?? "api",
|
||||
sourceAgentId: provenance?.sourceAgentId,
|
||||
@@ -4672,6 +4802,7 @@ export function createDelegateTaskTool(
|
||||
assignedAgentId: params.agent_id,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
...(lineage ? { missionId: lineage.missionId, sliceId: lineage.sliceId } : {}),
|
||||
...definedFeatureBootstrapInput(taskStore, lineage),
|
||||
source: {
|
||||
sourceType: "api",
|
||||
sourceParentTaskId: options?.sourceTaskId,
|
||||
|
||||
Reference in New Issue
Block a user