FN-8760: publish workflow lanes for new tasks

Publish durable workflow lane metadata when new tasks wake triage.

- Defer task-created events until selected workflow lanes are durable.
- Cache and emit resolved lifecycle lanes while preserving listener compatibility.
- Prevent proposal-claim replays from issuing duplicate triage wakes and cover custom lanes.

Files changed:
 .changeset/fn-8760-planning-wake.md                |  7 ++++
 .../postgres/task-proposal-claim.pg.test.ts        | 48 +++++++++++++++++++++-
 .../__tests__/task-updated-lanes-payload.test.ts   | 20 ++++++++-
 packages/core/src/store.ts                         | 12 ++++--
 packages/core/src/task-store/task-creation.ts      | 44 ++++++++++++++++----
 packages/engine/src/__tests__/triage.test.ts       | 34 ++++++++++++++-
 6 files changed, 152 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-8760

Fusion-Task-Lineage: 06275d4a-3fc0-405e-baa5-3cdc8d195695

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-03 03:01:24 -07:00
parent b269bff4d4
commit 71ba437cfe
6 changed files with 152 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Schedule Planning Mode-created tasks promptly in selected workflow lanes.
category: fix
dev: Publish task creation lifecycle lanes after durable workflow selection; replayed proposal claims do not re-wake triage.

View File

@@ -7,6 +7,7 @@ already-materialized task rather than surfacing 23505 or creating another row.
*/ */
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import type { WorkflowIr } from "../../workflows/workflow-ir-types.js";
import { import {
createSharedPgTaskStoreTestHarness, createSharedPgTaskStoreTestHarness,
pgDescribe, pgDescribe,
@@ -15,6 +16,36 @@ import {
const pgTest = pgDescribe; const pgTest = pgDescribe;
function planningWorkflowIr(): WorkflowIr {
return {
version: "v2",
name: "planning-wake-lanes",
columns: [
{ id: "planning-inbox", name: "Planning inbox", traits: [{ trait: "intake" }] },
{ id: "ready-to-plan", name: "Ready to plan", traits: [{ trait: "hold", config: { release: "capacity" } }] },
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }, { trait: "merge-blocker" }] },
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
{ id: "archive", name: "Archive", traits: [{ trait: "archived" }] },
],
nodes: [
{ id: "start", kind: "start", column: "planning-inbox" },
{ id: "plan", kind: "prompt", column: "ready-to-plan", config: { name: "Plan", prompt: "Specify." } },
{ id: "build", kind: "prompt", column: "building", config: { name: "Build", prompt: "Implement." } },
{ id: "review", kind: "prompt", column: "checking", config: { name: "Review", prompt: "Review." } },
{ id: "merge", kind: "merge-attempt", column: "checking", config: { capability: "task-merge" } },
{ id: "end", kind: "end", column: "shipped" },
],
edges: [
{ from: "start", to: "plan" },
{ from: "plan", to: "build", condition: "success" },
{ from: "build", to: "review", condition: "success" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
],
} as WorkflowIr;
}
pgTest("TaskStore proposal claim idempotency", () => { pgTest("TaskStore proposal claim idempotency", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_proposal_claim", prefix: "fusion_proposal_claim",
@@ -25,20 +56,31 @@ pgTest("TaskStore proposal claim idempotency", () => {
afterEach(h.afterEach); afterEach(h.afterEach);
afterAll(h.afterAll); afterAll(h.afterAll);
it("returns the existing task when a reclaim races the original proposal insert", async () => { /*
FNXC:PlanningModeScheduling 2026-08-03-09:44:
This uses the real PostgreSQL TaskStore boundary rather than a route mock: a selected workflow
must be durable before task:created exposes its renamed intake/hold lanes, and a proposal replay
must not emit another creation wake that could schedule duplicate planning work.
*/
it("publishes selected workflow lanes once across proposal-claim replay", async () => {
const stableProposalKey = "proposal-reclaim-race-stable-key"; const stableProposalKey = "proposal-reclaim-race-stable-key";
const store = h.store(); const store = h.store();
const definition = await store.createWorkflowDefinition({ name: "Planning wake lanes", ir: planningWorkflowIr() });
const events: Array<{ id: string; lanes?: { intake?: string; hold?: string } }> = [];
store.on("task:created", (task, meta) => events.push({ id: task.id, lanes: meta?.lanes }));
const [originalCreate, reclaimedCreate] = await Promise.all([ const [originalCreate, reclaimedCreate] = await Promise.all([
store.createTask({ store.createTask({
title: "Original proposal materialization", title: "Original proposal materialization",
description: "Original creator resumes after its lease was released.", description: "Original creator resumes after its lease was released.",
proposalClaimId: stableProposalKey, proposalClaimId: stableProposalKey,
workflowId: definition.id,
}), }),
store.createTask({ store.createTask({
title: "Reclaimed proposal materialization", title: "Reclaimed proposal materialization",
description: "Reclaimed creator uses the same stable proposal key.", description: "Reclaimed creator uses the same stable proposal key.",
proposalClaimId: stableProposalKey, proposalClaimId: stableProposalKey,
workflowId: definition.id,
}), }),
]); ]);
@@ -47,5 +89,9 @@ pgTest("TaskStore proposal claim idempotency", () => {
const persisted = (await store.listTasks()).filter((task) => task.proposalClaimId === stableProposalKey); const persisted = (await store.listTasks()).filter((task) => task.proposalClaimId === stableProposalKey);
expect(persisted).toHaveLength(1); expect(persisted).toHaveLength(1);
expect(persisted[0]?.id).toBe(originalCreate.id); expect(persisted[0]?.id).toBe(originalCreate.id);
expect(events).toEqual([{
id: originalCreate.id,
lanes: expect.objectContaining({ intake: "planning-inbox", hold: "ready-to-plan" }),
}]);
}); });
}); });

View File

@@ -4,7 +4,7 @@ import type { Task } from "../types.js";
const task = { id: "FN-lanes", column: "building" } as Task; const task = { id: "FN-lanes", column: "building" } as Task;
describe("task:updated lane payload", () => { describe("task lifecycle lane payload", () => {
it("decorates cache hits while keeping one-argument listeners and misses compatible", () => { it("decorates cache hits while keeping one-argument listeners and misses compatible", () => {
const store = new TaskStore(process.cwd()); const store = new TaskStore(process.cwd());
const received: Array<{ lanes?: { wip?: string } } | undefined> = []; const received: Array<{ lanes?: { wip?: string } } | undefined> = [];
@@ -31,6 +31,24 @@ describe("task:updated lane payload", () => {
expect(received).toEqual({ lanes: { wip: "building" } }); expect(received).toEqual({ lanes: { wip: "building" } });
}); });
/*
FNXC:PlanningModeScheduling 2026-08-03-09:44:
A created task needs the workflow lanes captured at the durable creation boundary; triage cannot
synchronously resolve a custom selection after its wake handler receives the event.
*/
it("delivers resolved lanes with task:created without changing one-argument listeners", () => {
const store = new TaskStore(process.cwd());
const received: Array<{ lanes?: { intake?: string; hold?: string } } | undefined> = [];
let oneArgumentCalls = 0;
store.on("task:created", (_task, meta) => received.push(meta));
store.on("task:created", () => { oneArgumentCalls += 1; });
store.emitTaskLifecycleEventSafely("task:created", [task, { lanes: { intake: "planning-inbox", hold: "ready-to-plan" } }]);
expect(received).toEqual([{ lanes: { intake: "planning-inbox", hold: "ready-to-plan" } }]);
expect(oneArgumentCalls).toBe(1);
});
it("preserves explicit metadata rather than replacing it from cache", () => { it("preserves explicit metadata rather than replacing it from cache", () => {
const store = new TaskStore(process.cwd()); const store = new TaskStore(process.cwd());
store.laneCache.set(task.id, { wip: "cached" }); store.laneCache.set(task.id, { wip: "cached" });

View File

@@ -144,7 +144,13 @@ import type { BranchGroupRow, PrEntityRow, TaskDocumentRow, ArtifactRow, TaskDoc
/** Database row shape for the tasks table (all columns). */ /** Database row shape for the tasks table (all columns). */
export interface TaskStoreEvents { export interface TaskStoreEvents {
"task:created": [task: Task]; /*
FNXC:PlanningModeScheduling 2026-08-03-09:44:
Task creation can select a custom workflow whose planning lanes do not use legacy names.
Creation emits its resolved lanes after the selection is durable, so synchronous triage wake
listeners observe the same authoritative lifecycle vocabulary as task:moved listeners.
*/
"task:created": [task: Task, meta?: { lanes?: TaskMoveLanes }];
/* /*
FNXC:WorkflowEvents 2026-07-31-21:00 (fleet — the emitter carries the lanes): FNXC:WorkflowEvents 2026-07-31-21:00 (fleet — the emitter carries the lanes):
`lanes` is the moving task's RESOLVED lifecycle columns, attached by the emitter. `lanes` is the moving task's RESOLVED lifecycle columns, attached by the emitter.
@@ -1046,7 +1052,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/** /**
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:25: * FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-13:25:
*/ */
public async _createTaskInternalBackend( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; }, ): Promise<Task> { public async _createTaskInternalBackend( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; deferTaskCreatedEvent?: boolean; onTaskInserted?: (task: Task) => void; }, ): Promise<Task> {
return _createTaskInternalBackendImpl(this, input, title, resolvedWorkflowSteps, id, options); return _createTaskInternalBackendImpl(this, input, title, resolvedWorkflowSteps, id, options);
} }
@@ -1062,7 +1068,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async createTaskWithReservedId( input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; invokeTaskCreatedHook?: boolean; }, ): Promise<Task> { async createTaskWithReservedId( input: TaskCreateInput, options: { taskId: string; createdAt?: string; updatedAt?: string; prompt?: string; applyDefaultWorkflowSteps?: boolean; invokeTaskCreatedHook?: boolean; }, ): Promise<Task> {
return createTaskWithReservedIdImpl(this, input, options); return createTaskWithReservedIdImpl(this, input, options);
} }
public async _createTaskInternal( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; }, ): Promise<Task> { public async _createTaskInternal( input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; deferTaskCreatedEvent?: boolean; onTaskInserted?: (task: Task) => void; }, ): Promise<Task> {
/* /*
FNXC:SqliteDualPathCleanup 2026-07-26-14:05: FNXC:SqliteDualPathCleanup 2026-07-26-14:05:
Task create is PostgreSQL-only (layer.transactionImmediate + insertTaskRowInTransaction). The former sync SQLite _createTaskInternalImpl arm is deleted; production always injects AsyncDataLayer. Task create is PostgreSQL-only (layer.transactionImmediate + insertTaskRowInTransaction). The former sync SQLite _createTaskInternalImpl arm is deleted; production always injects AsyncDataLayer.

View File

@@ -24,8 +24,8 @@ import {getErrorMessage} from "../process/error-message.js";
import {generateTaskLineageId} from "../tasks/task-lineage.js"; import {generateTaskLineageId} from "../tasks/task-lineage.js";
import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicates/duplicate-intake.js"; import {archiveAsSameAgentDuplicate, findSameAgentDuplicates, flagSameAgentDuplicate, type SameAgentDuplicateCandidate} from "../duplicates/duplicate-intake.js";
import {buildBootstrapPrompt} from "../mesh/mesh-task-replication.js"; import {buildBootstrapPrompt} from "../mesh/mesh-task-replication.js";
import {resolveWorkflowIrById} from "../workflows/workflow-ir-resolver.js"; import {resolveWorkflowIrById, resolveWorkflowIrForTask} from "../workflows/workflow-ir-resolver.js";
import {resolveTaskLifecycleColumns} from "../workflows/workflow-lifecycle-traits.js"; import {resolveTaskLifecycleColumns, toTaskMoveLanes} from "../workflows/workflow-lifecycle-traits.js";
import type {WorkflowIr} from "../workflows/workflow-ir-types.js"; import type {WorkflowIr} from "../workflows/workflow-ir-types.js";
import {DEFAULT_WORKFLOW_ID} from "../workflows/builtin-workflows.js"; import {DEFAULT_WORKFLOW_ID} from "../workflows/builtin-workflows.js";
import {columnsWithFlag} from "../workflows/workflow-lifecycle-traits.js"; import {columnsWithFlag} from "../workflows/workflow-lifecycle-traits.js";
@@ -320,6 +320,7 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
}); });
let task: Task; let task: Task;
let insertedTask = false;
try { try {
await store.assertNoDependencyCycle(reservation.taskId, input.dependencies ?? [], "createTask"); await store.assertNoDependencyCycle(reservation.taskId, input.dependencies ?? [], "createTask");
task = await store._createTaskInternalBackend( task = await store._createTaskInternalBackend(
@@ -327,7 +328,13 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
title, title,
resolvedWorkflowSteps, resolvedWorkflowSteps,
reservation.taskId, reservation.taskId,
{ invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization, resolvedEntryColumn, onProposalClaimConflict: options?.onProposalClaimConflict }, {
deferTaskCreatedEvent: true,
invokeTaskCreatedHook: shouldInvokeTaskCreatedHook && !hasPendingSummarization,
onProposalClaimConflict: options?.onProposalClaimConflict,
onTaskInserted: () => { insertedTask = true; },
resolvedEntryColumn,
},
); );
await allocator.commitDistributedTaskIdReservation({ await allocator.commitDistributedTaskIdReservation({
reservationId: reservation.reservationId, reservationId: reservation.reservationId,
@@ -354,6 +361,26 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
} }
} }
/*
FNXC:PlanningModeScheduling 2026-08-03-09:44:
A task:created listener runs synchronously and is the authoritative wake for triage admission.
Planning Mode creates through a project-scoped TaskStore, so emitting before the selected
workflow row exists leaves a custom intake lane indistinguishable from an unknown legacy lane.
Persist selection first, then publish the resolved lanes through the shared store event; the
listener only requests its normal poll, preserving pause, dependency, and capacity gates.
Proposal-claim replays return an existing row from the internal create path. They deliberately
do not re-emit task:created, so idempotent Planning Mode retries cannot schedule duplicate work.
*/
if (insertedTask) {
const lanes = toTaskMoveLanes(await resolveWorkflowIrForTask(store, task.id).catch(() => undefined));
store.laneCache.set(task.id, lanes);
store.emitTaskLifecycleEventSafely("task:created", [task, { lanes }]);
if (shouldInvokeTaskCreatedHook && !hasPendingSummarization) {
await store.invokeTaskCreatedHook(task);
}
}
// Deferred title summarization (same fire-and-forget pattern as SQLite path). // Deferred title summarization (same fire-and-forget pattern as SQLite path).
if (hasPendingSummarization && shouldInvokeTaskCreatedHook) { if (hasPendingSummarization && shouldInvokeTaskCreatedHook) {
const id = task.id; const id = task.id;
@@ -411,7 +438,7 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
return task; return task;
} }
export async function _createTaskInternalBackendImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; },): Promise<Task> { export async function _createTaskInternalBackendImpl(store: TaskStore, input: TaskCreateInput, title: string | undefined, resolvedWorkflowSteps: string[] | undefined, id: string, options?: { createdAt?: string; updatedAt?: string; promptOverride?: string; invokeTaskCreatedHook?: boolean; resolvedEntryColumn?: string; onProposalClaimConflict?: (task: Task) => void; deferTaskCreatedEvent?: boolean; onTaskInserted?: (task: Task) => void; },): Promise<Task> {
const layer = store.asyncLayer!; const layer = store.asyncLayer!;
const now = options?.createdAt ?? new Date().toISOString(); const now = options?.createdAt ?? new Date().toISOString();
const normalizedTitle = normalizeTitleForTaskId(title, id); const normalizedTitle = normalizeTitleForTaskId(title, id);
@@ -707,10 +734,13 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta
await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input); await store._maybeAutoArchiveSameAgentDuplicateBackend(task, input);
} }
store.emitTaskLifecycleEventSafely("task:created", [task]); if (!options?.deferTaskCreatedEvent) {
if (options?.invokeTaskCreatedHook !== false) { store.emitTaskLifecycleEventSafely("task:created", [task]);
await store.invokeTaskCreatedHook(task); if (options?.invokeTaskCreatedHook !== false) {
await store.invokeTaskCreatedHook(task);
}
} }
options?.onTaskInserted?.(task);
return task; return task;
} }

View File

@@ -882,7 +882,7 @@ describe("canonical triage policy prompt", () => {
describe("FN-5893 invariant regression wording", () => { describe("FN-5893 invariant regression wording", () => {
const corePromptSource = readFileSync( const corePromptSource = readFileSync(
fileURLToPath(new URL("../../../core/src/agent-prompts.ts", import.meta.url)), fileURLToPath(new URL("../../../core/src/agents/agent-prompts.ts", import.meta.url)),
"utf8", "utf8",
); );
@@ -1759,6 +1759,38 @@ Planner rewrote mission without the raw request.
// Should not throw // Should not throw
}); });
/*
FNXC:PlanningModeScheduling 2026-08-03-09:44:
Planning Mode may create into a selected workflow whose intake and hold lanes are renamed.
The creation event must carry that durable lane answer so the normal wake reaches triage without
a route-specific scheduler call; paused work remains gated before the poll is requested.
*/
it("wakes normal planning admission for a created task in a custom workflow lane", () => {
const listeners = new Map<string, (...args: unknown[]) => void>();
const triageStore = createMockStore({
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
listeners.set(event, listener);
return triageStore;
}),
off: vi.fn(),
});
const triageProcessor = trackProcessor(new TriageProcessor(triageStore, rootDir));
const requestImmediatePoll = vi.spyOn(triageProcessor, "requestImmediatePoll").mockReturnValue(true);
triageProcessor.start();
listeners.get("task:created")?.(
createTriageTask({ id: "FN-PLANNING-CREATED", column: "planning-inbox" }),
{ lanes: { intake: "planning-inbox", hold: "ready-to-plan" } },
);
listeners.get("task:created")?.(
createTriageTask({ id: "FN-PLANNING-PAUSED", column: "planning-inbox", paused: true }),
{ lanes: { intake: "planning-inbox", hold: "ready-to-plan" } },
);
expect(requestImmediatePoll).toHaveBeenCalledTimes(1);
triageProcessor.stop();
});
it("handles settings:updated event for globalPause", () => { it("handles settings:updated event for globalPause", () => {
const handler = vi.fn(); const handler = vi.fn();
(store.on as ReturnType<typeof vi.fn>).mockImplementation( (store.on as ReturnType<typeof vi.fn>).mockImplementation(