@zq-silk/yui 0.5.3 → 0.6.1

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 (157) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/agentConfigurationPicker.js +1 -1
  4. package/dist/cli/commandCatalog.js +251 -13
  5. package/dist/cli/updateOrchestrator.js +8 -0
  6. package/dist/cli/updatePorts.js +76 -22
  7. package/dist/cli.js +264 -20
  8. package/dist/commands/configCommands.js +83 -9
  9. package/dist/commands/controllerCommands.js +103 -0
  10. package/dist/commands/deliveryGuardPreflight.js +35 -0
  11. package/dist/commands/durableJobCommands.js +231 -0
  12. package/dist/commands/executionAuditCommands.js +193 -0
  13. package/dist/commands/grantCommands.js +374 -0
  14. package/dist/commands/projectCommands.js +119 -81
  15. package/dist/commands/releaseCommands.js +444 -0
  16. package/dist/commands/resourcesCommands.js +274 -0
  17. package/dist/commands/sessionCommands.js +104 -0
  18. package/dist/commands/taskActor.js +117 -0
  19. package/dist/commands/taskChangeSetCommands.js +60 -0
  20. package/dist/commands/taskCommands.js +610 -201
  21. package/dist/commands/taskCompletionGate.js +78 -1
  22. package/dist/commands/taskContextCommand.js +24 -6
  23. package/dist/commands/taskInputCommands.js +1 -1
  24. package/dist/commands/taskIntegrationCommands.js +136 -33
  25. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  26. package/dist/commands/taskNextActionCommand.js +85 -0
  27. package/dist/commands/taskOverlapCommands.js +120 -0
  28. package/dist/commands/taskOverviewCommand.js +36 -8
  29. package/dist/commands/telemetryCommands.js +330 -0
  30. package/dist/commands/workflowCommands.js +415 -0
  31. package/dist/config/yuiConfig.js +60 -0
  32. package/dist/controller/clientRuntime.js +42 -1
  33. package/dist/controller/controller.js +413 -61
  34. package/dist/controller/controllerMain.js +25 -2
  35. package/dist/controller/domainIdentity.js +16 -8
  36. package/dist/controller/ephemeralResourceReaper.js +2 -1
  37. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  38. package/dist/controller/handoverCandidate.js +168 -0
  39. package/dist/controller/jobClient.js +102 -0
  40. package/dist/controller/jobControl.js +613 -0
  41. package/dist/controller/jobSupervisor.js +498 -0
  42. package/dist/controller/providerHookRunFence.js +34 -5
  43. package/dist/controller/resourceCleanupLinux.js +18 -9
  44. package/dist/controller/resourceInventoryLinux.js +90 -39
  45. package/dist/controller/resourceInventoryRpc.js +85 -0
  46. package/dist/controller/resourceInventoryWorker.js +50 -0
  47. package/dist/controller/runtime.js +238 -22
  48. package/dist/controller/runtimeEventInbox.js +234 -57
  49. package/dist/controller/runtimeEventProcessor.js +549 -42
  50. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  51. package/dist/core/boundedRpc.js +475 -0
  52. package/dist/core/controllerServer.js +416 -27
  53. package/dist/core/controllerTelemetry.js +167 -0
  54. package/dist/doctor/doctor.js +113 -16
  55. package/dist/domain/validation.js +9 -0
  56. package/dist/execution/executionGroup.js +40 -3
  57. package/dist/executor/agentExecutor.js +6 -3
  58. package/dist/executor/effectiveLaunch.js +52 -0
  59. package/dist/executor/executorRegistry.js +50 -0
  60. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  61. package/dist/grant/capabilityGrant.js +282 -0
  62. package/dist/integration/changeSet.js +16 -3
  63. package/dist/integration/changeSetManifest.js +46 -0
  64. package/dist/integration/gitIntegrationService.js +528 -147
  65. package/dist/integration/integrationAttempt.js +54 -5
  66. package/dist/integration/integrationQueueEntry.js +221 -0
  67. package/dist/integration/integrationQueueService.js +955 -0
  68. package/dist/integration/manifestTags.js +99 -0
  69. package/dist/integration/overlapDiagnostics.js +211 -0
  70. package/dist/job/durableJob.js +449 -0
  71. package/dist/job/jobRunner.js +350 -0
  72. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  73. package/dist/lifecycle/providerErrorClass.js +126 -0
  74. package/dist/message/message.js +16 -3
  75. package/dist/observability/executionAudit.js +545 -0
  76. package/dist/observability/faultClassification.js +160 -0
  77. package/dist/observability/runtimeIdentity.js +367 -0
  78. package/dist/release/fakeReleasePorts.js +55 -0
  79. package/dist/release/releaseHandover.js +475 -0
  80. package/dist/release/releaseIdempotencyStore.js +165 -0
  81. package/dist/release/releaseWorkflow.js +459 -0
  82. package/dist/release/releaseWorkflowEngine.js +688 -0
  83. package/dist/release/releaseWorkflowPorts.js +1720 -0
  84. package/dist/release/runtimeRelease.js +495 -0
  85. package/dist/release/workflowFileLock.js +218 -0
  86. package/dist/repository/gitWorkspace.js +177 -1
  87. package/dist/repository/projectMaintenanceLock.js +315 -0
  88. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  89. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  90. package/dist/resources/autoResourceGc.js +116 -0
  91. package/dist/resources/liveReferences.js +574 -0
  92. package/dist/resources/resourceDiscovery.js +477 -0
  93. package/dist/resources/resourceGc.js +645 -0
  94. package/dist/resources/resourceRegistrar.js +256 -0
  95. package/dist/resources/resourceRegistry.js +150 -0
  96. package/dist/resources/resourceRegistryStore.js +41 -0
  97. package/dist/resources/resourceTypes.js +42 -0
  98. package/dist/resources/sqliteResourceRegistry.js +111 -0
  99. package/dist/review/reviewConfig.js +10 -0
  100. package/dist/review/reviewFinding.js +240 -0
  101. package/dist/review/reviewFindingLedger.js +545 -0
  102. package/dist/review/reviewOutcomeClassifier.js +61 -0
  103. package/dist/review/reviewRound.js +56 -4
  104. package/dist/run/agentRun.js +80 -4
  105. package/dist/run/providerRetry.js +84 -0
  106. package/dist/run/providerRetryConfig.js +63 -0
  107. package/dist/run/yieldReceipt.js +65 -0
  108. package/dist/runtime/exactControlPlane.js +79 -2
  109. package/dist/runtime/index.js +4 -0
  110. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  111. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  112. package/dist/runtime/sessionReconciliation.js +93 -0
  113. package/dist/runtime/sessionTerminationGuard.js +211 -0
  114. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  115. package/dist/runtime/tmuxAdapters.js +34 -1
  116. package/dist/scheduler/actionability.js +155 -0
  117. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  118. package/dist/scheduler/activeTaskProgress.js +60 -0
  119. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  120. package/dist/scheduler/roleRunStall.js +135 -29
  121. package/dist/scheduler/taskExecutionProjection.js +11 -0
  122. package/dist/setup/setupCommand.js +27 -4
  123. package/dist/storage/compatibleTaskStore.js +112 -5
  124. package/dist/storage/migration/productionRegistry.js +769 -1
  125. package/dist/storage/persistenceWorker.js +194 -0
  126. package/dist/storage/sqliteSchema.js +705 -0
  127. package/dist/storage/sqliteStore.js +1695 -0
  128. package/dist/storage/storageVersions.js +9 -2
  129. package/dist/storage/storeRpc.js +298 -0
  130. package/dist/storage/taskStore.js +982 -21
  131. package/dist/storage/upgrade/homeClassification.js +157 -12
  132. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  133. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  134. package/dist/storage/upgrade/recordVersions.js +10 -1
  135. package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
  136. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  137. package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
  138. package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
  139. package/dist/task/deliveryGuard.js +226 -0
  140. package/dist/task/nextAction.js +343 -0
  141. package/dist/task/repairWave.js +137 -0
  142. package/dist/task/taskRecordReference.js +6 -1
  143. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  144. package/dist/telemetry/telemetryCompaction.js +251 -0
  145. package/dist/telemetry/telemetryConfig.js +64 -0
  146. package/dist/telemetry/telemetryRouter.js +32 -0
  147. package/dist/telemetry/telemetryStore.js +19 -0
  148. package/dist/telemetry/telemetryWiring.js +33 -0
  149. package/dist/tmux/tmuxManager.js +20 -1
  150. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  151. package/dist/verification/gateArtifact.js +216 -0
  152. package/dist/verification/gateArtifactStore.js +87 -0
  153. package/dist/verification/verificationGateService.js +414 -0
  154. package/dist/verification/verificationPlan.js +308 -0
  155. package/dist/workspace/gitChangeSetCapture.js +12 -2
  156. package/dist/workspace/workItemChangeSetManager.js +60 -3
  157. package/package.json +2 -1
@@ -0,0 +1,955 @@
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, markIntegrationQueueValidated, recordIntegrationQueueAffectedPaths, recordIntegrationQueueAttempt } from "./integrationQueueEntry.js";
5
+ import { GitIntegrationService } from "./gitIntegrationService.js";
6
+ import { gateArtifactCoversCheckCommands } from "../verification/verificationGateService.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
+ "completed",
16
+ "failed",
17
+ "retired"
18
+ ]);
19
+ /**
20
+ * Only a ChangeSet whose producer WorkItem reached a deliverable may enter the
21
+ * queue. In-progress WorkItems are left to the capture path, which only runs
22
+ * once a Candidate exists; the re-check inside the write transaction closes
23
+ * the TOCTOU window between this read and the entry creation.
24
+ */
25
+ function assertEnqueueableProducer(store, taskId, changeSet) {
26
+ const workItem = store.getWorkItem(taskId, changeSet.workItemId);
27
+ if (workItem === null) {
28
+ throw new Error(`ChangeSet producer WorkItem not found: ${changeSet.workItemId}.`);
29
+ }
30
+ if (workItem.candidates.length === 0
31
+ && TERMINAL_PRODUCER_STATUSES.has(workItem.status)) {
32
+ throw new Error(`ChangeSet producer WorkItem has no terminal Candidate: ${workItem.id}/${workItem.status}.`);
33
+ }
34
+ }
35
+ /**
36
+ * Synchronous Task-active fence usable inside a write transaction. The Task
37
+ * may retire between the pre-flight read and the commit point; a terminal Task
38
+ * must not gain new queue entries or Integration Attempts.
39
+ */
40
+ function assertTaskActive(store, taskId) {
41
+ const task = store.getTask(taskId);
42
+ if (task === null) {
43
+ throw new Error(`Task not found: ${taskId}.`);
44
+ }
45
+ if (task.status !== "active") {
46
+ throw new Error(`Task is not active: ${task.id}/${task.status}.`);
47
+ }
48
+ }
49
+ /**
50
+ * Synchronous current-Candidate fence usable inside a write transaction.
51
+ * Rejects a running WorkItem (no current Candidate) and a ChangeSet whose
52
+ * headCommit no longer matches the latest immutable Candidate snapshot.
53
+ * Returns without error when the Candidate carries no head snapshot: the
54
+ * async workspace fallback in assertCurrentCandidateHead covers that case
55
+ * outside the transaction.
56
+ */
57
+ function assertCurrentCandidateInStore(store, taskId, changeSet) {
58
+ const workItem = store.getWorkItem(taskId, changeSet.workItemId);
59
+ if (workItem === null) {
60
+ throw new Error(`ChangeSet producer WorkItem not found: ${changeSet.workItemId}.`);
61
+ }
62
+ // A running WorkItem has no current Candidate: a new one is expected.
63
+ if (workItem.status === "running") {
64
+ throw new Error(`ChangeSet ${changeSet.id} has no current Candidate: `
65
+ + `WorkItem ${workItem.id} is running.`);
66
+ }
67
+ const latestCandidate = workItem.candidates.at(-1);
68
+ if (latestCandidate !== undefined) {
69
+ const snapshotHead = candidateHeadCommit(latestCandidate, changeSet.projectId);
70
+ if (snapshotHead !== undefined && snapshotHead !== changeSet.headCommit) {
71
+ throw new Error(`ChangeSet ${changeSet.id} was captured from a superseded Candidate: `
72
+ + `current Candidate head is ${snapshotHead}, ChangeSet head is ${changeSet.headCommit}.`);
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * A ChangeSet captured from a since-superseded Candidate must not enter the
78
+ * queue. The fence binds to the latest immutable Candidate snapshot: a
79
+ * running WorkItem has no current Candidate, and a ChangeSet whose headCommit
80
+ * no longer matches the latest Candidate (or the workspace HEAD when the
81
+ * Candidate carries no head snapshot) was captured from an earlier Candidate.
82
+ * When the workspace exists but cannot be inspected, fail closed.
83
+ */
84
+ async function assertCurrentCandidateHead(store, taskId, changeSet, git) {
85
+ assertCurrentCandidateInStore(store, taskId, changeSet);
86
+ // Fall back to the managed workspace HEAD when the Candidate carries no
87
+ // head-commit snapshot. Fail closed: a workspace that exists but cannot
88
+ // be inspected blocks enqueue.
89
+ const workspace = store.getManagedWorkspace({
90
+ type: "work-item",
91
+ taskId,
92
+ workItemId: changeSet.workItemId
93
+ });
94
+ if (workspace === null)
95
+ return;
96
+ const entry = workspace.entries.find((e) => e.projectId === changeSet.projectId);
97
+ if (entry === undefined)
98
+ return;
99
+ let currentHead;
100
+ try {
101
+ currentHead = (await git.inspect(entry.path)).baseCommit;
102
+ }
103
+ catch {
104
+ throw new Error(`ChangeSet ${changeSet.id} cannot be verified: `
105
+ + `workspace ${entry.path} exists but cannot be inspected.`);
106
+ }
107
+ if (currentHead !== changeSet.headCommit) {
108
+ throw new Error(`ChangeSet ${changeSet.id} was captured from a superseded Candidate: `
109
+ + `workspace HEAD is ${currentHead}, ChangeSet head is ${changeSet.headCommit}.`);
110
+ }
111
+ }
112
+ function candidateHeadCommit(candidate, projectId) {
113
+ const gitSnapshotHead = candidate.gitSnapshot?.projects
114
+ .find((p) => p.projectId === projectId)?.commit;
115
+ if (gitSnapshotHead !== undefined)
116
+ return gitSnapshotHead;
117
+ const taskMainHead = candidate.taskMainSnapshot?.projects
118
+ .find((p) => p.projectId === projectId)?.headCommit;
119
+ if (taskMainHead !== undefined)
120
+ return taskMainHead;
121
+ return undefined;
122
+ }
123
+ /**
124
+ * Enqueue one ChangeSet. Enqueue is idempotent per (Project, ChangeSet): an
125
+ * existing non-superseded entry is returned instead of duplicated. When the
126
+ * ChangeSet is already represented on the target (an ancestor or a same-tree
127
+ * commit) the entry converges directly to committed with its proof.
128
+ */
129
+ export async function enqueueIntegrationQueueEntry(input) {
130
+ const now = input.now ?? (() => new Date());
131
+ const git = input.git ?? new NodeGitWorkspace();
132
+ const task = input.store.getTask(input.taskId);
133
+ if (task === null)
134
+ throw new Error(`Task not found: ${input.taskId}.`);
135
+ if (task.status !== "active") {
136
+ throw new Error(`Task is not active: ${task.id}/${task.status}.`);
137
+ }
138
+ const changeSet = input.store.getChangeSet(task.id, input.changeSetId);
139
+ if (changeSet === null)
140
+ throw new Error(`ChangeSet not found: ${input.changeSetId}.`);
141
+ if (changeSet.projectId !== input.projectId) {
142
+ throw new Error(`ChangeSet belongs to another Project: ${changeSet.projectId}.`);
143
+ }
144
+ if (!task.projectBindings.some(({ projectId }) => projectId === input.projectId)) {
145
+ throw new Error(`Project does not belong to Task: ${input.projectId}.`);
146
+ }
147
+ // Producer fence: only a ChangeSet whose WorkItem reached a deliverable may
148
+ // enter the queue. Re-checked inside the write transaction.
149
+ assertEnqueueableProducer(input.store, task.id, changeSet);
150
+ // Current-Candidate fence: a ChangeSet captured from a since-superseded
151
+ // Candidate must not enter the queue. Runs before the duplicate fast path
152
+ // so a retry-window WorkItem cannot resurrect a stale ChangeSet that was
153
+ // enqueued before its Candidate was superseded.
154
+ await assertCurrentCandidateHead(input.store, task.id, changeSet, git);
155
+ // Fast path: a duplicate already visible returns without any git inspection.
156
+ // The authoritative re-check happens inside the write transaction so two
157
+ // concurrent enqueues cannot both create entries for the same ChangeSet.
158
+ const existing = findActiveQueueDuplicate(input.store, task.id, input.projectId, input.changeSetId);
159
+ if (existing !== undefined) {
160
+ // A conflicting idempotency retry must fail closed, even on the fast
161
+ // path: a caller that enqueues with a different explicit gate is either
162
+ // mistaken or trying to bypass the original gate. Reject instead of
163
+ // silently discarding the requested checks. A retry with no gate is a
164
+ // plain idempotent lookup and returns the existing entry.
165
+ const requestedChecks = input.checkCommands ?? [];
166
+ if (requestedChecks.length > 0
167
+ && (existing.checkCommands.length !== requestedChecks.length
168
+ || existing.checkCommands.some((cmd, i) => cmd !== requestedChecks[i]))) {
169
+ throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${existing.id} `
170
+ + `with checkCommands [${existing.checkCommands.join(", ")}]; `
171
+ + `a conflicting retry with [${requestedChecks.join(", ")}] is not allowed.`);
172
+ }
173
+ // A conflicting explicit target must also fail closed: a caller that
174
+ // enqueues for a different branch is either mistaken or trying to land
175
+ // the same ChangeSet on two targets through one entry. A retry with no
176
+ // explicit target is a plain idempotent lookup.
177
+ if (input.targetRef !== undefined) {
178
+ const requestedTarget = canonicalizeTargetRef(input.targetRef);
179
+ if (requestedTarget !== existing.targetRef) {
180
+ throw new Error(`ChangeSet ${input.changeSetId} is already queued as ${existing.id} `
181
+ + `with targetRef ${existing.targetRef}; `
182
+ + `a conflicting retry with targetRef ${requestedTarget} is not allowed.`);
183
+ }
184
+ }
185
+ return {
186
+ entry: existing,
187
+ outcome: existing.status === "committed" ? "already-committed" : "already-queued"
188
+ };
189
+ }
190
+ const project = input.store.getProject(input.projectId);
191
+ if (project === null)
192
+ throw new Error(`Project not found: ${input.projectId}.`);
193
+ const targetRef = input.targetRef
194
+ ?? changeSet.manifest?.targetRef
195
+ ?? taskMainBranch(input.store, task.id, input.projectId);
196
+ if (targetRef === undefined) {
197
+ throw new Error(`Task main worktree is not ready; reconcile the Task first: ${task.id}.`);
198
+ }
199
+ const canonicalTargetRef = canonicalizeTargetRef(targetRef);
200
+ const targetHead = (await git.inspect(project.path, exactBranchRef(canonicalTargetRef))).baseCommit;
201
+ let equivalent = await findEquivalentCommit(git, project.path, 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, project.path, 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(project.path, 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(project.path, exactBranchRef(canonicalTargetRef))).baseCommit;
265
+ if (verifiedHead !== effectiveTargetHead) {
266
+ equivalent = null;
267
+ effectiveTargetHead = verifiedHead;
268
+ }
269
+ }
270
+ }
271
+ if (equivalent === null || requestedChecks.length > 0) {
272
+ let entry = createIntegrationQueueEntry({
273
+ id,
274
+ taskId: task.id,
275
+ projectId: input.projectId,
276
+ changeSetId: input.changeSetId,
277
+ targetRef: canonicalTargetRef,
278
+ checkCommands: requestedChecks,
279
+ evidenceRefs: changeSet.manifest?.evidenceRefs ?? []
280
+ }, now());
281
+ // Durable positive exact-SHA evidence on an unchanged target, at the lane
282
+ // head, validates the entry so processing skips its checks. The process-
283
+ // path evidence fence still invalidates the binding if the target moves
284
+ // before this entry runs, so a non-empty advance never rebinds old
285
+ // evidence. A converged ChangeSet with explicit gates queues normally:
286
+ // the no-op apply still runs the caller's checks against the current
287
+ // target instead of waiving them.
288
+ if (await canReuseEvidenceAtHead(tx, task.id, tx.listIntegrationQueueEntries(task.id), entry, changeSet, effectiveTargetHead)) {
289
+ entry = markIntegrationQueueValidated(entry, now(), effectiveTargetHead);
290
+ }
291
+ tx.saveIntegrationQueueEntry(task.id, entry);
292
+ return { entry, outcome: "queued" };
293
+ }
294
+ // The ChangeSet is already represented on the target and no gate was
295
+ // requested, so applying it would be a no-op and the target does not move.
296
+ // Still record the one integration authority every acceptance and
297
+ // provenance consumer reads: a committed IntegrationAttempt against the
298
+ // exact observed target head, with the converged queue entry pointing at
299
+ // it. No gate runs because there is nothing new to apply and none was
300
+ // requested; the entry's proof string says why.
301
+ //
302
+ // Each inspect above is async: a concurrent landing can advance the
303
+ // target after the last read returns but before this transaction
304
+ // commits. A stale convergence proof would terminalize the entry
305
+ // against a head that no longer contains the candidate, so linearize
306
+ // with an atomic compare-and-swap on the target ref (the same
307
+ // `git update-ref` CAS that GitIntegrationService uses for actual
308
+ // landings). The CAS succeeds only when the ref still points at the
309
+ // expected head; a concurrent advance between the last inspect and
310
+ // this call makes it fail, and the entry falls back to queued so the
311
+ // process path re-evaluates at the new head.
312
+ const attempt = updateIntegrationAttempt(createIntegrationAttempt({
313
+ id: tx.nextIntegrationAttemptId(task.id),
314
+ taskId: task.id,
315
+ projectId: input.projectId,
316
+ targetRef: canonicalTargetRef,
317
+ expectedHead: effectiveTargetHead,
318
+ changeSetIds: [input.changeSetId],
319
+ checkCommands: []
320
+ }, now()), { status: "committed", candidateCommit: effectiveTargetHead }, 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(project.path, 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
+ evidenceRefs: changeSet.manifest?.evidenceRefs ?? []
345
+ }, now());
346
+ tx.saveIntegrationQueueEntry(task.id, queuedEntry);
347
+ return { entry: queuedEntry, outcome: "queued" };
348
+ }
349
+ tx.saveIntegrationAttempt(task.id, attempt);
350
+ tx.saveIntegrationQueueEntry(task.id, convergedEntry);
351
+ return { entry: convergedEntry, outcome: "converged" };
352
+ });
353
+ }
354
+ function findActiveQueueDuplicate(store, taskId, projectId, changeSetId) {
355
+ return store.listIntegrationQueueEntries(taskId)
356
+ .find((entry) => entry.projectId === projectId
357
+ && entry.changeSetId === changeSetId
358
+ && entry.status !== "superseded");
359
+ }
360
+ /**
361
+ * Whether a freshly queued entry may start `validated` (skipping its checks):
362
+ * its ChangeSet carries durable positive exact-SHA evidence, the target is
363
+ * unchanged since the ChangeSet was based (so integrating fast-forwards to
364
+ * the reviewed head), no earlier lane entry will advance the target first,
365
+ * and the evidence covers every requested check command. Entries behind a
366
+ * lane mate stay `queued`: the mate's commit moves the target, so the
367
+ * evidence would not cover their integration anyway. A review check that
368
+ * passed for command X never waives a different gate Y.
369
+ */
370
+ async function canReuseEvidenceAtHead(store, taskId, entries, entry, changeSet, targetHead) {
371
+ if (entry.evidenceRefs.length === 0)
372
+ return false;
373
+ if (targetHead !== changeSet.baseCommit)
374
+ return false;
375
+ const lane = queueLaneKey(entry);
376
+ if (entries.some((existing) => queueLaneKey(existing) === lane
377
+ && existing.status !== "committed"
378
+ && existing.status !== "superseded"))
379
+ return false;
380
+ return evidenceCoversCheckCommands(store, taskId, entry.evidenceRefs, entry.checkCommands, changeSet.headCommit, entry.projectId);
381
+ }
382
+ /**
383
+ * Resolve each reusable evidence reference to its ReviewRound and collect the
384
+ * names of checks that passed. A requested gate command is covered only when
385
+ * a resolved round recorded that exact command as passed. A review that ran
386
+ * `true` (or any other unrelated check) never waives a different gate.
387
+ *
388
+ * A check is reusable only when it ran on the frozen candidate tree. A
389
+ * ReviewRound must attest to this by recording its `evidenceCommit` as the
390
+ * exact `reviewBaseCommit`. A missing `evidenceCommit` is ambiguous: the
391
+ * reviewer may have run checks with uncommitted dirty diagnostics, so the
392
+ * absence cannot be treated as frozen-tree proof. A round whose
393
+ * `evidenceCommit` differs from its `reviewBaseCommit` ran its checks on a
394
+ * diagnostic tree (the reviewer's own commit with review-only changes); those
395
+ * checks proved a different tree and cannot waive the candidate gate.
396
+ */
397
+ async function evidenceCoversCheckCommands(store, taskId, evidenceRefs, checkCommands, candidateCommit, projectId) {
398
+ if (checkCommands.length === 0)
399
+ return true;
400
+ const covered = new Set();
401
+ for (const ref of evidenceRefs) {
402
+ // Issue 08: a `gate-artifact:` ref covers the exact commands its L2
403
+ // artifact passed on the exact candidate commit. The store resolves the
404
+ // artifact through its persistence backend (SQLite or file).
405
+ if (ref.startsWith("gate-artifact:")) {
406
+ if (await gateArtifactCoversCheckCommands(store, projectId, ref, checkCommands, candidateCommit)) {
407
+ for (const command of checkCommands)
408
+ covered.add(command);
409
+ }
410
+ continue;
411
+ }
412
+ const roundId = parseReviewRoundRef(ref);
413
+ if (roundId === undefined)
414
+ continue;
415
+ const round = store.getReviewRound(taskId, roundId);
416
+ if (round === null || round.status !== "completed")
417
+ continue;
418
+ if (round.evidenceCommit !== round.reviewBaseCommit)
419
+ continue;
420
+ for (const check of round.checks ?? []) {
421
+ if (check.outcome === "passed")
422
+ covered.add(check.name);
423
+ }
424
+ }
425
+ return checkCommands.every((cmd) => covered.has(cmd));
426
+ }
427
+ function parseReviewRoundRef(ref) {
428
+ const prefix = "review-round:";
429
+ return ref.startsWith(prefix) ? ref.slice(prefix.length) : undefined;
430
+ }
431
+ /**
432
+ * Process queued items in id order. Each item gets a fresh IntegrationAttempt
433
+ * on the exact current target; conflicts and gate failures map the item to
434
+ * `conflicted` without touching the others. After every commit the remaining
435
+ * items recompute overlap, and an item whose own paths became affected loses
436
+ * its evidence coverage and runs its checks again.
437
+ *
438
+ * A lane (one Project/targetRef pair) integrates one item at a time: while an
439
+ * item is `running` — claimed by this or another processor — no other item of
440
+ * the lane is selected, and the claim re-checks the barrier inside its write
441
+ * transaction so two store instances cannot run items of the same target
442
+ * concurrently. The barrier covers the whole lane regardless of id order, so
443
+ * a requeued predecessor is serialized behind a successor that started first.
444
+ */
445
+ export async function processIntegrationQueue(store, home, taskId, options = {}) {
446
+ const now = options.now ?? (() => new Date());
447
+ const git = options.git ?? new NodeGitWorkspace();
448
+ const task = store.getTask(taskId);
449
+ if (task === null)
450
+ throw new Error(`Task not found: ${taskId}.`);
451
+ if (task.status !== "active") {
452
+ throw new Error(`Task is not active: ${task.id}/${task.status}.`);
453
+ }
454
+ const processed = [];
455
+ for (;;) {
456
+ if (options.limit !== undefined && processed.length >= options.limit)
457
+ break;
458
+ await reconcileTerminalAttempts(store, task.id, git, now);
459
+ // The store already returns entries in numeric id order; trust it instead
460
+ // of re-sorting with a lexicographic compare (which yields 1, 10, 2, ...).
461
+ // A running entry is the persistent head of its lane: its successors are
462
+ // skipped here so another processor's in-flight item is never claimed
463
+ // past.
464
+ const candidate = firstClaimableQueueEntry(store.listIntegrationQueueEntries(task.id), options.projectId);
465
+ if (candidate === undefined)
466
+ break;
467
+ const project = store.getProject(candidate.projectId);
468
+ if (project === null)
469
+ throw new Error(`Project not found: ${candidate.projectId}.`);
470
+ const targetBefore = (await git.inspect(project.path, exactBranchRef(candidate.targetRef))).baseCommit;
471
+ // Evidence fence: a validated entry's reusable evidence only covers the
472
+ // exact target head it was validated against. An out-of-band target
473
+ // advance (another Task) that touches the entry's paths, or whose impact
474
+ // cannot be proven, invalidates the evidence: requeue and run its checks.
475
+ if (candidate.status === "validated"
476
+ && await evidenceTargetAdvanced(git, project.path, candidate, targetBefore, store, task.id)) {
477
+ store.transaction((tx) => {
478
+ assertTaskActive(tx, task.id);
479
+ const current = tx.getIntegrationQueueEntry(task.id, candidate.id);
480
+ if (current !== null && current.status === "validated") {
481
+ tx.saveIntegrationQueueEntry(task.id, markIntegrationQueueRequeued(current, now()));
482
+ }
483
+ });
484
+ continue;
485
+ }
486
+ // CAS claim: re-read the entry inside the write transaction. Another
487
+ // store instance may have taken it since selection; if the status changed,
488
+ // do not create an Attempt and re-select instead. The running barrier is
489
+ // re-checked here as well: an earlier lane mate that started running
490
+ // between selection and claim serializes this entry behind it.
491
+ const claimed = store.transaction((tx) => {
492
+ const current = tx.getIntegrationQueueEntry(task.id, candidate.id);
493
+ if (current === null
494
+ || (current.status !== "queued" && current.status !== "validated")) {
495
+ return { skipped: true };
496
+ }
497
+ if (laneBlockedByRunningEntry(tx.listIntegrationQueueEntries(task.id), current)) {
498
+ return { skipped: true };
499
+ }
500
+ // Task-active fence: the Task may have retired between the pre-flight
501
+ // read and this commit. A terminal Task must not gain a new running
502
+ // Integration Attempt. This throws (not caught by the Candidate-fence
503
+ // handler below) so the caller sees the rejection.
504
+ assertTaskActive(tx, task.id);
505
+ // Current-Candidate fence: a queued ChangeSet whose producer WorkItem
506
+ // entered retry (or whose latest Candidate no longer matches) must not
507
+ // advance the target. Mark it conflicted so the Leader can supersede
508
+ // or re-enqueue a fresh ChangeSet.
509
+ try {
510
+ const changeSet = tx.getChangeSet(task.id, current.changeSetId);
511
+ if (changeSet === null) {
512
+ throw new Error(`ChangeSet not found: ${current.changeSetId}.`);
513
+ }
514
+ assertCurrentCandidateInStore(tx, task.id, changeSet);
515
+ }
516
+ catch (error) {
517
+ const running = markIntegrationQueueRunning(current, targetBefore, now());
518
+ tx.saveIntegrationQueueEntry(task.id, running);
519
+ tx.saveIntegrationQueueEntry(task.id, markIntegrationQueueBlocked(running, error instanceof Error ? error.message : String(error), now()));
520
+ return { skipped: true };
521
+ }
522
+ const attempt = createIntegrationAttempt({
523
+ id: tx.nextIntegrationAttemptId(task.id),
524
+ taskId: task.id,
525
+ projectId: current.projectId,
526
+ targetRef: current.targetRef,
527
+ expectedHead: targetBefore,
528
+ changeSetIds: [current.changeSetId],
529
+ checkCommands: current.status === "validated" ? [] : current.checkCommands
530
+ }, now());
531
+ tx.saveIntegrationAttempt(task.id, attempt);
532
+ const entry = recordIntegrationQueueAttempt(markIntegrationQueueRunning(current, targetBefore, now()), attempt.id, now());
533
+ tx.saveIntegrationQueueEntry(task.id, entry);
534
+ return { skipped: false, entry, attempt };
535
+ });
536
+ if (claimed.skipped)
537
+ continue;
538
+ const result = await new GitIntegrationService(home, store, git, now, options.environment)
539
+ .integrate(task.id, claimed.attempt.id);
540
+ const settled = store.transaction((tx) => {
541
+ let entry = claimed.entry;
542
+ if (result.status === "committed") {
543
+ entry = markIntegrationQueueCommitted(entry, committedTargetAfter(result.attempt), now());
544
+ }
545
+ else if (result.status === "blocked") {
546
+ entry = markIntegrationQueueBlocked(entry, result.attempt.conflict?.summary
547
+ ?? "Integration blocked without a conflict report.", now());
548
+ }
549
+ else {
550
+ entry = markIntegrationQueueBlocked(entry, gateFailureSummary(result.attempt), now());
551
+ }
552
+ tx.saveIntegrationQueueEntry(task.id, entry);
553
+ return entry;
554
+ });
555
+ if (result.status === "committed") {
556
+ const project = store.getProject(settled.projectId);
557
+ if (project !== null) {
558
+ await recomputeAffectedPaths(store, task.id, settled, project.path, git, now);
559
+ }
560
+ }
561
+ processed.push({ entry: settled, attempt: result.attempt, result });
562
+ }
563
+ return processed;
564
+ }
565
+ /**
566
+ * A queue lane serializes one target ref: entries for the same Project and
567
+ * targetRef integrate one at a time in id order. Entries of different lanes
568
+ * (another Project or ref) do not share a target and may integrate alongside.
569
+ */
570
+ /**
571
+ * Canonicalise a Git ref so that "master" and "refs/heads/master" share one
572
+ * lane. Only the `refs/heads/` prefix is stripped; other ref namespaces
573
+ * (tags, remotes) are kept distinct.
574
+ */
575
+ function canonicalizeTargetRef(ref) {
576
+ return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
577
+ }
578
+ /**
579
+ * Resolve a canonical target ref to its exact Git ref for inspection. A
580
+ * short name or `refs/heads/*` resolves to the exact branch ref, so a
581
+ * same-named tag cannot shadow the branch. Other fully-qualified refs
582
+ * keep their exact meaning.
583
+ */
584
+ function exactBranchRef(canonicalRef) {
585
+ return canonicalRef.startsWith("refs/") ? canonicalRef : `refs/heads/${canonicalRef}`;
586
+ }
587
+ function queueLaneKey(entry) {
588
+ return `${entry.projectId} ${canonicalizeTargetRef(entry.targetRef)}`;
589
+ }
590
+ /**
591
+ * The first entry a processor may claim: the earliest queued/validated entry
592
+ * whose lane has no running entry anywhere in it. A running entry serializes
593
+ * its whole lane — predecessors and successors alike — so a requeued
594
+ * predecessor cannot slip past a successor that is already running.
595
+ */
596
+ function firstClaimableQueueEntry(entries, projectId) {
597
+ const blockedLanes = new Set();
598
+ for (const entry of entries) {
599
+ if (projectId !== undefined && entry.projectId !== projectId)
600
+ continue;
601
+ if (entry.status === "running") {
602
+ blockedLanes.add(queueLaneKey(entry));
603
+ }
604
+ }
605
+ for (const entry of entries) {
606
+ if (projectId !== undefined && entry.projectId !== projectId)
607
+ continue;
608
+ if ((entry.status === "queued" || entry.status === "validated")
609
+ && !blockedLanes.has(queueLaneKey(entry))) {
610
+ return entry;
611
+ }
612
+ }
613
+ return undefined;
614
+ }
615
+ /**
616
+ * The claim-time re-check of the running barrier: the candidate stays
617
+ * claimable only while no other entry of its lane is running. The barrier
618
+ * covers the whole lane regardless of id order, so a requeued predecessor is
619
+ * serialized behind a successor that started running first.
620
+ */
621
+ function laneBlockedByRunningEntry(entries, candidate) {
622
+ const lane = queueLaneKey(candidate);
623
+ return entries.some((entry) => entry.id !== candidate.id
624
+ && entry.status === "running"
625
+ && queueLaneKey(entry) === lane);
626
+ }
627
+ /**
628
+ * Whether a validated entry's reusable evidence no longer covers the current
629
+ * target. The evidence only proves the gate as of `entry.evidenceTargetHead`;
630
+ * an out-of-band target advance (another Task) on the entry's own paths, or any
631
+ * advance whose impact on the entry's gate cannot be proven, invalidates it.
632
+ * Returns false (evidence still covers the target) only when proven unaffected.
633
+ */
634
+ async function evidenceTargetAdvanced(git, repositoryPath, entry, currentTargetHead, store, taskId) {
635
+ const boundary = entry.evidenceTargetHead;
636
+ if (boundary === undefined)
637
+ return true;
638
+ if (boundary === currentTargetHead)
639
+ return false;
640
+ // The target advanced out of band since the evidence boundary. Compute the
641
+ // real path delta and re-run the gate unless the entry is provably unaffected.
642
+ if (git.changedFilesBetween === undefined)
643
+ return true;
644
+ let delta;
645
+ try {
646
+ delta = await git.changedFilesBetween({
647
+ repositoryPath,
648
+ fromCommit: boundary,
649
+ toCommit: currentTargetHead
650
+ });
651
+ }
652
+ catch {
653
+ return true;
654
+ }
655
+ const changeSet = store.getChangeSet(taskId, entry.changeSetId);
656
+ if (changeSet === null)
657
+ return true;
658
+ const ownPaths = new Set([
659
+ ...changeSet.changedPaths,
660
+ ...(changeSet.manifest?.deletedPaths ?? [])
661
+ ]);
662
+ if (delta.some((path) => ownPaths.has(path)))
663
+ return true;
664
+ // The delta does not touch the entry's own paths, but the entry's gate may
665
+ // read any file — including the exact target SHA the evidence is bound to.
666
+ // A changed target SHA invalidates exact-SHA evidence regardless of tree
667
+ // identity, so any entry with checks must re-run its gate. An entry
668
+ // without checks has nothing to re-run and may commit directly.
669
+ return entry.checkCommands.length > 0;
670
+ }
671
+ /**
672
+ * A conflicted entry whose manual-resolution Attempt committed converges. The
673
+ * settle happens in one transaction; the downstream affected-path recompute
674
+ * follows with the same committed evidence, so waiting successors never keep
675
+ * stale overlap evidence.
676
+ */
677
+ export async function reconcileIntegrationQueueEntry(store, taskId, entryId, git, now = () => new Date()) {
678
+ const entry = store.getIntegrationQueueEntry(taskId, entryId);
679
+ if (entry === null) {
680
+ throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
681
+ }
682
+ if (entry.status !== "conflicted") {
683
+ throw new Error(`Integration queue entry is not conflicted: ${entry.id}/${entry.status}.`);
684
+ }
685
+ const attempt = entry.integrationAttemptId === undefined
686
+ ? undefined
687
+ : store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
688
+ if (attempt?.status !== "committed") {
689
+ throw new Error(`Integration Attempt has not committed: ${entry.integrationAttemptId ?? "-"}/${attempt?.status ?? "missing"}.`);
690
+ }
691
+ const committed = store.transaction((tx) => {
692
+ const settled = markIntegrationQueueCommitted(entry, committedTargetAfter(attempt), now());
693
+ tx.saveIntegrationQueueEntry(taskId, settled);
694
+ return settled;
695
+ });
696
+ const project = store.getProject(committed.projectId);
697
+ if (project !== null) {
698
+ await recomputeAffectedPaths(store, taskId, committed, project.path, git, now);
699
+ }
700
+ return committed;
701
+ }
702
+ /**
703
+ * A conflicted entry may carry a blocked IntegrationAttempt waiting for a
704
+ * leader decision. Requeue or supersede IS that decision — the blocked
705
+ * attempt is abandoned — so resolve it as rejected (terminal `failed`)
706
+ * rather than leaving it to block Task retirement.
707
+ */
708
+ function resolveBlockedAttempt(store, taskId, entry, decision, now) {
709
+ if (entry.integrationAttemptId === undefined)
710
+ return;
711
+ const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
712
+ if (attempt === null || attempt.status !== "blocked")
713
+ return;
714
+ const rejected = recordResolutionDecision(attempt, {
715
+ action: "reject",
716
+ rationale: `Integration Attempt rejected by queue ${decision}.`
717
+ }, now());
718
+ store.saveIntegrationAttempt(taskId, rejected);
719
+ }
720
+ /**
721
+ * A linked Integration Attempt that is actively processing or already
722
+ * committed cannot be discarded by requeue or supersede. Only a blocked
723
+ * Attempt (finalized by resolveBlockedAttempt) or a failed one is safe to
724
+ * recover; a running, validating, or committed Attempt would leave the
725
+ * queue entry contradicting the Attempt/target state.
726
+ */
727
+ function assertRecoverableAttempt(store, taskId, entry, action) {
728
+ if (entry.integrationAttemptId === undefined)
729
+ return;
730
+ const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
731
+ if (attempt === null)
732
+ return;
733
+ if (attempt.status !== "blocked" && attempt.status !== "failed") {
734
+ throw new Error(`Integration queue entry ${entry.id} is backed by ${attempt.status} `
735
+ + `Integration Attempt ${attempt.id}; reconcile the queue entry instead `
736
+ + `of ${action} it.`);
737
+ }
738
+ }
739
+ /** Retry a conflicted item (for example after a gate failure was fixed). */
740
+ export function requeueIntegrationQueueEntry(store, taskId, entryId, now = () => new Date()) {
741
+ // The committed-Attempt guard, the blocked-Attempt finalization, and the
742
+ // queue write must be atomic: a concurrent manual-resolution continue can
743
+ // commit the Attempt between the guard and the write, leaving the queue
744
+ // entry requeued behind a committed target.
745
+ return store.transaction((tx) => {
746
+ assertTaskActive(tx, taskId);
747
+ const entry = tx.getIntegrationQueueEntry(taskId, entryId);
748
+ if (entry === null) {
749
+ throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
750
+ }
751
+ assertRecoverableAttempt(tx, taskId, entry, "requeue");
752
+ resolveBlockedAttempt(tx, taskId, entry, "requeue", now);
753
+ const waiting = markIntegrationQueueRequeued(entry, now());
754
+ tx.saveIntegrationQueueEntry(taskId, waiting);
755
+ return waiting;
756
+ });
757
+ }
758
+ export function supersedeIntegrationQueueEntry(store, taskId, entryId, reason, now = () => new Date()) {
759
+ return store.transaction((tx) => {
760
+ const entry = tx.getIntegrationQueueEntry(taskId, entryId);
761
+ if (entry === null) {
762
+ throw new Error(`Integration queue entry not found: ${taskId}/${entryId}.`);
763
+ }
764
+ assertRecoverableAttempt(tx, taskId, entry, "supersede");
765
+ resolveBlockedAttempt(tx, taskId, entry, "supersede", now);
766
+ const superseded = markIntegrationQueueSuperseded(entry, reason, now());
767
+ tx.saveIntegrationQueueEntry(taskId, superseded);
768
+ return superseded;
769
+ });
770
+ }
771
+ /**
772
+ * Converge entries whose linked IntegrationAttempt already settled. A
773
+ * conflicted entry whose manual resolve committed, or a `running` entry
774
+ * whose process crashed between the Attempt's terminal write and the queue
775
+ * settle, both prove their outcome through the Attempt itself: converge
776
+ * them idempotently — a committed Attempt to `committed`, replaying the
777
+ * downstream affected/evidence updates a normal settle would, and a failed
778
+ * or blocked Attempt to `conflicted` carrying the Attempt's own diagnosis —
779
+ * so the next process pass sees a consistent queue and a wedged lane frees.
780
+ */
781
+ async function reconcileTerminalAttempts(store, taskId, git, now) {
782
+ for (const entry of store.listIntegrationQueueEntries(taskId)) {
783
+ if (entry.integrationAttemptId === undefined)
784
+ continue;
785
+ if (entry.status === "committed") {
786
+ // Crash window: the settle marked this entry committed but died before
787
+ // recomputing downstream affectedPaths. Replay that update idempotently
788
+ // so waiting entries regain their overlap evidence.
789
+ const project = store.getProject(entry.projectId);
790
+ if (project !== null) {
791
+ await recomputeAffectedPaths(store, taskId, entry, project.path, git, now);
792
+ }
793
+ continue;
794
+ }
795
+ if (entry.status !== "conflicted" && entry.status !== "running")
796
+ continue;
797
+ const attempt = store.getIntegrationAttempt(taskId, entry.integrationAttemptId);
798
+ if (attempt === null)
799
+ continue;
800
+ if (attempt.status === "committed") {
801
+ const committed = markIntegrationQueueCommitted(entry, committedTargetAfter(attempt), now());
802
+ store.saveIntegrationQueueEntry(taskId, committed);
803
+ const project = store.getProject(committed.projectId);
804
+ if (project !== null) {
805
+ await recomputeAffectedPaths(store, taskId, committed, project.path, git, now);
806
+ }
807
+ continue;
808
+ }
809
+ // Crash window: the Attempt persisted a terminal failed/blocked outcome
810
+ // but the process died before settling the entry. Only a `running`
811
+ // entry can be wedged by it — a `conflicted` entry already carries its
812
+ // diagnosis — so converge the stuck lane head to `conflicted` with the
813
+ // Attempt's own diagnosis, after which requeue or supersede can recover
814
+ // it. An Attempt still running or validating is genuinely in flight.
815
+ if (entry.status !== "running")
816
+ continue;
817
+ if (attempt.status !== "failed" && attempt.status !== "blocked")
818
+ continue;
819
+ const diagnosis = attempt.status === "blocked"
820
+ ? attempt.conflict?.summary ?? "Integration blocked without a conflict report."
821
+ : gateFailureSummary(attempt);
822
+ store.saveIntegrationQueueEntry(taskId, markIntegrationQueueBlocked(entry, diagnosis, now()));
823
+ }
824
+ }
825
+ async function recomputeAffectedPaths(store, taskId, committed, projectPath, git, now) {
826
+ const committedChangeSet = store.getChangeSet(taskId, committed.changeSetId);
827
+ if (committedChangeSet === null)
828
+ return;
829
+ const landed = new Set(committedChangeSet.changedPaths);
830
+ for (const entry of store.listIntegrationQueueEntries(taskId)) {
831
+ if (entry.projectId !== committed.projectId)
832
+ continue;
833
+ if (canonicalizeTargetRef(entry.targetRef) !== canonicalizeTargetRef(committed.targetRef))
834
+ continue;
835
+ if (entry.status !== "queued" && entry.status !== "validated")
836
+ continue;
837
+ const changeSet = store.getChangeSet(taskId, entry.changeSetId);
838
+ if (changeSet === null)
839
+ continue;
840
+ // A committed entry whose targetAfter is already an ancestor of the
841
+ // waiting entry's baseCommit is part of that entry's base, not a
842
+ // post-enqueue target advance. This prevents recovery replay from
843
+ // reviving evidence that was valid at enqueue time, and covers both
844
+ // the direct predecessor and transitive history cases.
845
+ if (committed.targetAfter !== undefined
846
+ && await git.isAncestor(projectPath, committed.targetAfter, changeSet.baseCommit))
847
+ continue;
848
+ const affected = changeSet.changedPaths.filter((path) => landed.has(path));
849
+ // Re-read inside the transaction: a concurrent processor may have claimed
850
+ // or conflicted this entry during the async isAncestor gap above. Only
851
+ // persist the recompute when the entry is still waiting to be processed.
852
+ store.transaction((tx) => {
853
+ const current = tx.getIntegrationQueueEntry(taskId, entry.id);
854
+ if (current === null)
855
+ return;
856
+ if (current.status !== "queued" && current.status !== "validated")
857
+ return;
858
+ let updated = recordIntegrationQueueAffectedPaths(current, affected, now());
859
+ if (updated.status === "validated" && (updated.affectedPaths?.length ?? 0) > 0) {
860
+ // The target advanced onto this entry's own paths: its evidence no
861
+ // longer covers the target, so the gate must run again.
862
+ updated = markIntegrationQueueRequeued(updated, now());
863
+ }
864
+ // A queued entry keeps its place and runs its checks against the exact
865
+ // current target at process time. Disjoint changedPaths must never
866
+ // rebind its evidence to the new target head: a gate may read any file,
867
+ // so a non-empty target increment cannot be proven irrelevant from path
868
+ // metadata alone.
869
+ if (updated.status !== current.status
870
+ || (updated.affectedPaths ?? []).join("\n") !== (current.affectedPaths ?? []).join("\n")) {
871
+ tx.saveIntegrationQueueEntry(taskId, updated);
872
+ }
873
+ });
874
+ }
875
+ }
876
+ function committedTargetAfter(attempt) {
877
+ if (attempt.candidateCommit === undefined) {
878
+ throw new Error(`Committed Integration Attempt has no candidate commit: ${attempt.id}.`);
879
+ }
880
+ return attempt.candidateCommit;
881
+ }
882
+ function gateFailureSummary(attempt) {
883
+ const failed = (attempt.checks ?? []).find((check) => check.outcome === "failed");
884
+ return failed === undefined
885
+ ? "Integration failed before the target ref advanced."
886
+ : `gate failed: ${failed.name}${failed.details === undefined ? "" : `: ${failed.details}`}`;
887
+ }
888
+ async function findEquivalentCommit(git, repositoryPath, sourceCommit, historyHead, baseCommit) {
889
+ if (git.findCommitWithSameTreeInHistory === undefined) {
890
+ return (await git.isAncestor(repositoryPath, sourceCommit, historyHead))
891
+ ? sourceCommit
892
+ : null;
893
+ }
894
+ const found = await git.findCommitWithSameTreeInHistory({
895
+ repositoryPath,
896
+ sourceCommit,
897
+ historyHead
898
+ });
899
+ if (found === null)
900
+ return null;
901
+ // The exact source commit landing on the target is integration evidence
902
+ // regardless of the base relationship. A different same-tree commit is
903
+ // only evidence when it is at or after the ChangeSet base: a same-tree
904
+ // commit older than the base is history the ChangeSet may be deliberately
905
+ // restoring, not proof that the restore landed.
906
+ if (found === sourceCommit)
907
+ return found;
908
+ return (await git.isAncestor(repositoryPath, baseCommit, found)) ? found : null;
909
+ }
910
+ /**
911
+ * A ChangeSet whose head already agrees with the target on every path it
912
+ * touched (deletions included) is fully represented there: an identical
913
+ * parallel change landed through another commit. Converge it directly,
914
+ * since applying it would be a no-op and a second commit would only
915
+ * duplicate the same work.
916
+ */
917
+ async function findContainedChangeSet(git, repositoryPath, changeSet, targetHead) {
918
+ if (git.treesAgreeOnPaths === undefined)
919
+ return null;
920
+ // A migrated v2 ChangeSet without a manifest has no deletedPaths. Recover
921
+ // the actual deletions from git so the containment proof verifies both
922
+ // sides of a rename: without this, a rename whose destination already
923
+ // exists on the target would converge even though the source still lives
924
+ // there. A ChangeSet with a manifest trusts its declared deletedPaths.
925
+ let extraDeleted = [];
926
+ if (changeSet.manifest === undefined) {
927
+ if (git.deletedFilesBetween === undefined)
928
+ return null;
929
+ extraDeleted = await git.deletedFilesBetween({
930
+ repositoryPath,
931
+ fromCommit: changeSet.baseCommit,
932
+ toCommit: changeSet.headCommit
933
+ });
934
+ }
935
+ const paths = [...new Set([
936
+ ...changeSet.changedPaths,
937
+ ...(changeSet.manifest?.deletedPaths ?? []),
938
+ ...extraDeleted
939
+ ])];
940
+ if (paths.length === 0)
941
+ return null;
942
+ const agrees = await git.treesAgreeOnPaths({
943
+ repositoryPath,
944
+ leftCommit: changeSet.headCommit,
945
+ rightCommit: targetHead,
946
+ paths
947
+ });
948
+ return agrees ? targetHead : null;
949
+ }
950
+ function taskMainBranch(store, taskId, projectId) {
951
+ const mainWorkspace = store.getTaskWorkspace(taskId);
952
+ if (mainWorkspace === null)
953
+ return undefined;
954
+ return workspaceProjectEntry(mainWorkspace, projectId)?.branch;
955
+ }