From 83565a535aa55e6a9bbf013db2238cd5c49d61c3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 8 Jun 2026 23:34:35 -0700 Subject: [PATCH] fix(FN-6035): project workflow dispatch metadata --- .changeset/workflow-projection-capacity.md | 3 + .../builtin-coding-workflow-ir.test.ts | 4 + .../core/src/__tests__/builtin-traits.test.ts | 8 ++ .../core/src/builtin-coding-workflow-ir.ts | 6 +- packages/core/src/builtin-traits.ts | 8 +- packages/core/src/workflow-capacity.ts | 13 ++- .../engine/src/__tests__/scheduler.test.ts | 42 +++++++++ .../workflow-graph-executor-handlers.test.ts | 93 +++++++++++++++++++ packages/engine/src/executor.ts | 21 +++++ packages/engine/src/scheduler.ts | 11 ++- .../engine/src/workflow-graph-executor.ts | 92 +++++++++++++++++- .../engine/src/workflow-graph-task-runner.ts | 8 +- packages/engine/src/workflow-node-handlers.ts | 12 ++- 13 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 .changeset/workflow-projection-capacity.md diff --git a/.changeset/workflow-projection-capacity.md b/.changeset/workflow-projection-capacity.md new file mode 100644 index 0000000000..888c6b2175 --- /dev/null +++ b/.changeset/workflow-projection-capacity.md @@ -0,0 +1,3 @@ +"@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/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/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..8ef04e0232 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,99 @@ 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("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("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..91900c4be5 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -3751,6 +3751,27 @@ export class TaskExecutor { seams: this.createAuthoritativeWorkflowSeams(settings), runCustomNode: (node, nodeTask) => this.runGraphCustomNode(node, nodeTask, settings, resolveBindingForNode(node.id)), + publishTaskProjection: async (taskId, patch) => { + const liveTask = await this.store.getTask(taskId); + 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 } as Task["mergeDetails"]; + } + if (patch.summary !== undefined) update.summary = patch.summary; + if (patch.review !== undefined) update.review = patch.review as unknown as Task["review"]; + if (patch.reviewState !== undefined) update.reviewState = patch.reviewState as unknown as Task["reviewState"]; + if (patch.workflowStepResults !== undefined) update.workflowStepResults = patch.workflowStepResults as Task["workflowStepResults"]; + if (patch.tokenUsage !== undefined) update.tokenUsage = patch.tokenUsage as unknown as Task["tokenUsage"]; + if (patch.error !== undefined) update.error = patch.error; + if (patch.status !== undefined) update.status = patch.status; + if (Object.keys(update).length > 0) { + await this.store.updateTask(taskId, 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..34f77b226d 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -38,6 +38,18 @@ export interface WorkflowNodeResult { contextPatch?: Record; } +export interface WorkflowTaskProjection { + modifiedFiles?: string[]; + mergeDetails?: Record; + summary?: string; + review?: Record; + reviewState?: Record; + workflowStepResults?: unknown[]; + tokenUsage?: Record; + error?: string | null; + status?: string | null; +} + export interface WorkflowNodeExecutionContext { task: TaskDetail; settings: Pick | undefined; @@ -131,6 +143,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 +163,59 @@ 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 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); + if (mergeDetails) patch.mergeDetails = mergeDetails; + if (typeof contextPatch.filesChanged === "number" && Number.isFinite(contextPatch.filesChanged)) { + patch.mergeDetails = { ...(patch.mergeDetails ?? {}), filesChanged: contextPatch.filesChanged }; + } + + if (typeof contextPatch.summary === "string") patch.summary = contextPatch.summary; + const review = objectRecord(contextPatch.review); + if (review) patch.review = review; + const reviewState = objectRecord(contextPatch.reviewState); + if (reviewState) patch.reviewState = reviewState; + if (Array.isArray(contextPatch.workflowStepResults)) patch.workflowStepResults = contextPatch.workflowStepResults; + const tokenUsage = objectRecord(contextPatch.tokenUsage); + if (tokenUsage) patch.tokenUsage = tokenUsage; + if (typeof contextPatch.error === "string" || contextPatch.error === null) patch.error = contextPatch.error; + if (typeof contextPatch.status === "string" || contextPatch.status === null) patch.status = contextPatch.status; + return patch; +} + +function hasTaskProjection(patch: WorkflowTaskProjection): boolean { + return Object.keys(patch).length > 0; +} + export class WorkflowGraphExecutor { private readonly maxRetriesPerNode: number; @@ -611,11 +680,16 @@ 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) { + await this.publishTaskProjectionFromResult(task.id, node, pluginResult); + return 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 }); + await this.publishTaskProjectionFromResult(task.id, node, result); + return result; } catch (error) { lastError = error; } @@ -629,4 +703,18 @@ export class WorkflowGraphExecutor { }, }; } + + private async publishTaskProjectionFromResult( + taskId: string, + node: WorkflowIrNode, + result: WorkflowNodeResult, + ): Promise { + const patch = extractTaskProjection(result.contextPatch); + if (!hasTaskProjection(patch)) return; + const source = { nodeId: node.id, nodeKind: node.kind }; + await this.deps.publishTaskProjection?.(taskId, patch, source); + if (patch.modifiedFiles && patch.modifiedFiles.length > 0) { + await this.deps.publishTouchedFiles?.(taskId, patch.modifiedFiles, source); + } + } } 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 {