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