diff --git a/.changeset/fn-8545-mission-admission.md b/.changeset/fn-8545-mission-admission.md new file mode 100644 index 0000000000..c91c1d9a8b --- /dev/null +++ b/.changeset/fn-8545-mission-admission.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 573c2d836c..0f423184a3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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: diff --git a/docs/missions.md b/docs/missions.md index dd50e20f58..4b327b4ef1 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 8e704945d8..0593653c0e 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -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); + 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 })).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. diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index 43e82e7405..01f72a5e6c 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -1022,27 +1022,198 @@ export class AsyncMissionStore extends EventEmitter { return outcome.feature; } - async linkFeatureToTask(featureId: string, taskId: string): Promise { - 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 { + /* + 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 = 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 { + 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 { + /* + 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 { + /* + 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 { const feature = await getFeature(this.db, featureId); if (!feature) throw new Error(`Feature ${featureId} not found`); diff --git a/packages/core/src/duplicate-guard.ts b/packages/core/src/duplicate-guard.ts index 1422eed293..48eb996bc6 100644 --- a/packages/core/src/duplicate-guard.ts +++ b/packages/core/src/duplicate-guard.ts @@ -139,8 +139,10 @@ export async function reconcileDeterministicDuplicate( windowMs?: number; sourceParentTaskId?: string | null; logger?: { warn(msg: string, data?: Record): 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, diff --git a/packages/core/src/mission-store.ts b/packages/core/src/mission-store.ts index 8b5640e010..f8efd5134a 100644 --- a/packages/core/src/mission-store.ts +++ b/packages/core/src/mission-store.ts @@ -2461,6 +2461,21 @@ export class MissionStore extends EventEmitter { ); } + 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 diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 123dd45fb7..394bdedd70 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -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; + /** + * 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, 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, 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) { diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index e9d5ce9a3c..8e4a20f67b 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -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 { @@ -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 }).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 }).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 }).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 })) + .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 }); + + /* 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 }, { 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 }); 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 () => { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index e47b942eb2..b523ad7e3f 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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; + claimDefinedFeatureTask: (input: { featureId: string; taskId: string; missionId: string; sliceId: string }) => Promise; + archiveDefinedFeatureBootstrapDuplicate: (input: { featureId: string; taskId: string; duplicateTaskId: string }) => Promise; +}; + +type AgentTaskInputWithBootstrap = TaskCreateInput & { + afterTaskInsert?: (tx: DbTransaction, task: Task) => Promise; + validateDuplicateCanonical?: (task: Task) => Promise; + skipSameAgentDuplicateIntake?: boolean; + preflightSameAgentDuplicate?: boolean; + reconcileCreatedDuplicate?: (duplicate: Task, created: Task) => Promise; +}; + +function definedFeatureBootstrapInput(store: TaskStore, lineage: MissionLineageReference | null): Pick { + if (!lineage?.bootstrapDefinedFeature) return {}; + const missionStore = store.getMissionStore() as Partial; + 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 { + 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>; 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,