diff --git a/.changeset/workflow-projection-capacity.md b/.changeset/workflow-projection-capacity.md new file mode 100644 index 0000000000..c3dc751d9d --- /dev/null +++ b/.changeset/workflow-projection-capacity.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix workflow-native dispatch capacity accounting and publish workflow node task metadata to the existing task fields used by scheduler and dashboard surfaces. diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 2596e31402..8e7ff67cdc 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -58,6 +58,10 @@ describe("builtin coding workflow ir", () => { expect(traitsFor("in-review")).toEqual(["merge-blocker", "human-review", "stall-detection", "merge"]); expect(traitsFor("done")).toEqual(["complete"]); expect(traitsFor("archived")).toEqual(["archived"]); + // in-progress owns the legacy execution concurrency policy in workflow data: + // the limit is supplied by the project maxConcurrent setting. + const wip = byId.get("in-progress")!.traits.find((t) => t.trait === "wip"); + expect(wip?.config).toEqual({ limitSetting: "maxConcurrent", countPending: true }); // todo's hold is capacity-released (legacy "pull from todo when a slot frees"). const hold = byId.get("todo")!.traits.find((t) => t.trait === "hold"); expect(hold?.config?.release).toBe("capacity"); diff --git a/packages/core/src/__tests__/builtin-traits.test.ts b/packages/core/src/__tests__/builtin-traits.test.ts index 5938073064..b8ae0b2217 100644 --- a/packages/core/src/__tests__/builtin-traits.test.ts +++ b/packages/core/src/__tests__/builtin-traits.test.ts @@ -109,6 +109,14 @@ describe("default workflow columns validate cleanly", () => { expect(flags.timing).toBe(true); }); + it("wip trait schema supports explicit settings-backed limits", () => { + const r = freshRegistry(); + const fields = r.getTrait("wip")?.configSchema?.fields ?? []; + const limitSetting = fields.find((field) => field.key === "limitSetting"); + expect(limitSetting?.type).toBe("enum"); + expect(limitSetting?.enumValues).toEqual(["maxConcurrent"]); + }); + it("the default workflow's in-review column resolves review and merge flags", () => { const r = freshRegistry(); const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; diff --git a/packages/core/src/__tests__/store-update.test.ts b/packages/core/src/__tests__/store-update.test.ts index e1e5c1febe..f7749ea711 100644 --- a/packages/core/src/__tests__/store-update.test.ts +++ b/packages/core/src/__tests__/store-update.test.ts @@ -1198,5 +1198,43 @@ Task with acceptance criteria }); }); + describe("updateTaskAtomic", () => { + it("serializes read-merge-write patches against the freshest task snapshot", async () => { + const task = await store.createTask({ description: "atomic task projection" }); + let releaseFirst: () => void = () => {}; + const firstCanFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstRead: () => void = () => {}; + const firstRead = new Promise((resolve) => { + markFirstRead = resolve; + }); + + const first = store.updateTaskAtomic(task.id, async (current) => { + markFirstRead(); + expect(current.modifiedFiles).toBeUndefined(); + await firstCanFinish; + return { + modifiedFiles: [...new Set([...(current.modifiedFiles ?? []), "src/a.ts"])].sort(), + mergeDetails: { ...(current.mergeDetails ?? {}), filesChanged: 1 }, + }; + }); + + await firstRead; + + const second = store.updateTaskAtomic(task.id, (current) => ({ + modifiedFiles: [...new Set([...(current.modifiedFiles ?? []), "src/b.ts"])].sort(), + mergeDetails: { ...(current.mergeDetails ?? {}), insertions: 2 }, + })); + + releaseFirst(); + await Promise.all([first, second]); + + const updated = await store.getTask(task.id); + expect(updated.modifiedFiles).toEqual(["src/a.ts", "src/b.ts"]); + expect(updated.mergeDetails).toEqual({ filesChanged: 1, insertions: 2 }); + }); + }); + }); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 0905ece9b5..ed61d200bc 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -37,7 +37,11 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { { id: "in-progress", name: "In progress", - traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }], + traits: [ + { trait: "wip", config: { limitSetting: "maxConcurrent", countPending: true } }, + { trait: "abort-on-exit" }, + { trait: "timing" }, + ], }, { id: "in-review", diff --git a/packages/core/src/builtin-traits.ts b/packages/core/src/builtin-traits.ts index 957b6735cc..1cf39ff542 100644 --- a/packages/core/src/builtin-traits.ts +++ b/packages/core/src/builtin-traits.ts @@ -77,7 +77,13 @@ export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [ flags: { countsTowardWip: true }, configSchema: { fields: [ - { key: "limit", type: "number", required: true, description: "Max concurrent cards" }, + { key: "limit", type: "number", description: "Max concurrent cards" }, + { + key: "limitSetting", + type: "enum", + enumValues: ["maxConcurrent"], + description: "Project setting that supplies the capacity limit", + }, { key: "countPending", type: "boolean", description: "Count mid-transition cards" }, ], }, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index a5166dcc71..03b98a1abc 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -7445,6 +7445,23 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return this.withTaskLock(id, () => this.updateTaskUnlocked(id, updates, runContext)); } + async updateTaskAtomic( + id: string, + updater: ( + current: Task, + ) => Parameters[1] | null | undefined | Promise[1] | null | undefined>, + runContext?: RunMutationContext, + ): Promise { + return this.withTaskLock(id, async () => { + const current = await this.readTaskJson(this.taskDir(id)); + const updates = await updater(current); + if (!updates || Object.values(updates).every((value) => value === undefined)) { + return current; + } + return this.updateTaskUnlocked(id, updates, runContext); + }); + } + /** * Merge a validated/normalized custom-field patch into the existing values. * `null` in the patch deletes that field's value (the delete sentinel from diff --git a/packages/core/src/workflow-capacity.ts b/packages/core/src/workflow-capacity.ts index e34ae46eab..439ab7c4c4 100644 --- a/packages/core/src/workflow-capacity.ts +++ b/packages/core/src/workflow-capacity.ts @@ -71,10 +71,12 @@ function isDefaultWorkflowColumns(ir: WorkflowIr): boolean { * * Limit resolution order: * 1. An explicit numeric `limit` in the column's `wip` trait config wins. - * 2. Otherwise, for the DEFAULT workflow's `in-progress` column, read through + * 2. A `limitSetting: "maxConcurrent"` declaration reads through to the + * project setting, making the built-in workflow's capacity policy explicit. + * 3. Otherwise, for the DEFAULT workflow's `in-progress` column, read through * to `settings.maxConcurrent` (default 2) so the legacy knob keeps working * and flag-ON default-workflow scheduling matches flag-OFF (legacy parity). - * 3. Otherwise the column has a capacity trait but no resolvable finite limit + * 4. Otherwise the column has a capacity trait but no resolvable finite limit * → `Infinity` (does not gate; the trait is inert until configured). */ export function resolveColumnCapacity( @@ -91,6 +93,7 @@ export function resolveColumnCapacity( // The capacity trait config (the `wip` trait carries `limit` + `countPending`). // Find the first trait config whose trait sets countsTowardWip. let configLimit: number | undefined; + let limitSetting: string | undefined; let countPending = true; for (const ct of column.traits) { const def = getTraitRegistry().getTrait(ct.trait); @@ -99,6 +102,9 @@ export function resolveColumnCapacity( if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) { configLimit = cfg.limit; } + if (typeof cfg.limitSetting === "string") { + limitSetting = cfg.limitSetting; + } if (typeof cfg.countPending === "boolean") { countPending = cfg.countPending; } @@ -108,6 +114,9 @@ export function resolveColumnCapacity( let limit: number; if (configLimit !== undefined) { limit = configLimit; + } else if (limitSetting === "maxConcurrent") { + const maxConcurrent = settings?.maxConcurrent; + limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2; } else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) { // Read-through: legacy maxConcurrent maps onto the default workflow's // in-progress WIP limit (U6 scheduler integration). diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index cdfba9ac16..2a3341e8a7 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -584,6 +584,48 @@ describe("Scheduler", () => { expect(onMoves).toContainEqual(["FN-1", "in-progress"]); }); + it("re-reads tasks after flag-ON hold-release sweep before legacy dispatch", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + const tasks = new Map( + Array.from({ length: 6 }, (_, index) => { + const id = `FN-${String(index + 1).padStart(3, "0")}`; + return [id, createMockTask({ id, column: "todo", dependencies: [] })]; + }), + ); + const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => { + const current = tasks.get(taskId); + if (!current) throw new Error(`missing task ${taskId}`); + if (column === "in-progress") { + const inProgressCount = [...tasks.values()].filter((task) => task.column === "in-progress").length; + if (inProgressCount >= 3) { + throw new Error("capacity-exhausted"); + } + } + const updated = { ...current, column } as Task; + tasks.set(taskId, updated); + return updated; + }); + const store = createMockStore({ + listTasks: vi.fn(async () => [...tasks.values()]), + getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 3, + maxWorktrees: 10, + experimentalFeatures: { workflowColumns: true }, + }), + moveTask, + }); + + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + await scheduler.schedule(); + + expect([...tasks.values()].filter((task) => task.column === "in-progress")).toHaveLength(3); + expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress")).toHaveLength(6); + expect(vi.mocked(store.listTasks).mock.calls.length).toBeGreaterThanOrEqual(2); + }); + it("flag-OFF: todo dispatch is tagged as scheduler-sourced for redispatch guards", async () => { const off = setupTodoStore(false); await off.scheduler.schedule(); diff --git a/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts b/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts index 450e72c7dd..169e515163 100644 --- a/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-executor-handlers.test.ts @@ -117,6 +117,223 @@ describe("WorkflowGraphExecutor traversal", () => { expect(result.visitedNodeIds).not.toContain("right"); }); + it("publishes workflow node task projections for dispatcher and UI", async () => { + const ir: WorkflowIr = { + version: "v1", + name: "projection", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a" }, + { from: "a", to: "end", condition: "success" }, + ], + }; + const publishTaskProjection = vi.fn(); + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async () => ({ + outcome: "success", + contextPatch: { + touchedFiles: ["./packages/engine/src/workflow-graph-executor.ts", "packages\\core\\src\\store.ts"], + filesChanged: 2, + summary: "workflow published task metadata", + }, + }), + }, + publishTaskProjection, + }); + + await executor.run(task, settingsOn(), ir); + + expect(publishTaskProjection).toHaveBeenCalledWith( + task.id, + { + modifiedFiles: ["packages/core/src/store.ts", "packages/engine/src/workflow-graph-executor.ts"], + mergeDetails: { filesChanged: 2 }, + summary: "workflow published task metadata", + }, + { nodeId: "a", nodeKind: "prompt" }, + ); + }); + + it("keeps projection writes to safe task metadata fields", async () => { + const ir: WorkflowIr = { + version: "v1", + name: "safe-projection", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a" }, + { from: "a", to: "end", condition: "success" }, + ], + }; + const publishTaskProjection = vi.fn(); + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async () => ({ + outcome: "success", + contextPatch: { + modifiedFiles: ["src/index.ts"], + mergeDetails: { + commitSha: "engine-owned", + mergeConfirmed: true, + filesChanged: 3, + insertions: 12.8, + deletions: 1, + }, + status: "done", + error: "bypass", + review: {}, + reviewState: {}, + workflowStepResults: [{}], + tokenUsage: {}, + }, + }), + }, + publishTaskProjection, + }); + + await executor.run(task, settingsOn(), ir); + + expect(publishTaskProjection).toHaveBeenCalledWith( + task.id, + { + modifiedFiles: ["src/index.ts"], + mergeDetails: { filesChanged: 3, insertions: 12, deletions: 1 }, + }, + { nodeId: "a", nodeKind: "prompt" }, + ); + }); + + it("publishes projections from loop template nodes", async () => { + const ir: WorkflowIr = { + version: "v2", + name: "loop-projection", + columns: [ + { id: "todo", name: "Todo", traits: [] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { + id: "loop", + kind: "loop", + column: "todo", + config: { + maxIterations: 1, + exitWhen: { type: "output-contains", value: "done" }, + template: { + nodes: [{ id: "inner", kind: "prompt" }], + edges: [], + }, + }, + }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "loop" }, + { from: "loop", to: "end", condition: "success" }, + ], + }; + const publishTaskProjection = vi.fn(); + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async () => ({ + outcome: "success", + value: "done", + contextPatch: { modifiedFiles: ["src/from-loop.ts"] }, + }), + }, + publishTaskProjection, + }); + + await executor.run(task, settingsOn(), ir); + + expect(publishTaskProjection).toHaveBeenCalledWith( + task.id, + { modifiedFiles: ["src/from-loop.ts"] }, + { nodeId: "inner", nodeKind: "prompt" }, + ); + }); + + it("does not retry an already-executed node when projection publishing fails", async () => { + const ir: WorkflowIr = { + version: "v1", + name: "projection-failure", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a" }, + { from: "a", to: "end", condition: "failure" }, + ], + }; + const handler = vi.fn(async () => ({ + outcome: "success" as const, + contextPatch: { modifiedFiles: ["src/once.ts"] }, + })); + const publishTaskProjection = vi.fn(async () => { + throw new Error("store unavailable"); + }); + const executor = new WorkflowGraphExecutor({ + handlers: { prompt: handler }, + maxRetriesPerNode: 3, + publishTaskProjection, + }); + + const result = await executor.run(task, settingsOn(), ir); + + expect(handler).toHaveBeenCalledTimes(1); + expect(publishTaskProjection).toHaveBeenCalledTimes(1); + expect(result.outcome).toBe("failure"); + expect(result.context["node:a:projectionError"]).toBe("store unavailable"); + }); + + it("does not fail the node when the deprecated touched-files hook fails", async () => { + const ir: WorkflowIr = { + version: "v1", + name: "legacy-touched-files-failure", + nodes: [ + { id: "start", kind: "start" }, + { id: "a", kind: "prompt" }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "a" }, + { from: "a", to: "end", condition: "success" }, + ], + }; + const publishTaskProjection = vi.fn(); + const publishTouchedFiles = vi.fn(async () => { + throw new Error("legacy sink unavailable"); + }); + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async () => ({ + outcome: "success", + contextPatch: { modifiedFiles: ["src/projected.ts"] }, + }), + }, + publishTaskProjection, + publishTouchedFiles, + }); + + const result = await executor.run(task, settingsOn(), ir); + + expect(publishTaskProjection).toHaveBeenCalledTimes(1); + expect(publishTouchedFiles).toHaveBeenCalledTimes(1); + expect(result.outcome).toBe("success"); + expect(result.context["node:a:projectionError"]).toBeUndefined(); + }); + it("caps retries and converts exceptions to failure", async () => { const ir: WorkflowIr = { version: "v1", diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index f3f00d539d..05f86305cb 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -3751,6 +3751,20 @@ export class TaskExecutor { seams: this.createAuthoritativeWorkflowSeams(settings), runCustomNode: (node, nodeTask) => this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)), + publishTaskProjection: async (taskId, patch) => { + await this.store.updateTaskAtomic(taskId, (liveTask) => { + const update: Parameters[1] = {}; + if (patch.modifiedFiles) { + const merged = [...new Set([...(liveTask.modifiedFiles ?? []), ...patch.modifiedFiles])].sort(); + if (merged.length > 0) update.modifiedFiles = merged; + } + if (patch.mergeDetails) { + update.mergeDetails = { ...(liveTask.mergeDetails ?? {}), ...patch.mergeDetails }; + } + if (patch.summary !== undefined) update.summary = patch.summary; + return update; + }); + }, onEvent: (event) => executorLog.log(`[workflow-graph] ${event.type} ${event.taskId}: ${event.detail}`), // Wire SQLite-backed per-branch persistence in production (#1407): the // executor writes each branch's currentNodeId/status to diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 5a6554b9eb..4501e22e08 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -1225,10 +1225,8 @@ export class Scheduler { this.scheduling = true; try { - const tasks = await this.store.listTasks({ slim: true, includeArchived: false }); - const settings = await this.store.getSettings(); - const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2; - const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4; + let tasks = await this.store.listTasks({ slim: true, includeArchived: false }); + let settings = await this.store.getSettings(); this.idleSemaphoreLeakCandidateSince = recoverIdleSemaphoreLeak( this.options.semaphore, tasks, @@ -1275,8 +1273,13 @@ export class Scheduler { // workflow hold handling and the generalized capacity-release path. if (isWorkflowColumnsEnabled(settings)) { await this.runHoldReleaseSweepPass(); + tasks = await this.store.listTasks({ slim: true, includeArchived: false }); + settings = await this.store.getSettings(); } + const maxConcurrent = settings.maxConcurrent ?? this.options.maxConcurrent ?? 2; + const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4; + // Count only in-progress tasks toward the worktree limit. // In-review tasks with worktrees are idle (waiting to merge) and // should not block new tasks from starting. diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 83c152907c..b7f642b996 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -38,6 +38,16 @@ export interface WorkflowNodeResult { contextPatch?: Record; } +export interface WorkflowTaskProjection { + modifiedFiles?: string[]; + mergeDetails?: { + filesChanged?: number; + insertions?: number; + deletions?: number; + }; + summary?: string; +} + export interface WorkflowNodeExecutionContext { task: TaskDetail; settings: Pick | undefined; @@ -131,6 +141,10 @@ export interface WorkflowGraphExecutorDeps { resumeReconcile?: ForeachEnvironment["resumeReconcile"]; /** FIX 4 (context gap): task-level log sink for integration-conflict rework. */ logTaskEntry?: ForeachEnvironment["logTaskEntry"]; + /** Project node-published task metadata onto the task row for dispatcher/UI. */ + publishTaskProjection?: (taskId: string, patch: WorkflowTaskProjection, source: { nodeId: string; nodeKind: WorkflowIrNode["kind"] }) => void | Promise; + /** @deprecated use publishTaskProjection. Kept for older callers. */ + publishTouchedFiles?: (taskId: string, files: string[], source: { nodeId: string; nodeKind: WorkflowIrNode["kind"] }) => void | Promise; } export interface WorkflowGraphExecutorResult { @@ -147,6 +161,66 @@ const TERMINAL_FAILURE: WorkflowGraphExecutorResult = { visitedNodeIds: [], }; +function normalizeTouchedFile(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); + return trimmed.length > 0 ? trimmed : undefined; + } + if (value && typeof value === "object" && "path" in value) { + return normalizeTouchedFile((value as { path?: unknown }).path); + } + return undefined; +} + +function extractTouchedFiles(contextPatch: Record | undefined): string[] { + if (!contextPatch) return []; + const raw = contextPatch.modifiedFiles ?? contextPatch.touchedFiles ?? contextPatch.changedFiles; + if (!Array.isArray(raw)) return []; + return [...new Set(raw.map(normalizeTouchedFile).filter((file): file is string => file !== undefined))].sort(); +} + +function objectRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function finiteCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function extractTaskProjection(contextPatch: Record | undefined): WorkflowTaskProjection { + if (!contextPatch) return {}; + const patch: WorkflowTaskProjection = {}; + const files = extractTouchedFiles(contextPatch); + if (files.length > 0) patch.modifiedFiles = files; + + const mergeDetails = objectRecord(contextPatch.mergeDetails); + const safeMergeDetails = { + filesChanged: finiteCount(contextPatch.filesChanged ?? mergeDetails?.filesChanged), + insertions: finiteCount(mergeDetails?.insertions), + deletions: finiteCount(mergeDetails?.deletions), + }; + if ( + safeMergeDetails.filesChanged !== undefined + || safeMergeDetails.insertions !== undefined + || safeMergeDetails.deletions !== undefined + ) { + patch.mergeDetails = Object.fromEntries( + Object.entries(safeMergeDetails).filter(([, value]) => value !== undefined), + ) as NonNullable; + } + + if (typeof contextPatch.summary === "string") patch.summary = contextPatch.summary; + return patch; +} + +function hasTaskProjection(patch: WorkflowTaskProjection): boolean { + return Object.keys(patch).length > 0; +} + export class WorkflowGraphExecutor { private readonly maxRetriesPerNode: number; @@ -611,11 +685,14 @@ export class WorkflowGraphExecutor { if (signal?.aborted) return { outcome: "failure", value: "aborted" }; try { const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal); - if (pluginResult) return pluginResult; + if (pluginResult) { + return await this.publishTaskProjectionFromResult(task.id, node, pluginResult); + } if (!handler) { throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`); } - return await handler(node, { task, settings, context, signal }); + const result = await handler(node, { task, settings, context, signal }); + return await this.publishTaskProjectionFromResult(task.id, node, result); } catch (error) { lastError = error; } @@ -629,4 +706,34 @@ export class WorkflowGraphExecutor { }, }; } + + private async publishTaskProjectionFromResult( + taskId: string, + node: WorkflowIrNode, + result: WorkflowNodeResult, + ): Promise { + const patch = extractTaskProjection(result.contextPatch); + if (!hasTaskProjection(patch)) return result; + const source = { nodeId: node.id, nodeKind: node.kind }; + try { + await this.deps.publishTaskProjection?.(taskId, patch, source); + } catch (error) { + return { + outcome: "failure", + value: "projection-error", + contextPatch: { + ...(result.contextPatch ?? {}), + [`node:${node.id}:projectionError`]: error instanceof Error ? error.message : String(error), + }, + }; + } + if (patch.modifiedFiles && patch.modifiedFiles.length > 0) { + try { + await this.deps.publishTouchedFiles?.(taskId, patch.modifiedFiles, source); + } catch { + // Deprecated compatibility hook; primary projection persistence owns node outcome. + } + } + return result; + } } diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index bdfc6477ef..83d0347be0 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -1,7 +1,7 @@ import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core"; import { getBuiltinWorkflow, isBuiltinWorkflowId, isExperimentalFeatureEnabled } from "@fusion/core"; -import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js"; +import { WorkflowGraphExecutor, type WorkflowNodeOutcome, type WorkflowTaskProjection } from "./workflow-graph-executor.js"; import type { CodeNodeRunner, ForeachActiveContext, @@ -85,6 +85,10 @@ export interface WorkflowGraphTaskRunnerDeps { resumeReconcile?: ForeachEnvironment["resumeReconcile"]; /** FIX 4 (context gap): task-level log sink for integration-conflict rework. */ logTaskEntry?: ForeachEnvironment["logTaskEntry"]; + /** Project node-published task metadata onto the task row for dispatcher/UI. */ + publishTaskProjection?: (taskId: string, patch: WorkflowTaskProjection, source: { nodeId: string; nodeKind: string }) => void | Promise; + /** @deprecated use publishTaskProjection. */ + publishTouchedFiles?: (taskId: string, files: string[], source: { nodeId: string; nodeKind: string }) => void | Promise; /** * Step-inversion (KTD-6): the production run id, threaded from the caller so it * is the SINGLE source of truth shared with the executor-side persistence deps @@ -229,6 +233,8 @@ export class WorkflowGraphTaskRunner { semaphoreAvailability: this.deps.semaphoreAvailability, resumeReconcile: this.deps.resumeReconcile, logTaskEntry: this.deps.logTaskEntry, + publishTaskProjection: this.deps.publishTaskProjection, + publishTouchedFiles: this.deps.publishTouchedFiles, // Single source of truth (KTD-6): prefer the caller-threaded run id so the // executor's persistence deps probe/flip rows under the SAME id; fall back // to the canonical derivation when unthreaded. diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index ae62911aca..eef8535382 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -325,10 +325,20 @@ export function createPrimitivePromptLikeHandler( }; } const result = await primitives.runCodingSession(primitiveCtx, context.task, prepared.data); - const contextPatch = prepared.contextPatch || result.contextPatch + const sessionPatch: Record = {}; + if (result.data?.modifiedFiles && result.data.modifiedFiles.length > 0) { + sessionPatch.modifiedFiles = result.data.modifiedFiles; + } else if (prepared.data.modifiedFiles && prepared.data.modifiedFiles.length > 0) { + sessionPatch.modifiedFiles = prepared.data.modifiedFiles; + } + if (result.data?.summary) { + sessionPatch.summary = result.data.summary; + } + const contextPatch = prepared.contextPatch || result.contextPatch || Object.keys(sessionPatch).length > 0 ? { ...(prepared.contextPatch ?? {}), ...(result.contextPatch ?? {}), + ...sessionPatch, } : undefined; return {