@zq-silk/yui 0.15.12 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/ARCHITECTURE.zh-CN.md +3 -2
  3. package/dist/artifacts/artifactCommitLock.js +16 -55
  4. package/dist/artifacts/taskArtifactRepository.js +3 -3
  5. package/dist/cli/agentConfigurationPicker.js +1 -1
  6. package/dist/cli/commandCatalog.js +57 -41
  7. package/dist/cli/completion.js +18 -18
  8. package/dist/cli/completionWizard.js +1 -1
  9. package/dist/cli/dynamicCompletion.js +1 -1
  10. package/dist/cli/interactionPolicy.js +7 -3
  11. package/dist/cli/invocationAuthority.js +64 -0
  12. package/dist/cli/managedDiagnostics.js +1 -1
  13. package/dist/cli/parseRepeatable.js +35 -0
  14. package/dist/cli/updatePorts.js +26 -5
  15. package/dist/cli.js +1280 -1243
  16. package/dist/commands/agentCommands.js +4 -4
  17. package/dist/commands/capabilityCommands.js +1 -1
  18. package/dist/commands/configCommands.js +6 -57
  19. package/dist/commands/configOverview.js +2 -2
  20. package/dist/commands/durableJobCommands.js +12 -6
  21. package/dist/commands/globalRoleCommands.js +53 -29
  22. package/dist/commands/grantCommands.js +0 -3
  23. package/dist/commands/jobCommands.js +1 -4
  24. package/dist/commands/operatorCommands.js +3 -3
  25. package/dist/commands/projectCommands.js +1 -10
  26. package/dist/commands/resourcesCommands.js +10 -40
  27. package/dist/commands/roleConfiguration.js +2 -6
  28. package/dist/commands/taskActivationCommands.js +4 -8
  29. package/dist/commands/taskAgentCapabilities.js +9 -0
  30. package/dist/commands/taskCommandSupport.js +155 -0
  31. package/dist/commands/taskCommandTypes.js +1 -0
  32. package/dist/commands/taskCommands.js +271 -710
  33. package/dist/commands/taskCompletionGate.js +1 -1
  34. package/dist/commands/taskExecutionCommands.js +1 -1
  35. package/dist/commands/taskFactCommands.js +325 -0
  36. package/dist/commands/taskInputCommands.js +12 -42
  37. package/dist/commands/taskIntegrationCommands.js +17 -57
  38. package/dist/commands/taskNextActionCommand.js +7 -6
  39. package/dist/commands/taskPublicationAdoptCommand.js +3 -3
  40. package/dist/commands/taskPublicationCommands.js +1 -1
  41. package/dist/commands/taskPublicationVerifyCommand.js +1 -1
  42. package/dist/commands/taskRemoteDeliveryCommand.js +2 -94
  43. package/dist/commands/taskUpstreamCommands.js +19 -8
  44. package/dist/commands/workflowCommands.js +6 -1
  45. package/dist/completion/completionInstaller.js +26 -26
  46. package/dist/completion/completionState.js +26 -26
  47. package/dist/completion/fileCompletionManager.js +6 -11
  48. package/dist/config/configCatalog.js +0 -2
  49. package/dist/config/timeZone.js +14 -0
  50. package/dist/config/yuiConfig.js +0 -29
  51. package/dist/context/runContextPack.js +2 -3
  52. package/dist/context/taskCatalog.js +3 -5
  53. package/dist/context/taskContext.js +139 -151
  54. package/dist/context/wakeRunReferences.js +11 -0
  55. package/dist/controller/agentCapabilities.js +58 -0
  56. package/dist/controller/agentRuntimeObserver.js +6 -11
  57. package/dist/controller/clientRuntime.js +22 -14
  58. package/dist/controller/controller.js +9 -22
  59. package/dist/controller/fileSchedulerStoreAdapter.js +117 -212
  60. package/dist/controller/jobClient.js +4 -4
  61. package/dist/controller/jobControl.js +60 -25
  62. package/dist/controller/jobSupervisor.js +4 -4
  63. package/dist/controller/providerRetryDelivery.js +7 -7
  64. package/dist/controller/runtime.js +51 -44
  65. package/dist/controller/runtimeEventProcessor.js +0 -5
  66. package/dist/controller/sessionOwnerReconciliation.js +5 -0
  67. package/dist/controller/taskAgentError.js +35 -0
  68. package/dist/coordination/workMailboxQueue.js +2 -24
  69. package/dist/core/controllerClient.js +2 -2
  70. package/dist/core/fileLockOwner.js +74 -0
  71. package/dist/decision/decision.js +17 -0
  72. package/dist/doctor/doctor.js +18 -9
  73. package/dist/event/taskEvent.js +12 -0
  74. package/dist/execution/workItemExecutionProjection.js +2 -2
  75. package/dist/executor/agentCapabilityConfig.js +43 -0
  76. package/dist/executor/agentConfigurationCatalog.js +49 -11
  77. package/dist/executor/agentExecutor.js +5 -8
  78. package/dist/executor/fileRoleLaunchPlanner.js +11 -53
  79. package/dist/executor/taskAgentCapabilities.js +64 -0
  80. package/dist/integration/deliveryObligation.js +1 -69
  81. package/dist/integration/gitIntegrationService.js +60 -84
  82. package/dist/integration/integrationAttempt.js +3 -14
  83. package/dist/integration/integrationSourceApplication.js +4 -12
  84. package/dist/integration/manifestTags.js +2 -2
  85. package/dist/job/durableJob.js +4 -25
  86. package/dist/job/jobAssignmentScope.js +22 -0
  87. package/dist/job/jobOperation.js +16 -0
  88. package/dist/job/jobRunner.js +2 -1
  89. package/dist/job/stepDirectory.js +15 -0
  90. package/dist/kernel/builtinCapabilities.js +15 -11
  91. package/dist/kernel/kernelPorts.js +1 -17
  92. package/dist/lifecycle/exactRunTerminalization.js +1 -9
  93. package/dist/message/inputControlResolution.js +3 -4
  94. package/dist/message/message.js +17 -20
  95. package/dist/message/messageContinuation.js +1 -1
  96. package/dist/milestone/milestone.js +11 -0
  97. package/dist/observability/orchestrationMetrics.js +24 -1
  98. package/dist/observability/runtimeIdentity.js +1 -17
  99. package/dist/output/agentConfigurationPresentation.js +2 -0
  100. package/dist/output/timePresentation.js +1 -14
  101. package/dist/plugins/pluginService.js +12 -4
  102. package/dist/release/releaseWorkflowPorts.js +45 -96
  103. package/dist/release/runtimeRelease.js +9 -15
  104. package/dist/repository/projectMaintenanceLock.js +23 -64
  105. package/dist/repository/taskBaseFreshness.js +1 -1
  106. package/dist/repository/taskWorkspaceCoordinator.js +13 -22
  107. package/dist/repository/taskWorkspacePreparer.js +29 -25
  108. package/dist/repository/workspaceCleanupInspection.js +5 -5
  109. package/dist/resources/autoResourceGc.js +4 -77
  110. package/dist/resources/liveReferences.js +7 -54
  111. package/dist/resources/resourceDiscovery.js +5 -118
  112. package/dist/resources/resourceGc.js +233 -181
  113. package/dist/resources/resourceRegistrar.js +5 -4
  114. package/dist/resources/resourceRegistry.js +3 -37
  115. package/dist/resources/resourceRegistryStore.js +13 -36
  116. package/dist/resources/sqliteResourceRegistry.js +43 -26
  117. package/dist/review/deltaRecheck.js +1 -1
  118. package/dist/review/reviewAcceptance.js +1 -1
  119. package/dist/review/reviewDecision.js +1 -1
  120. package/dist/review/reviewRound.js +8 -7
  121. package/dist/review/taskFinalReviewContractResolution.js +1 -1
  122. package/dist/runtime/acpProtocol.js +4 -51
  123. package/dist/runtime/acpSession.js +10 -75
  124. package/dist/runtime/acpSessionConfiguration.js +1 -8
  125. package/dist/runtime/agentEndpoint.js +3 -2
  126. package/dist/runtime/agentFailureContext.js +43 -0
  127. package/dist/runtime/agentHost.js +15 -15
  128. package/dist/runtime/codexInteractiveHost.js +2 -2
  129. package/dist/runtime/index.js +0 -1
  130. package/dist/runtime/managedCaller.js +21 -1
  131. package/dist/runtime/providerErrors.js +38 -0
  132. package/dist/runtime/runtimeObservation.js +0 -5
  133. package/dist/runtime/runtimeSessionCandidate.js +2 -3
  134. package/dist/runtime/sessionOwnerIdentity.js +1 -1
  135. package/dist/runtime/structuredProviderHost.js +4 -41
  136. package/dist/runtime/tmuxAdapters.js +6 -8
  137. package/dist/scheduler/activeRoleRunDelivery.js +7 -7
  138. package/dist/scheduler/leaderWakeupProcessor.js +19 -6
  139. package/dist/scheduler/operatorEvent.js +12 -20
  140. package/dist/scheduler/operatorInputNotificationProcessor.js +1 -1
  141. package/dist/scheduler/ports.js +5 -8
  142. package/dist/scheduler/roleRunLiveness.js +4 -4
  143. package/dist/scheduler/roleRunStall.js +16 -23
  144. package/dist/scheduler/taskExecutionProjection.js +25 -33
  145. package/dist/scheduler/taskObservabilityProjection.js +0 -1
  146. package/dist/scheduler/taskWake.js +5 -10
  147. package/dist/scheduler/wakeReason.js +2 -0
  148. package/dist/scheduler/wakeupQueue.js +2 -4
  149. package/dist/setup/setupCommand.js +1 -1
  150. package/dist/storage/contextRecords.js +106 -0
  151. package/dist/storage/currentTaskStore.js +1 -1
  152. package/dist/storage/homeLayout.js +13 -17
  153. package/dist/storage/migrations/agentFailureContext.js +22 -0
  154. package/dist/storage/migrations/currentInputContract.js +86 -0
  155. package/dist/storage/migrations/currentRuntimeContract.js +228 -0
  156. package/dist/storage/migrations/historicalVerificationPlan.js +35 -0
  157. package/dist/storage/migrations/integrationContinuation.js +5 -4
  158. package/dist/storage/migrations/narrowAgentFailureContext.js +65 -0
  159. package/dist/storage/migrations/notificationOnlyWakes.js +74 -0
  160. package/dist/storage/migrations/verificationPlanV1.js +162 -0
  161. package/dist/storage/migrations/verificationPolicy.js +74 -0
  162. package/dist/storage/migrations/workItemHistory.js +46 -0
  163. package/dist/storage/persistenceWorker.js +12 -4
  164. package/dist/storage/recordValidation.js +87 -0
  165. package/dist/storage/sqliteSchema.js +142 -1
  166. package/dist/storage/sqliteStore.js +156 -162
  167. package/dist/storage/storageSchema.js +2 -14
  168. package/dist/storage/storageVersions.js +1 -1
  169. package/dist/storage/storeRpc.js +15 -7
  170. package/dist/storage/taskStore.js +3 -7
  171. package/dist/storage/upgrade/upgradeOrchestrator.js +25 -37
  172. package/dist/task/completionReadiness.js +5 -24
  173. package/dist/task/draftPlan.js +0 -53
  174. package/dist/task/nextAction.js +7 -13
  175. package/dist/task/remoteDeliveryService.js +89 -0
  176. package/dist/task/taskActivation.js +2 -25
  177. package/dist/task/taskActivationService.js +0 -2
  178. package/dist/task/taskRecordReference.js +0 -1
  179. package/dist/task/taskSubmission.js +3 -14
  180. package/dist/telemetry/sqliteTelemetryBatch.js +29 -0
  181. package/dist/telemetry/sqliteTelemetryStore.js +49 -49
  182. package/dist/telemetry/telemetryWiring.js +4 -3
  183. package/dist/tmux/tmuxManager.js +29 -20
  184. package/dist/verification/gateArtifact.js +15 -24
  185. package/dist/verification/gateArtifactStore.js +14 -7
  186. package/dist/verification/verificationGateService.js +29 -88
  187. package/dist/verification/verificationPlan.js +27 -72
  188. package/dist/web/assets/client/app.js +1 -1
  189. package/dist/web/assets/client/taskSurface.js +4 -4
  190. package/dist/web/webServer.js +4 -6
  191. package/dist/web/webSnapshot.js +3 -83
  192. package/dist/web/webTaskSurface.js +17 -46
  193. package/dist/workItem/workItem.js +1 -12
  194. package/docs/architecture/capabilities-and-resources.md +14 -2
  195. package/docs/architecture/capabilities-and-resources.zh-CN.md +11 -1
  196. package/docs/managed-turn-and-session-runtime.md +83 -10
  197. package/docs/managed-turn-and-session-runtime.zh-CN.md +65 -11
  198. package/docs/plugin-sdk.md +4 -2
  199. package/docs/provider-runtime.md +3 -1
  200. package/docs/provider-runtime.zh-CN.md +4 -2
  201. package/docs/release-workflow.md +190 -7
  202. package/docs/release-workflow.zh-CN.md +139 -5
  203. package/docs/roles-and-configuration.md +29 -0
  204. package/docs/roles-and-configuration.zh-CN.md +21 -0
  205. package/docs/sqlite-control-plane-design.md +16 -3
  206. package/docs/sqlite-control-plane-design.zh-CN.md +13 -2
  207. package/docs/task-dag-semantics.md +3 -3
  208. package/docs/task-dag-semantics.zh-CN.md +2 -2
  209. package/docs/task-delivery.md +91 -5
  210. package/docs/task-delivery.zh-CN.md +72 -4
  211. package/docs/task-discovery.md +10 -11
  212. package/docs/task-discovery.zh-CN.md +8 -9
  213. package/docs/testing/verification-levels.md +145 -9
  214. package/docs/testing/verification-levels.zh-CN.md +103 -8
  215. package/package.json +1 -1
  216. package/skills/yui-leader/SKILL.md +5 -0
  217. package/skills/yui-leader/references/integration.md +9 -5
  218. package/skills/yui-leader/references/planning.md +9 -3
  219. package/skills/yui-operator/SKILL.md +15 -10
  220. package/skills/yui-runtime/SKILL.md +6 -6
  221. package/skills/yui-runtime/references/recovery.md +47 -0
  222. package/dist/agent/agentRegistry.js +0 -10
  223. package/dist/agentRun/runIdentity.js +0 -22
  224. package/dist/commands/deliveryGuardPreflight.js +0 -30
  225. package/dist/commands/taskIntegrationQueueCommands.js +0 -226
  226. package/dist/commands/taskOverviewCommand.js +0 -377
  227. package/dist/completion/completionWizard.js +0 -125
  228. package/dist/context/dispatchContext.js +0 -110
  229. package/dist/context/wakeNotification.js +0 -144
  230. package/dist/integration/integrationCheckEvidenceReuse.js +0 -53
  231. package/dist/integration/integrationQueueEntry.js +0 -191
  232. package/dist/integration/integrationQueueService.js +0 -810
  233. package/dist/release/fakeReleasePorts.js +0 -55
  234. package/dist/runtime/sessionOwnerRegistry.js +0 -137
  235. package/dist/task/deliveryGuard.js +0 -226
  236. /package/dist/{commands/taskActor.js → task/taskAuthority.js} +0 -0
@@ -1,810 +0,0 @@
1
- import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
2
- import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
3
- import { createIntegrationAttempt, recordResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
4
- import { createConvergedIntegrationQueueEntry, createIntegrationQueueEntry, markIntegrationQueueBlocked, markIntegrationQueueCommitted, markIntegrationQueueRequeued, markIntegrationQueueRunning, markIntegrationQueueSuperseded, recordIntegrationQueueAffectedPaths, recordIntegrationQueueAttempt } from "./integrationQueueEntry.js";
5
- import { GitIntegrationService } from "./gitIntegrationService.js";
6
- import { governingWorkItemCandidate } from "../workItem/workItem.js";
7
- /**
8
- * Producer WorkItem statuses that close the WorkItem lifecycle. A ChangeSet
9
- * captured from a WorkItem that reached one of these without ever producing a
10
- * Candidate has no deliverable to integrate: landing it would break Task-final
11
- * provenance, which traces every queued ChangeSet back to its WorkItem's
12
- * Candidate.
13
- */
14
- const TERMINAL_PRODUCER_STATUSES = new Set([
15
- "accepted",
16
- "retired"
17
- ]);
18
- /**
19
- * Only a ChangeSet whose producer WorkItem reached a deliverable may enter the
20
- * queue. In-progress WorkItems are left to the capture path, which only runs
21
- * once a Candidate exists; the re-check inside the write transaction closes
22
- * the TOCTOU window between this read and the entry creation.
23
- */
24
- function assertEnqueueableProducer(store, taskId, changeSet) {
25
- const workItem = store.getWorkItem(taskId, changeSet.workItemId);
26
- if (workItem === null) {
27
- throw new Error(`ChangeSet producer WorkItem not found: ${changeSet.workItemId}.`);
28
- }
29
- if (workItem.candidates.length === 0
30
- && TERMINAL_PRODUCER_STATUSES.has(workItem.status)) {
31
- throw new Error(`ChangeSet producer WorkItem has no terminal Candidate: ${workItem.id}/${workItem.status}.`);
32
- }
33
- }
34
- /**
35
- * Synchronous Task-active fence usable inside a write transaction. The Task
36
- * may retire between the pre-flight read and the commit point; a terminal Task
37
- * must not gain new queue entries or Integration Attempts.
38
- */
39
- function assertTaskActive(store, taskId) {
40
- const task = store.getTask(taskId);
41
- if (task === null) {
42
- throw new Error(`Task not found: ${taskId}.`);
43
- }
44
- if (task.status !== "active") {
45
- throw new Error(`Task is not active: ${task.id}/${task.status}.`);
46
- }
47
- }
48
- /**
49
- * Synchronous current-Candidate fence usable inside a write transaction.
50
- * Rejects a running WorkItem (no current Candidate) and a ChangeSet whose
51
- * headCommit no longer matches the latest immutable Candidate snapshot.
52
- * Returns without error when the Candidate carries no head snapshot: the
53
- * async workspace fallback in assertCurrentCandidateHead covers that case
54
- * outside the transaction.
55
- */
56
- function assertCurrentCandidateInStore(store, taskId, changeSet) {
57
- const workItem = store.getWorkItem(taskId, changeSet.workItemId);
58
- if (workItem === null) {
59
- throw new Error(`ChangeSet producer WorkItem not found: ${changeSet.workItemId}.`);
60
- }
61
- // A running WorkItem has no current Candidate: a new one is expected.
62
- if (workItem.status === "open" && workItem.currentCandidateId === undefined) {
63
- throw new Error(`ChangeSet ${changeSet.id} has no current Candidate: `
64
- + `WorkItem ${workItem.id} is running.`);
65
- }
66
- const latestCandidate = governingWorkItemCandidate(workItem);
67
- if (latestCandidate !== undefined) {
68
- const snapshotHead = candidateHeadCommit(latestCandidate, changeSet.projectId);
69
- if (snapshotHead !== undefined && snapshotHead !== changeSet.headCommit) {
70
- throw new Error(`ChangeSet ${changeSet.id} was captured from a superseded Candidate: `
71
- + `current Candidate head is ${snapshotHead}, ChangeSet head is ${changeSet.headCommit}.`);
72
- }
73
- }
74
- }
75
- /**
76
- * A ChangeSet captured from a since-superseded Candidate must not enter the
77
- * queue. The fence binds to the latest immutable Candidate snapshot: a
78
- * running WorkItem has no current Candidate, and a ChangeSet whose headCommit
79
- * no longer matches the latest Candidate (or the workspace HEAD when the
80
- * Candidate carries no head snapshot) was captured from an earlier Candidate.
81
- * When the workspace exists but cannot be inspected, fail closed.
82
- */
83
- async function assertCurrentCandidateHead(store, taskId, changeSet, git) {
84
- assertCurrentCandidateInStore(store, taskId, changeSet);
85
- // Fall back to the managed workspace HEAD when the Candidate carries no
86
- // head-commit snapshot. Fail closed: a workspace that exists but cannot
87
- // be inspected blocks enqueue.
88
- const workspace = store.getManagedWorkspace({
89
- type: "work-item",
90
- taskId,
91
- workItemId: changeSet.workItemId
92
- });
93
- if (workspace === null)
94
- return;
95
- const entry = workspace.entries.find((e) => e.projectId === changeSet.projectId);
96
- if (entry === undefined)
97
- return;
98
- let currentHead;
99
- try {
100
- currentHead = (await git.inspect(entry.path)).baseCommit;
101
- }
102
- catch {
103
- throw new Error(`ChangeSet ${changeSet.id} cannot be verified: `
104
- + `workspace ${entry.path} exists but cannot be inspected.`);
105
- }
106
- if (currentHead !== changeSet.headCommit) {
107
- throw new Error(`ChangeSet ${changeSet.id} was captured from a superseded Candidate: `
108
- + `workspace HEAD is ${currentHead}, ChangeSet head is ${changeSet.headCommit}.`);
109
- }
110
- }
111
- function candidateHeadCommit(candidate, projectId) {
112
- const gitSnapshotHead = candidate.gitSnapshot?.projects
113
- .find((p) => p.projectId === projectId)?.commit;
114
- if (gitSnapshotHead !== undefined)
115
- return gitSnapshotHead;
116
- const taskMainHead = candidate.taskMainSnapshot?.projects
117
- .find((p) => p.projectId === projectId)?.headCommit;
118
- if (taskMainHead !== undefined)
119
- return taskMainHead;
120
- return undefined;
121
- }
122
- /**
123
- * Enqueue one ChangeSet. Enqueue is idempotent per (Project, ChangeSet): an
124
- * existing non-superseded entry is returned instead of duplicated. When the
125
- * ChangeSet is already represented on the target (an ancestor or a same-tree
126
- * commit) the entry converges directly to committed with its proof.
127
- */
128
- export async function enqueueIntegrationQueueEntry(input) {
129
- const now = input.now ?? (() => new Date());
130
- const git = input.git ?? new NodeGitWorkspace();
131
- const task = input.store.getTask(input.taskId);
132
- if (task === null)
133
- throw new Error(`Task not found: ${input.taskId}.`);
134
- if (task.status !== "active") {
135
- throw new Error(`Task is not active: ${task.id}/${task.status}.`);
136
- }
137
- const changeSet = input.store.getChangeSet(task.id, input.changeSetId);
138
- if (changeSet === null)
139
- throw new Error(`ChangeSet not found: ${input.changeSetId}.`);
140
- if (changeSet.projectId !== input.projectId) {
141
- throw new Error(`ChangeSet belongs to another Project: ${changeSet.projectId}.`);
142
- }
143
- if (!task.projectBindings.some(({ projectId }) => projectId === input.projectId)) {
144
- throw new Error(`Project does not belong to Task: ${input.projectId}.`);
145
- }
146
- // Producer fence: only a ChangeSet whose WorkItem reached a deliverable may
147
- // enter the queue. Re-checked inside the write transaction.
148
- assertEnqueueableProducer(input.store, task.id, changeSet);
149
- // Current-Candidate fence: a ChangeSet captured from a since-superseded
150
- // Candidate must not enter the queue. Runs before the duplicate fast path
151
- // so a retry-window WorkItem cannot resurrect a stale ChangeSet that was
152
- // enqueued before its Candidate was superseded.
153
- await assertCurrentCandidateHead(input.store, task.id, changeSet, git);
154
- // Fast path: a duplicate already visible returns without any git inspection.
155
- // The authoritative re-check happens inside the write transaction so two
156
- // concurrent enqueues cannot both create entries for the same ChangeSet.
157
- const existing = findActiveQueueDuplicate(input.store, task.id, input.projectId, input.changeSetId);
158
- if (existing !== undefined) {
159
- // A conflicting idempotency retry must fail closed, even on the fast
160
- // path: a caller that enqueues with a different explicit gate is either
161
- // mistaken or trying to bypass the original gate. Reject instead of
162
- // silently discarding the requested checks. A retry with no gate is a
163
- // plain idempotent lookup and returns the existing entry.
164
- const requestedChecks = input.checkCommands ?? [];
165
- if (requestedChecks.length > 0
166
- && (existing.checkCommands.length !== requestedChecks.length
167
- || existing.checkCommands.some((cmd, i) => cmd !== requestedChecks[i]))) {
168
- throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${existing.id} `
169
- + `with checkCommands [${existing.checkCommands.join(", ")}]; `
170
- + `a conflicting retry with [${requestedChecks.join(", ")}] is not allowed.`);
171
- }
172
- // A conflicting explicit target must also fail closed: a caller that
173
- // enqueues for a different branch is either mistaken or trying to land
174
- // the same ChangeSet on two targets through one entry. A retry with no
175
- // explicit target is a plain idempotent lookup.
176
- if (input.targetRef !== undefined) {
177
- const requestedTarget = canonicalizeTargetRef(input.targetRef);
178
- if (requestedTarget !== existing.targetRef) {
179
- throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${existing.id} `
180
- + `with targetRef ${existing.targetRef}; `
181
- + `a conflicting retry with targetRef ${requestedTarget} is not allowed.`);
182
- }
183
- }
184
- return {
185
- entry: existing,
186
- outcome: existing.status === "committed" ? "already-committed" : "already-queued"
187
- };
188
- }
189
- const project = input.store.getProject(input.projectId);
190
- if (project === null)
191
- throw new Error(`Project not found: ${input.projectId}.`);
192
- const targetRef = input.targetRef
193
- ?? changeSet.manifest.targetRef
194
- ?? taskMainBranch(input.store, task.id, input.projectId);
195
- if (targetRef === undefined) {
196
- throw new Error(`Task main worktree is not ready; reconcile the Task first: ${task.id}.`);
197
- }
198
- const canonicalTargetRef = canonicalizeTargetRef(targetRef);
199
- const repositoryPath = taskMainRepositoryPath(input.store, task.id, project.id);
200
- const targetHead = (await git.inspect(repositoryPath, exactBranchRef(canonicalTargetRef))).baseCommit;
201
- let equivalent = await findEquivalentCommit(git, repositoryPath, changeSet.headCommit, targetHead, changeSet.baseCommit);
202
- if (equivalent === null) {
203
- // The identical change may already have landed through another commit
204
- // while the target also moved on with unrelated work: the whole-tree
205
- // check misses it, but the touched paths already agree.
206
- equivalent = await findContainedChangeSet(git, repositoryPath, changeSet, targetHead);
207
- }
208
- return input.store.transactionAsync(async (tx) => {
209
- const duplicate = findActiveQueueDuplicate(tx, task.id, input.projectId, input.changeSetId);
210
- if (duplicate !== undefined) {
211
- // A conflicting idempotency retry must fail closed: a caller that
212
- // enqueues with a different explicit gate is either mistaken or trying
213
- // to bypass the original gate. Reject instead of silently discarding
214
- // the requested checks. A retry with no gate is a plain idempotent
215
- // lookup and returns the existing entry.
216
- const requestedChecks = input.checkCommands ?? [];
217
- if (requestedChecks.length > 0
218
- && (duplicate.checkCommands.length !== requestedChecks.length
219
- || duplicate.checkCommands.some((cmd, i) => cmd !== requestedChecks[i]))) {
220
- throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${duplicate.id} `
221
- + `with checkCommands [${duplicate.checkCommands.join(", ")}]; `
222
- + `a conflicting retry with [${requestedChecks.join(", ")}] is not allowed.`);
223
- }
224
- // A conflicting explicit target must also fail closed. A retry with
225
- // no explicit target is a plain idempotent lookup.
226
- if (input.targetRef !== undefined && canonicalTargetRef !== duplicate.targetRef) {
227
- throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${duplicate.id} `
228
- + `with targetRef ${duplicate.targetRef}; `
229
- + `a conflicting retry with targetRef ${canonicalTargetRef} is not allowed.`);
230
- }
231
- return {
232
- entry: duplicate,
233
- outcome: duplicate.status === "committed" ? "already-committed" : "already-queued"
234
- };
235
- }
236
- // Re-verify the Task is still active inside the write transaction:
237
- // the Task may have retired between the pre-flight read and this commit.
238
- assertTaskActive(tx, task.id);
239
- // Re-verify the producer inside the write transaction: the WorkItem may
240
- // have retired (or been deleted) between the read above and this commit.
241
- assertEnqueueableProducer(tx, task.id, changeSet);
242
- // Re-verify the current-Candidate fence inside the write transaction:
243
- // the WorkItem may have entered retry (status running) between the
244
- // async target inspection and this commit.
245
- assertCurrentCandidateInStore(tx, task.id, changeSet);
246
- const id = tx.nextIntegrationQueueEntryId(task.id);
247
- const requestedChecks = input.checkCommands ?? [];
248
- // The async proof above observed the target before this write
249
- // transaction. A concurrent landing may have advanced it since; a
250
- // stale containment proof cannot terminalize the entry against the
251
- // old head, so re-read inside the transaction and invalidate the
252
- // proof if it moved.
253
- let effectiveTargetHead = targetHead;
254
- if (equivalent !== null) {
255
- effectiveTargetHead = (await git.inspect(repositoryPath, exactBranchRef(canonicalTargetRef))).baseCommit;
256
- if (effectiveTargetHead !== targetHead) {
257
- equivalent = null;
258
- }
259
- else {
260
- // The inspect above is itself async: a concurrent landing can
261
- // advance the target during the call, so the returned head may
262
- // already be stale. Verify with a second read; if it moved, the
263
- // convergence proof is stale and the entry must queue.
264
- const verifiedHead = (await git.inspect(repositoryPath, exactBranchRef(canonicalTargetRef))).baseCommit;
265
- if (verifiedHead !== effectiveTargetHead) {
266
- equivalent = null;
267
- effectiveTargetHead = verifiedHead;
268
- }
269
- }
270
- }
271
- if (equivalent === null || requestedChecks.length > 0) {
272
- const entry = createIntegrationQueueEntry({
273
- id,
274
- taskId: task.id,
275
- projectId: input.projectId,
276
- changeSetId: input.changeSetId,
277
- targetRef: canonicalTargetRef,
278
- checkCommands: requestedChecks
279
- }, now());
280
- tx.saveIntegrationQueueEntry(task.id, entry);
281
- return { entry, outcome: "queued" };
282
- }
283
- // The ChangeSet is already represented on the target and no gate was
284
- // requested, so applying it would be a no-op and the target does not move.
285
- // Still record the one integration authority every acceptance and
286
- // provenance consumer reads: a committed IntegrationAttempt against the
287
- // exact observed target head, with the converged queue entry pointing at
288
- // it. No gate runs because there is nothing new to apply and none was
289
- // requested; the entry's proof string says why.
290
- //
291
- // Each inspect above is async: a concurrent landing can advance the
292
- // target after the last read returns but before this transaction
293
- // commits. A stale convergence proof would terminalize the entry
294
- // against a head that no longer contains the candidate, so linearize
295
- // with an atomic compare-and-swap on the target ref (the same
296
- // `git update-ref` CAS that GitIntegrationService uses for actual
297
- // landings). The CAS succeeds only when the ref still points at the
298
- // expected head; a concurrent advance between the last inspect and
299
- // this call makes it fail, and the entry falls back to queued so the
300
- // process path re-evaluates at the new head.
301
- const attempt = updateIntegrationAttempt(createIntegrationAttempt({
302
- id: tx.nextIntegrationAttemptId(task.id),
303
- taskId: task.id,
304
- projectId: input.projectId,
305
- targetRef: canonicalTargetRef,
306
- beforeCommit: effectiveTargetHead,
307
- source: {
308
- kind: "work-item",
309
- workItemId: changeSet.workItemId,
310
- startCommit: changeSet.baseCommit,
311
- resultCommit: changeSet.headCommit,
312
- strategy: "cherry-pick"
313
- },
314
- checkCommands: []
315
- }, now()), {
316
- status: "committed",
317
- candidateCommit: effectiveTargetHead,
318
- afterCommit: effectiveTargetHead,
319
- summary: `WorkItem ${changeSet.workItemId} result was already represented; Task head was unchanged.`
320
- }, now());
321
- const convergedEntry = createConvergedIntegrationQueueEntry({
322
- id,
323
- taskId: task.id,
324
- projectId: input.projectId,
325
- changeSetId: input.changeSetId,
326
- targetRef: canonicalTargetRef,
327
- targetHead: effectiveTargetHead,
328
- proof: equivalent === changeSet.headCommit
329
- ? `ancestor-convergence:${equivalent}`
330
- : `tree-convergence:${equivalent}`,
331
- integrationAttemptId: attempt.id
332
- }, now());
333
- try {
334
- await git.assertRefAt(repositoryPath, exactBranchRef(canonicalTargetRef), effectiveTargetHead);
335
- }
336
- catch {
337
- const queuedEntry = createIntegrationQueueEntry({
338
- id,
339
- taskId: task.id,
340
- projectId: input.projectId,
341
- changeSetId: input.changeSetId,
342
- targetRef: canonicalTargetRef,
343
- checkCommands: requestedChecks
344
- }, now());
345
- tx.saveIntegrationQueueEntry(task.id, queuedEntry);
346
- return { entry: queuedEntry, outcome: "queued" };
347
- }
348
- tx.saveIntegrationAttempt(task.id, attempt);
349
- tx.saveIntegrationQueueEntry(task.id, convergedEntry);
350
- return { entry: convergedEntry, outcome: "converged" };
351
- });
352
- }
353
- function findActiveQueueDuplicate(store, taskId, projectId, changeSetId) {
354
- return store.listIntegrationQueueEntries(taskId)
355
- .find((entry) => entry.projectId === projectId
356
- && entry.changeSetId === changeSetId
357
- && entry.status !== "superseded");
358
- }
359
- /**
360
- * Process queued items in id order. Each item gets a fresh IntegrationAttempt
361
- * on the exact current target; conflicts and gate failures map the item to
362
- * `conflicted` without touching the others. After every commit the remaining
363
- * items recompute overlap diagnostics.
364
- *
365
- * A lane (one Project/targetRef pair) integrates one item at a time: while an
366
- * item is `running` — claimed by this or another processor — no other item of
367
- * the lane is selected, and the claim re-checks the barrier inside its write
368
- * transaction so two store instances cannot run items of the same target
369
- * concurrently. The barrier covers the whole lane regardless of id order, so
370
- * a requeued predecessor is serialized behind a successor that started first.
371
- */
372
- export async function processIntegrationQueue(store, home, taskId, options = {}) {
373
- const now = options.now ?? (() => new Date());
374
- const git = options.git ?? new NodeGitWorkspace();
375
- const task = store.getTask(taskId);
376
- if (task === null)
377
- throw new Error(`Task not found: ${taskId}.`);
378
- if (task.status !== "active") {
379
- throw new Error(`Task is not active: ${task.id}/${task.status}.`);
380
- }
381
- const processed = [];
382
- for (;;) {
383
- if (options.limit !== undefined && processed.length >= options.limit)
384
- break;
385
- await reconcileTerminalAttempts(store, task.id, git, now);
386
- // The store already returns entries in numeric id order; trust it instead
387
- // of re-sorting with a lexicographic compare (which yields 1, 10, 2, ...).
388
- // A running entry is the persistent head of its lane: its successors are
389
- // skipped here so another processor's in-flight item is never claimed
390
- // past.
391
- const candidate = firstClaimableQueueEntry(store.listIntegrationQueueEntries(task.id), options.projectId);
392
- if (candidate === undefined)
393
- break;
394
- const project = store.getProject(candidate.projectId);
395
- if (project === null)
396
- throw new Error(`Project not found: ${candidate.projectId}.`);
397
- const repositoryPath = taskMainRepositoryPath(store, task.id, project.id);
398
- const targetBefore = (await git.inspect(repositoryPath, exactBranchRef(candidate.targetRef))).baseCommit;
399
- // CAS claim: re-read the entry inside the write transaction. Another
400
- // store instance may have taken it since selection; if the status changed,
401
- // do not create an Attempt and re-select instead. The running barrier is
402
- // re-checked here as well: an earlier lane mate that started running
403
- // between selection and claim serializes this entry behind it.
404
- const claimed = store.transaction((tx) => {
405
- const current = tx.getIntegrationQueueEntry(task.id, candidate.id);
406
- if (current === null || current.status !== "queued") {
407
- return { skipped: true };
408
- }
409
- if (laneBlockedByRunningEntry(tx.listIntegrationQueueEntries(task.id), current)) {
410
- return { skipped: true };
411
- }
412
- // Task-active fence: the Task may have retired between the pre-flight
413
- // read and this commit. A terminal Task must not gain a new running
414
- // Integration Attempt. This throws (not caught by the Candidate-fence
415
- // handler below) so the caller sees the rejection.
416
- assertTaskActive(tx, task.id);
417
- // Current-Candidate fence: a queued ChangeSet whose producer WorkItem
418
- // entered retry (or whose latest Candidate no longer matches) must not
419
- // advance the target. Mark it conflicted so the Leader can supersede
420
- // or re-enqueue a fresh ChangeSet.
421
- const changeSet = tx.getChangeSet(task.id, current.changeSetId);
422
- try {
423
- if (changeSet === null) {
424
- throw new Error(`ChangeSet not found: ${current.changeSetId}.`);
425
- }
426
- assertCurrentCandidateInStore(tx, task.id, changeSet);
427
- }
428
- catch (error) {
429
- const running = markIntegrationQueueRunning(current, targetBefore, now());
430
- tx.saveIntegrationQueueEntry(task.id, running);
431
- tx.saveIntegrationQueueEntry(task.id, markIntegrationQueueBlocked(running, error instanceof Error ? error.message : String(error), now()));
432
- return { skipped: true };
433
- }
434
- if (changeSet === null) {
435
- throw new Error(`ChangeSet not found after validation: ${current.changeSetId}.`);
436
- }
437
- const attempt = createIntegrationAttempt({
438
- id: tx.nextIntegrationAttemptId(task.id),
439
- taskId: task.id,
440
- projectId: current.projectId,
441
- targetRef: current.targetRef,
442
- beforeCommit: targetBefore,
443
- source: {
444
- kind: "work-item",
445
- workItemId: changeSet.workItemId,
446
- startCommit: changeSet.baseCommit,
447
- resultCommit: changeSet.headCommit,
448
- strategy: "cherry-pick"
449
- },
450
- checkCommands: current.checkCommands
451
- }, now());
452
- tx.saveIntegrationAttempt(task.id, attempt);
453
- const entry = recordIntegrationQueueAttempt(markIntegrationQueueRunning(current, targetBefore, now()), attempt.id, now());
454
- tx.saveIntegrationQueueEntry(task.id, entry);
455
- return { skipped: false, entry, attempt };
456
- });
457
- if (claimed.skipped)
458
- continue;
459
- const result = await new GitIntegrationService(home, store, git, now, options.environment)
460
- .integrate(task.id, claimed.attempt.id);
461
- const settled = store.transaction((tx) => {
462
- let entry = claimed.entry;
463
- if (result.status === "committed") {
464
- entry = markIntegrationQueueCommitted(entry, committedTargetAfter(result.attempt), now());
465
- }
466
- else if (result.status === "blocked" || result.status === "conflicted") {
467
- entry = markIntegrationQueueBlocked(entry, result.attempt.conflict?.summary
468
- ?? result.attempt.summary ?? "Inspect the unfinished Integration.", now());
469
- }
470
- else {
471
- entry = markIntegrationQueueBlocked(entry, gateFailureSummary(result.attempt), now());
472
- }
473
- tx.saveIntegrationQueueEntry(task.id, entry);
474
- return entry;
475
- });
476
- if (result.status === "committed") {
477
- await recomputeAffectedPaths(store, task.id, settled, taskMainRepositoryPath(store, task.id, settled.projectId), git, now);
478
- }
479
- processed.push({ entry: settled, attempt: result.attempt, result });
480
- }
481
- return processed;
482
- }
483
- /**
484
- * A queue lane serializes one target ref: entries for the same Project and
485
- * targetRef integrate one at a time in id order. Entries of different lanes
486
- * (another Project or ref) do not share a target and may integrate alongside.
487
- */
488
- /**
489
- * Canonicalise a Git ref so that "master" and "refs/heads/master" share one
490
- * lane. Only the `refs/heads/` prefix is stripped; other ref namespaces
491
- * (tags, remotes) are kept distinct.
492
- */
493
- function canonicalizeTargetRef(ref) {
494
- return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
495
- }
496
- /**
497
- * Resolve a canonical target ref to its exact Git ref for inspection. A
498
- * short name or `refs/heads/*` resolves to the exact branch ref, so a
499
- * same-named tag cannot shadow the branch. Other fully-qualified refs
500
- * keep their exact meaning.
501
- */
502
- function exactBranchRef(canonicalRef) {
503
- return canonicalRef.startsWith("refs/") ? canonicalRef : `refs/heads/${canonicalRef}`;
504
- }
505
- function queueLaneKey(entry) {
506
- return `${entry.projectId} ${canonicalizeTargetRef(entry.targetRef)}`;
507
- }
508
- /**
509
- * The first entry a processor may claim: the earliest queued entry
510
- * whose lane has no running entry anywhere in it. A running entry serializes
511
- * its whole lane — predecessors and successors alike — so a requeued
512
- * predecessor cannot slip past a successor that is already running.
513
- */
514
- function firstClaimableQueueEntry(entries, projectId) {
515
- const blockedLanes = new Set();
516
- for (const entry of entries) {
517
- if (projectId !== undefined && entry.projectId !== projectId)
518
- continue;
519
- if (entry.status === "running") {
520
- blockedLanes.add(queueLaneKey(entry));
521
- }
522
- }
523
- for (const entry of entries) {
524
- if (projectId !== undefined && entry.projectId !== projectId)
525
- continue;
526
- if (entry.status === "queued"
527
- && !blockedLanes.has(queueLaneKey(entry))) {
528
- return entry;
529
- }
530
- }
531
- return undefined;
532
- }
533
- /**
534
- * The claim-time re-check of the running barrier: the candidate stays
535
- * claimable only while no other entry of its lane is running. The barrier
536
- * covers the whole lane regardless of id order, so a requeued predecessor is
537
- * serialized behind a successor that started running first.
538
- */
539
- function laneBlockedByRunningEntry(entries, candidate) {
540
- const lane = queueLaneKey(candidate);
541
- return entries.some((entry) => entry.id !== candidate.id
542
- && entry.status === "running"
543
- && queueLaneKey(entry) === lane);
544
- }
545
- /**
546
- * A conflicted entry whose manual-resolution Attempt committed converges. The
547
- * settle happens in one transaction; the downstream affected-path recompute
548
- * follows with the same committed evidence, so waiting successors never keep
549
- * stale overlap evidence.
550
- */
551
- export async function reconcileIntegrationQueueEntry(store, taskId, entryId, git, now = () => new Date()) {
552
- const entry = store.getIntegrationQueueEntry(taskId, entryId);
553
- if (entry === null) {
554
- throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
555
- }
556
- if (entry.status !== "conflicted") {
557
- throw new Error(`Integration queue entry is not conflicted: ${entry.id}/${entry.status}.`);
558
- }
559
- const attempt = entry.integrationAttemptId === undefined
560
- ? undefined
561
- : store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
562
- if (attempt?.status !== "committed") {
563
- throw new Error(`Integration Attempt has not committed: ${entry.integrationAttemptId ?? "-"}/${attempt?.status ?? "missing"}.`);
564
- }
565
- const committed = store.transaction((tx) => {
566
- const settled = markIntegrationQueueCommitted(entry, committedTargetAfter(attempt), now());
567
- tx.saveIntegrationQueueEntry(taskId, settled);
568
- return settled;
569
- });
570
- await recomputeAffectedPaths(store, taskId, committed, taskMainRepositoryPath(store, taskId, committed.projectId), git, now);
571
- return committed;
572
- }
573
- /**
574
- * A conflicted entry may carry a blocked IntegrationAttempt waiting for a
575
- * Task Agent decision. Requeue or supersede IS that decision — the blocked
576
- * attempt is abandoned — so resolve it as rejected (terminal `failed`)
577
- * rather than leaving it to block Task retirement.
578
- */
579
- function resolveBlockedAttempt(store, taskId, entry, decision, decidedBy, now) {
580
- if (entry.integrationAttemptId === undefined)
581
- return;
582
- const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
583
- if (attempt === null || (attempt.status !== "blocked" && attempt.status !== "conflicted"))
584
- return;
585
- const rejected = recordResolutionDecision(attempt, {
586
- action: "reject",
587
- rationale: `Integration Attempt rejected by queue ${decision}.`
588
- }, decidedBy, now());
589
- store.saveIntegrationAttempt(taskId, rejected);
590
- }
591
- /**
592
- * A linked Integration Attempt that is actively processing or already
593
- * committed cannot be discarded by requeue or supersede. Only a blocked
594
- * Attempt (finalized by resolveBlockedAttempt) or a failed one is safe to
595
- * recover; a running, validating, or committed Attempt would leave the
596
- * queue entry contradicting the Attempt/target state.
597
- */
598
- function assertRecoverableAttempt(store, taskId, entry, action) {
599
- if (entry.integrationAttemptId === undefined)
600
- return;
601
- const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
602
- if (attempt === null)
603
- return;
604
- if (attempt.status !== "blocked" && attempt.status !== "conflicted" && attempt.status !== "failed") {
605
- throw new Error(`Integration queue entry ${entry.id} is backed by ${attempt.status} `
606
- + `Integration Attempt ${attempt.id}; reconcile the queue entry instead `
607
- + `of ${action} it.`);
608
- }
609
- }
610
- /** Retry a conflicted item (for example after a gate failure was fixed). */
611
- export function requeueIntegrationQueueEntry(store, taskId, entryId, decidedBy, now = () => new Date()) {
612
- // The committed-Attempt guard, the blocked-Attempt finalization, and the
613
- // queue write must be atomic: a concurrent manual-resolution continue can
614
- // commit the Attempt between the guard and the write, leaving the queue
615
- // entry requeued behind a committed target.
616
- return store.transaction((tx) => {
617
- assertTaskActive(tx, taskId);
618
- const entry = tx.getIntegrationQueueEntry(taskId, entryId);
619
- if (entry === null) {
620
- throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
621
- }
622
- assertRecoverableAttempt(tx, taskId, entry, "requeue");
623
- resolveBlockedAttempt(tx, taskId, entry, "requeue", decidedBy, now);
624
- const waiting = markIntegrationQueueRequeued(entry, now());
625
- tx.saveIntegrationQueueEntry(taskId, waiting);
626
- return waiting;
627
- });
628
- }
629
- export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, decidedBy, now = () => new Date()) {
630
- return store.transaction((tx) => {
631
- const entry = tx.getIntegrationQueueEntry(taskId, entryId);
632
- if (entry === null) {
633
- throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
634
- }
635
- assertRecoverableAttempt(tx, taskId, entry, "supersede");
636
- resolveBlockedAttempt(tx, taskId, entry, "supersede", decidedBy, now);
637
- const superseded = markIntegrationQueueSuperseded(entry, reason, now());
638
- tx.saveIntegrationQueueEntry(taskId, superseded);
639
- return superseded;
640
- });
641
- }
642
- /**
643
- * Converge entries whose linked IntegrationAttempt already settled. A
644
- * conflicted entry whose manual resolve committed, or a `running` entry
645
- * whose process crashed between the Attempt's terminal write and the queue
646
- * settle, both prove their outcome through the Attempt itself: converge
647
- * them idempotently — a committed Attempt to `committed`, replaying the
648
- * downstream affected/evidence updates a normal settle would, and a failed
649
- * or blocked Attempt to `conflicted` carrying the Attempt's own diagnosis —
650
- * so the next process pass sees a consistent queue and a wedged lane frees.
651
- */
652
- async function reconcileTerminalAttempts(store, taskId, git, now) {
653
- for (const entry of store.listIntegrationQueueEntries(taskId)) {
654
- if (entry.integrationAttemptId === undefined)
655
- continue;
656
- if (entry.status === "committed") {
657
- // Crash window: the settle marked this entry committed but died before
658
- // recomputing downstream affectedPaths. Replay that update idempotently
659
- // so waiting entries regain their overlap evidence.
660
- await recomputeAffectedPaths(store, taskId, entry, taskMainRepositoryPath(store, taskId, entry.projectId), git, now);
661
- continue;
662
- }
663
- if (entry.status !== "conflicted" && entry.status !== "running")
664
- continue;
665
- const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
666
- if (attempt === null)
667
- continue;
668
- if (attempt.status === "committed") {
669
- const committed = markIntegrationQueueCommitted(entry, committedTargetAfter(attempt), now());
670
- store.saveIntegrationQueueEntry(taskId, committed);
671
- await recomputeAffectedPaths(store, taskId, committed, taskMainRepositoryPath(store, taskId, committed.projectId), git, now);
672
- continue;
673
- }
674
- // Crash window: the Attempt persisted a terminal failed/blocked outcome
675
- // but the process died before settling the entry. Only a `running`
676
- // entry can be wedged by it — a `conflicted` entry already carries its
677
- // diagnosis — so converge the stuck lane head to `conflicted` with the
678
- // Attempt's own diagnosis, after which requeue or supersede can recover
679
- // it. An Attempt still running or validating is genuinely in flight.
680
- if (entry.status !== "running")
681
- continue;
682
- if (attempt.status !== "failed" && attempt.status !== "blocked" && attempt.status !== "conflicted")
683
- continue;
684
- const diagnosis = attempt.status === "blocked" || attempt.status === "conflicted"
685
- ? attempt.conflict?.summary ?? "Integration blocked without a conflict report."
686
- : gateFailureSummary(attempt);
687
- store.saveIntegrationQueueEntry(taskId, markIntegrationQueueBlocked(entry, diagnosis, now()));
688
- }
689
- }
690
- async function recomputeAffectedPaths(store, taskId, committed, projectPath, git, now) {
691
- const committedChangeSet = store.getChangeSet(taskId, committed.changeSetId);
692
- if (committedChangeSet === null)
693
- return;
694
- const landed = new Set(committedChangeSet.changedPaths);
695
- for (const entry of store.listIntegrationQueueEntries(taskId)) {
696
- if (entry.projectId !== committed.projectId)
697
- continue;
698
- if (canonicalizeTargetRef(entry.targetRef) !== canonicalizeTargetRef(committed.targetRef))
699
- continue;
700
- if (entry.status !== "queued")
701
- continue;
702
- const changeSet = store.getChangeSet(taskId, entry.changeSetId);
703
- if (changeSet === null)
704
- continue;
705
- // A committed entry whose targetAfter is already an ancestor of the
706
- // waiting entry's baseCommit is part of that entry's base, not a
707
- // post-enqueue target advance. This prevents recovery replay from
708
- // reviving evidence that was valid at enqueue time, and covers both
709
- // the direct predecessor and transitive history cases.
710
- if (committed.targetAfter !== undefined
711
- && await git.isAncestor(projectPath, committed.targetAfter, changeSet.baseCommit))
712
- continue;
713
- const affected = changeSet.changedPaths.filter((path) => landed.has(path));
714
- // Re-read inside the transaction: a concurrent processor may have claimed
715
- // or conflicted this entry during the async isAncestor gap above. Only
716
- // persist the recompute when the entry is still waiting to be processed.
717
- store.transaction((tx) => {
718
- const current = tx.getIntegrationQueueEntry(taskId, entry.id);
719
- if (current === null)
720
- return;
721
- if (current.status !== "queued")
722
- return;
723
- const updated = recordIntegrationQueueAffectedPaths(current, affected, now());
724
- // A queued entry keeps its place and runs its checks against the exact
725
- // current target at process time. Disjoint changedPaths must never
726
- // rebind its evidence to the new target head: a gate may read any file,
727
- // so a non-empty target increment cannot be proven irrelevant from path
728
- // metadata alone.
729
- if ((updated.affectedPaths ?? []).join("\n")
730
- !== (current.affectedPaths ?? []).join("\n")) {
731
- tx.saveIntegrationQueueEntry(taskId, updated);
732
- }
733
- });
734
- }
735
- }
736
- function committedTargetAfter(attempt) {
737
- if (attempt.candidateCommit === undefined) {
738
- throw new Error(`Committed Integration Attempt has no candidate commit: ${attempt.id}.`);
739
- }
740
- return attempt.candidateCommit;
741
- }
742
- function gateFailureSummary(attempt) {
743
- const failed = (attempt.checks ?? []).find((check) => check.outcome === "failed");
744
- return failed === undefined
745
- ? attempt.summary ?? "Inspect the Integration target and preserved evidence."
746
- : `gate failed: ${failed.name}${failed.details === undefined ? "" : `: ${failed.details}`}`;
747
- }
748
- async function findEquivalentCommit(git, repositoryPath, sourceCommit, historyHead, baseCommit) {
749
- if (git.findCommitWithSameTreeInHistory === undefined) {
750
- return (await git.isAncestor(repositoryPath, sourceCommit, historyHead))
751
- ? sourceCommit
752
- : null;
753
- }
754
- const found = await git.findCommitWithSameTreeInHistory({
755
- repositoryPath,
756
- sourceCommit,
757
- historyHead
758
- });
759
- if (found === null)
760
- return null;
761
- // The exact source commit landing on the target is integration evidence
762
- // regardless of the base relationship. A different same-tree commit is
763
- // only evidence when it is at or after the ChangeSet base: a same-tree
764
- // commit older than the base is history the ChangeSet may be deliberately
765
- // restoring, not proof that the restore landed.
766
- if (found === sourceCommit)
767
- return found;
768
- return (await git.isAncestor(repositoryPath, baseCommit, found)) ? found : null;
769
- }
770
- /**
771
- * A ChangeSet whose head already agrees with the target on every path it
772
- * touched (deletions included) is fully represented there: an identical
773
- * parallel change landed through another commit. Converge it directly,
774
- * since applying it would be a no-op and a second commit would only
775
- * duplicate the same work.
776
- */
777
- async function findContainedChangeSet(git, repositoryPath, changeSet, targetHead) {
778
- if (git.treesAgreeOnPaths === undefined)
779
- return null;
780
- const paths = [...new Set([
781
- ...changeSet.changedPaths,
782
- ...changeSet.manifest.deletedPaths
783
- ])];
784
- if (paths.length === 0)
785
- return null;
786
- const agrees = await git.treesAgreeOnPaths({
787
- repositoryPath,
788
- leftCommit: changeSet.headCommit,
789
- rightCommit: targetHead,
790
- paths
791
- });
792
- return agrees ? targetHead : null;
793
- }
794
- function taskMainBranch(store, taskId, projectId) {
795
- const mainWorkspace = store.getTaskWorkspace(taskId);
796
- if (mainWorkspace === null)
797
- return undefined;
798
- return workspaceProjectEntry(mainWorkspace, projectId)?.branch;
799
- }
800
- function taskMainRepositoryPath(store, taskId, projectId) {
801
- const mainWorkspace = store.getTaskWorkspace(taskId);
802
- if (mainWorkspace === null || mainWorkspace.owner.type !== "task") {
803
- throw new Error(`Task has no authoritative main workspace: ${taskId}.`);
804
- }
805
- const entry = workspaceProjectEntry(mainWorkspace, projectId);
806
- if (entry === undefined) {
807
- throw new Error(`Task main workspace has no Project: ${taskId}/${projectId}.`);
808
- }
809
- return entry.path;
810
- }