@zq-silk/yui 0.6.0 → 0.6.2
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.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -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
|
+
}
|