From 5a36ac1cd69c402bc03c9a13b7b39b13fc94ea63 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 17:15:29 -0700 Subject: [PATCH] feat(FN-000): project merge requests to workflow work Fusion-Task-Id: FN-000 --- .../s02-merge-request-projection.md | 46 ++++++++ .../__tests__/merge-request-record.test.ts | 110 ++++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 93 ++++++++++++++- packages/core/src/types.ts | 6 + 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md diff --git a/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md b/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md new file mode 100644 index 0000000000..59b86eba17 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s02-merge-request-projection.md @@ -0,0 +1,46 @@ +--- +title: "S02: merge request projection onto work items" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S02 +milestone: "Foundation" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-retry-scheduling-plan +--- + +# S02: merge request projection onto work items + +## Stack Role + +This draft PR reserves the S02 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 + +Foundation + +## Depends On + +S1 workflow work-item schema and store API. + +## Goal + +Project existing merge request records into workflow work-item state so dashboards and schedulers can dual-read before cutover. + +## Expected File Scope + +packages/core/src/store.ts; packages/core/src/task-merge.ts; packages/core/src/types.ts; merge-request and dual-observe tests. + +## Expected Tests + +Projection tests for queued/running/retrying/manual-required/succeeded/exhausted states, hard cancel cancellation, and restart idempotency. + +## Exit Gate + +Every merge request state has a lossless workflow work-item equivalent. + +## 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 018b79c15e..19dea9f5f3 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -69,6 +69,87 @@ describe("TaskStore merge request record + completion handoff marker", () => { expect(store.transitionMergeRequestState(taskId, "succeeded", { now: "2026-05-30T00:00:05.000Z" }).state).toBe("succeeded"); }); + it("projects merge request states onto workflow work items", async () => { + const cases = [ + { mergeState: "queued", workState: "runnable", kind: "merge" }, + { mergeState: "running", workState: "running", kind: "merge" }, + { mergeState: "retrying", workState: "retrying", kind: "merge" }, + { mergeState: "manual-required", workState: "manual-required", kind: "manual-hold" }, + { mergeState: "succeeded", workState: "succeeded", kind: "merge" }, + { mergeState: "exhausted", workState: "exhausted", kind: "merge" }, + { mergeState: "cancelled", workState: "cancelled", kind: "merge" }, + ] as const; + + for (const { mergeState, workState, kind } of cases) { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: mergeState, + attemptCount: 3, + lastError: mergeState === "manual-required" ? "needs human" : "last failure", + now: "2026-05-30T00:00:00.000Z", + }); + + const item = store.projectMergeRequestToWorkflowWorkItem(taskId, { + now: "2026-05-30T00:00:01.000Z", + }); + + expect(item).toMatchObject({ + runId: `merge-request:${taskId}`, + taskId, + nodeId: "builtin.merge.request", + kind, + state: workState, + attempt: 3, + }); + } + }); + + it("projects merge requests idempotently across restart-style replays", async () => { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: "retrying", + attemptCount: 2, + lastError: "network reset", + now: "2026-05-30T00:00:00.000Z", + }); + + const first = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" }); + const second = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:02.000Z" }); + + expect(second?.id).toBe(first?.id); + expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toHaveLength(1); + expect(second).toMatchObject({ state: "retrying", attempt: 2, lastError: "network reset" }); + }); + + it("cancels stale manual-hold projection when the same merge request succeeds", async () => { + const taskId = await createTask(); + store.upsertMergeRequestRecord(taskId, { + state: "manual-required", + attemptCount: 1, + lastError: "needs human", + now: "2026-05-30T00:00:00.000Z", + }); + + const hold = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:01.000Z" }); + store.upsertMergeRequestRecord(taskId, { + state: "succeeded", + attemptCount: 1, + lastError: null, + now: "2026-05-30T00:00:02.000Z", + }); + const merge = store.projectMergeRequestToWorkflowWorkItem(taskId, { now: "2026-05-30T00:00:03.000Z" }); + + expect(merge).toMatchObject({ kind: "merge", state: "succeeded" }); + expect(store.getWorkflowWorkItem(hold?.id ?? "")).toMatchObject({ + kind: "manual-hold", + state: "cancelled", + lastError: "superseded-by-merge-request-projection", + }); + expect(store.listWorkflowWorkItemsForTask(taskId).filter((item) => item.state !== "cancelled")).toEqual([ + expect.objectContaining({ id: merge?.id, kind: "merge", state: "succeeded" }), + ]); + }); + it("rejects invalid merge-request transitions", async () => { const taskId = await createTask(); store.upsertMergeRequestRecord(taskId, { state: "queued" }); @@ -112,4 +193,33 @@ describe("TaskStore merge request record + completion handoff marker", () => { expect(store.getMergeRequestRecord(taskId)?.state).toBe("cancelled"); expect(store.getCompletionHandoffAcceptedMarker(taskId)).toBeNull(); }); + + it("cancels active workflow merge work on user hard-cancel from in-review to todo", 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-1", agentId: "agent-test" }, + }); + store.setCompletionHandoffAcceptedMarker(taskId, { source: "executor:fn_task_done" }); + const mergeWork = store.upsertWorkflowWorkItem({ + runId: "run-merge", + taskId, + nodeId: "builtin.merge.request", + kind: "merge", + state: "running", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-05-30T00:05:00.000Z", + }); + + await store.moveTask(taskId, "todo", { moveSource: "user" }); + + expect(store.getWorkflowWorkItem(mergeWork.id)).toMatchObject({ + state: "cancelled", + leaseOwner: null, + leaseExpiresAt: null, + lastError: "cancelled-by-user-hard-cancel", + }); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4b7d074933..49d5f697bd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 849398ab1b..0202ec11c0 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; @@ -7139,6 +7139,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); } } + this.cancelActiveWorkflowWorkItemsForTask(id, { + kinds: ["merge", "manual-hold"], + now: movedAt, + lastError: "cancelled-by-user-hard-cancel", + }); this.clearCompletionHandoffAcceptedMarker(id); } if (toColumn === "done") { @@ -8844,6 +8849,19 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return state === "succeeded" || state === "failed" || state === "cancelled" || state === "exhausted"; } + private workflowStateForMergeRequestState(state: MergeRequestState): WorkflowWorkItemState { + const states: Record = { + queued: "runnable", + running: "running", + retrying: "retrying", + succeeded: "succeeded", + exhausted: "exhausted", + cancelled: "cancelled", + "manual-required": "manual-required", + }; + return states[state]; + } + private rowToWorkflowWorkItem(row: WorkflowWorkItemRow): WorkflowWorkItem { return { id: row.id, @@ -8952,6 +8970,43 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return row ? this.rowToMergeRequestRecord(row) : null; } + projectMergeRequestToWorkflowWorkItem( + taskId: string, + opts: MergeRequestWorkflowProjectionOptions = {}, + ): WorkflowWorkItem | null { + return this.db.transactionImmediate(() => { + const record = this.getMergeRequestRecord(taskId); + if (!record) return null; + const state = this.workflowStateForMergeRequestState(record.state); + const kind = record.state === "manual-required" ? "manual-hold" : "merge"; + const item = this.upsertWorkflowWorkItem({ + runId: opts.runId ?? `merge-request:${taskId}`, + taskId, + nodeId: opts.nodeId ?? "builtin.merge.request", + kind, + state, + attempt: record.attemptCount, + lastError: record.lastError, + blockedReason: record.state === "manual-required" ? record.lastError ?? "manual merge required" : null, + now: opts.now ?? record.updatedAt, + }); + this.cancelActiveWorkflowWorkItemsForTask(taskId, { + kinds: [kind === "manual-hold" ? "merge" : "manual-hold"], + now: opts.now ?? record.updatedAt, + lastError: "superseded-by-merge-request-projection", + }); + this.insertRunAuditEventRow({ + taskId, + runId: item.runId, + domain: "database", + mutationType: "mergeRequest:workflow-projection", + target: item.id, + metadata: { taskId, mergeRequestState: record.state, workflowState: item.state, workItemKind: item.kind }, + }); + return item; + }); + } + upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem { return this.db.transactionImmediate(() => { const existing = this.db @@ -9073,6 +9128,42 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return row ? this.rowToWorkflowWorkItem(row) : null; } + listWorkflowWorkItemsForTask(taskId: string, opts: { kinds?: WorkflowWorkItemKind[] } = {}): WorkflowWorkItem[] { + const conditions = ["taskId = ?"]; + const params: unknown[] = [taskId]; + if (opts.kinds?.length) { + conditions.push(`kind IN (${opts.kinds.map(() => "?").join(", ")})`); + params.push(...opts.kinds); + } + const rows = this.db + .prepare( + `SELECT * + FROM workflow_work_items + WHERE ${conditions.join(" AND ")} + ORDER BY createdAt ASC, id ASC`, + ) + .all(...params) as WorkflowWorkItemRow[]; + return rows.map((row) => this.rowToWorkflowWorkItem(row)); + } + + cancelActiveWorkflowWorkItemsForTask( + taskId: string, + opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null } = {}, + ): WorkflowWorkItem[] { + return this.db.transactionImmediate(() => { + const activeStates: WorkflowWorkItemState[] = ["runnable", "running", "held", "retrying", "manual-required"]; + const items = this.listWorkflowWorkItemsForTask(taskId, opts).filter((item) => activeStates.includes(item.state)); + return items.map((item) => + this.transitionWorkflowWorkItem(item.id, "cancelled", { + now: opts.now, + leaseOwner: null, + leaseExpiresAt: null, + lastError: opts.lastError ?? item.lastError ?? "cancelled-by-user-hard-cancel", + }), + ); + }); + } + listDueWorkflowWorkItems(filter: WorkflowWorkItemDueFilter = {}): WorkflowWorkItem[] { const now = filter.now ?? new Date().toISOString(); const includeExpiredRunning = !filter.states || filter.states.includes("running"); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a26d15a01a..d8e25cc472 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -160,6 +160,12 @@ export interface WorkflowWorkItemDueFilter { states?: WorkflowWorkItemState[]; } +export interface MergeRequestWorkflowProjectionOptions { + runId?: string; + nodeId?: string; + now?: string; +} + export interface MergeQueueEntry { taskId: string; enqueuedAt: string;