From 296f191a83c0a627c1658762479214e34222cb1a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 17:17:34 -0700 Subject: [PATCH] feat(FN-000): create workflow merge work on handoff Fusion-Task-Id: FN-000 --- .../s07-completion-handoff-merge-work.md | 46 +++++++++++++++++ .../__tests__/merge-request-record.test.ts | 50 +++++++++++++++++++ packages/core/src/store.ts | 42 ++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md diff --git a/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md b/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md new file mode 100644 index 0000000000..7ab5820093 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md @@ -0,0 +1,46 @@ +--- +title: "S07: completion handoff creates merge work" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S07 +milestone: "Runtime" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s06-git-merge-capabilities +--- + +# S07: completion handoff creates merge work + +## Stack Role + +This draft PR reserves the S07 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Runtime + +## Depends On + +S2 projection, S5 runtime driver, and S6 merge capabilities. + +## Goal + +Replace task-moved in-review auto-enqueue as policy authority with workflow completion handoff creating merge work. + +## Expected File Scope + +packages/engine/src/project-engine.ts; packages/engine/src/merger.ts; packages/core/src/store.ts; completion and cutover tests. + +## Expected Tests + +Coding completion creates merge work, autoMerge false creates manual hold, duplicate handoff idempotency, soft-delete cancellation, startup projection dedupe. + +## Exit Gate + +New task completions produce workflow merge work before old queue processing runs. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index 19dea9f5f3..2050f62565 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -222,4 +222,54 @@ describe("TaskStore merge request record + completion handoff marker", () => { lastError: "cancelled-by-user-hard-cancel", }); }); + + it("creates idempotent workflow merge work during completion handoff", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:00.000Z", + }); + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:01.000Z", + }); + + expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toEqual([ + expect.objectContaining({ + runId: "run-handoff", + taskId, + nodeId: "merge-gate", + kind: "merge", + state: "runnable", + }), + ]); + }); + + it("creates manual hold workflow work instead of merge work when autoMerge is false", async () => { + const taskId = await createTask(); + await store.updateTask(taskId, { autoMerge: false }); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-manual", agentId: "agent-test" }, + }); + + expect(store.listWorkflowWorkItemsForTask(taskId)).toEqual([ + expect.objectContaining({ + runId: "run-manual", + taskId, + nodeId: "merge-manual-hold", + kind: "manual-hold", + state: "manual-required", + blockedReason: "autoMerge:false", + }), + ]); + }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 0202ec11c0..b6376bc7b6 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6625,6 +6625,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }, }); this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now }); + this.createCompletionHandoffWorkflowWork(task, { + runId: internal.runContext?.runId, + now: internal.now, + source: internal.evidence?.reason, + }); this.insertRunAuditEventRow({ taskId: id, agentId: internal.runContext?.agentId, @@ -7092,6 +7097,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (internal.fromHandoff) { alreadyEnqueued = Boolean(this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id)); this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now }); + this.createCompletionHandoffWorkflowWork(task, { + runId: internal.runContext?.runId, + now: internal.now, + source: internal.evidence?.reason, + }); this.insertRunAuditEventRow({ taskId: id, agentId: internal.runContext?.agentId, @@ -9007,6 +9017,38 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); } + createCompletionHandoffWorkflowWork( + task: Pick, + opts: { runId?: string; now?: string; source?: string } = {}, + ): WorkflowWorkItem { + const autoMerge = task.autoMerge !== false; + const item = this.upsertWorkflowWorkItem({ + runId: opts.runId ?? `completion-handoff:${task.id}`, + taskId: task.id, + nodeId: autoMerge ? "merge-gate" : "merge-manual-hold", + kind: autoMerge ? "merge" : "manual-hold", + state: autoMerge ? "runnable" : "manual-required", + blockedReason: autoMerge ? null : "autoMerge:false", + now: opts.now, + }); + this.insertRunAuditEventRow({ + taskId: task.id, + runId: item.runId, + domain: "database", + mutationType: "workflowWorkItem:completion-handoff", + target: item.id, + metadata: { + taskId: task.id, + autoMerge, + source: opts.source ?? "completion-handoff", + workItemId: item.id, + nodeId: item.nodeId, + state: item.state, + }, + }); + return item; + } + upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem { return this.db.transactionImmediate(() => { const existing = this.db