@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
|
@@ -5,13 +5,19 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { selectEnvironment } from "../agent/launchEnvironment.js";
|
|
7
7
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
8
|
+
import { planBootstrapJobSteps, planL2JobSteps } from "../verification/verificationPlan.js";
|
|
9
|
+
import { assertNoAdHocFullSuiteChecks, checkResultsFromGateArtifact, checkResultsFromGateJob, gateIdentityForCandidate, lookupReusableGateArtifact, recordGateArtifactFromJob, recordGateArtifactFromStepOutcomes, resolveVerificationGate, runGateStepsInProcess } from "../verification/verificationGateService.js";
|
|
10
|
+
import { recordGateArtifactPotentialReuse, recordGateArtifactReuse } from "../verification/gateArtifact.js";
|
|
11
|
+
import { touchGateArtifact } from "../verification/gateArtifactStore.js";
|
|
8
12
|
import { NodeGitWorkspace, RemoteBaselineConflictError } from "../repository/gitWorkspace.js";
|
|
9
13
|
import { resolveWorktreeRoot } from "../repository/taskWorkspacePreparer.js";
|
|
14
|
+
import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
|
|
10
15
|
import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
|
|
11
16
|
import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
|
|
12
17
|
import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
13
|
-
import { requireLeaderDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
18
|
+
import { recordIntegrationCheckJob, requireLeaderDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
14
19
|
import { createManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
20
|
+
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
15
21
|
const executeFile = promisify(execFile);
|
|
16
22
|
const INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES = [
|
|
17
23
|
"PATH",
|
|
@@ -47,188 +53,383 @@ export class GitIntegrationService {
|
|
|
47
53
|
store;
|
|
48
54
|
git;
|
|
49
55
|
now;
|
|
56
|
+
jobPort;
|
|
50
57
|
home;
|
|
51
58
|
worktreeRoot;
|
|
52
59
|
environment;
|
|
53
60
|
runtimeIsolation;
|
|
54
|
-
|
|
61
|
+
#resourceRegistrarValue;
|
|
62
|
+
constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home), jobPort) {
|
|
55
63
|
this.store = store;
|
|
56
64
|
this.git = git;
|
|
57
65
|
this.now = now;
|
|
66
|
+
this.jobPort = jobPort;
|
|
58
67
|
this.home = resolve(home);
|
|
59
68
|
this.worktreeRoot = resolveWorktreeRoot(home, store.getConfig().defaultWorkspace);
|
|
60
69
|
this.environment = { ...environment };
|
|
61
70
|
this.runtimeIsolation = runtimeIsolation;
|
|
62
71
|
}
|
|
72
|
+
#resourceRegistrar() {
|
|
73
|
+
return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
|
|
74
|
+
}
|
|
63
75
|
async integrate(taskId, integrationId, options = {}) {
|
|
64
76
|
const initial = requireIntegration(this.store, taskId, integrationId);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
const project = this.store.getProject(initial.projectId);
|
|
73
|
-
if (project === null)
|
|
74
|
-
throw new Error(`Project not found: ${initial.projectId}.`);
|
|
75
|
-
let prepared;
|
|
76
|
-
let workspace;
|
|
77
|
-
let managedWorkspace;
|
|
78
|
-
try {
|
|
79
|
-
prepared = await this.git.ensureIntegrationWorktree({
|
|
80
|
-
repositoryPath: project.path,
|
|
81
|
-
container: join(this.worktreeRoot, project.name),
|
|
82
|
-
taskSegment: taskWorkspaceRefSegment(task),
|
|
83
|
-
integrationId: initial.id,
|
|
84
|
-
baseRef: initial.expectedHead
|
|
85
|
-
});
|
|
86
|
-
workspace = {
|
|
87
|
-
projectId: project.id,
|
|
88
|
-
path: prepared.path,
|
|
89
|
-
branch: prepared.branch,
|
|
90
|
-
baseCommit: prepared.baseCommit
|
|
91
|
-
};
|
|
92
|
-
const existingWorkspace = this.store.getIntegrationWorkspace(task.id, initial.id);
|
|
93
|
-
managedWorkspace = existingWorkspace ?? createManagedWorkspace({
|
|
94
|
-
owner: {
|
|
95
|
-
type: "integration-attempt",
|
|
96
|
-
taskId: task.id,
|
|
97
|
-
integrationAttemptId: initial.id
|
|
98
|
-
},
|
|
99
|
-
root: prepared.path,
|
|
100
|
-
entries: [{
|
|
101
|
-
projectId: project.id,
|
|
102
|
-
directory: project.name,
|
|
103
|
-
access: "write",
|
|
104
|
-
path: prepared.path,
|
|
105
|
-
branch: prepared.branch,
|
|
106
|
-
baseRef: initial.expectedHead,
|
|
107
|
-
baseCommit: prepared.baseCommit
|
|
108
|
-
}]
|
|
109
|
-
}, this.now());
|
|
110
|
-
this.store.saveManagedWorkspace(managedWorkspace);
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
return this.#fail(initial, error, "integration-preparation");
|
|
114
|
-
}
|
|
115
|
-
if (initial.status === "validating") {
|
|
116
|
-
return this.#recoverValidating(initial, workspace, project.path);
|
|
117
|
-
}
|
|
118
|
-
let current = initial;
|
|
77
|
+
// The whole Integration is one Git transaction against the Project's
|
|
78
|
+
// repository: worktree creation, cherry-picks, checks, and the target ref
|
|
79
|
+
// CAS. A concurrent `project migrate` must not switch the catalog path or
|
|
80
|
+
// remove the old checkout mid-Integration, so the per-Project maintenance
|
|
81
|
+
// fence is held across every Git effect and released only on exit.
|
|
82
|
+
const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
|
|
119
83
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
84
|
+
const task = this.store.getTask(initial.taskId);
|
|
85
|
+
if (task === null || !task.projectBindings.some(({ projectId }) => projectId === initial.projectId)) {
|
|
86
|
+
throw new Error(`Integration Task Project is unavailable: ${initial.taskId}.`);
|
|
87
|
+
}
|
|
88
|
+
if (task.status !== "active") {
|
|
89
|
+
throw new Error(`Integration Task is not active: ${task.id}/${task.status}.`);
|
|
90
|
+
}
|
|
91
|
+
const project = this.store.getProject(initial.projectId);
|
|
92
|
+
if (project === null)
|
|
93
|
+
throw new Error(`Project not found: ${initial.projectId}.`);
|
|
94
|
+
// Issue 08: a Project with a VerificationPlan gates through its plan
|
|
95
|
+
// (bootstrap + L2) and reuses exact-SHA artifacts; an unconfigured
|
|
96
|
+
// Project keeps the existing explicit check path unchanged.
|
|
97
|
+
const gate = resolveVerificationGate(project, this.environment);
|
|
98
|
+
let prepared;
|
|
99
|
+
let workspace;
|
|
100
|
+
let managedWorkspace;
|
|
101
|
+
try {
|
|
102
|
+
prepared = await this.git.ensureIntegrationWorktree({
|
|
103
|
+
repositoryPath: project.path,
|
|
104
|
+
container: join(this.worktreeRoot, project.name),
|
|
105
|
+
taskSegment: taskWorkspaceRefSegment(task),
|
|
106
|
+
integrationId: initial.id,
|
|
107
|
+
baseRef: initial.expectedHead
|
|
129
108
|
});
|
|
109
|
+
workspace = {
|
|
110
|
+
projectId: project.id,
|
|
111
|
+
path: prepared.path,
|
|
112
|
+
branch: prepared.branch,
|
|
113
|
+
baseCommit: prepared.baseCommit
|
|
114
|
+
};
|
|
115
|
+
const existingWorkspace = this.store.getIntegrationWorkspace(task.id, initial.id);
|
|
116
|
+
managedWorkspace = existingWorkspace ?? createManagedWorkspace({
|
|
117
|
+
owner: {
|
|
118
|
+
type: "integration-attempt",
|
|
119
|
+
taskId: task.id,
|
|
120
|
+
integrationAttemptId: initial.id
|
|
121
|
+
},
|
|
122
|
+
root: prepared.path,
|
|
123
|
+
entries: [{
|
|
124
|
+
projectId: project.id,
|
|
125
|
+
directory: project.name,
|
|
126
|
+
access: "write",
|
|
127
|
+
path: prepared.path,
|
|
128
|
+
branch: prepared.branch,
|
|
129
|
+
baseRef: initial.expectedHead,
|
|
130
|
+
baseCommit: prepared.baseCommit
|
|
131
|
+
}]
|
|
132
|
+
}, this.now());
|
|
133
|
+
this.#resourceRegistrar().registerManagedWorkspace(managedWorkspace);
|
|
134
|
+
this.store.saveManagedWorkspace(managedWorkspace);
|
|
130
135
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Keep their IDs as provenance/check evidence, but never cherry-pick the
|
|
134
|
-
// source commits again. A manual continuation of a remote merge carries
|
|
135
|
-
// the same semantic marker in its conflict report.
|
|
136
|
-
const remoteOnly = options.remoteBaseline !== undefined
|
|
137
|
-
|| (current.resolution?.action === "manual-resolution"
|
|
138
|
-
&& current.conflict?.summary.startsWith(REMOTE_BASELINE_CONFLICT_PREFIX));
|
|
139
|
-
const plan = remoteOnly
|
|
140
|
-
? []
|
|
141
|
-
: await integrationCommitPlan(this.store, task.id, project.path, current.changeSetIds, current.expectedHead);
|
|
142
|
-
let remaining = plan;
|
|
143
|
-
if (current.resolution?.action === "manual-resolution"
|
|
144
|
-
&& current.conflict?.summary.startsWith(REMOTE_BASELINE_CONFLICT_PREFIX)) {
|
|
145
|
-
await completeRemoteBaselineResolution(prepared.path);
|
|
136
|
+
catch (error) {
|
|
137
|
+
return this.#fail(initial, error, "integration-preparation");
|
|
146
138
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const resolvedIndex = plan.findIndex(({ commit }) => commit === resolvedCommit);
|
|
150
|
-
if (resolvedIndex < 0) {
|
|
151
|
-
throw new Error(`Manual resolution commit is not part of the Integration plan: ${resolvedCommit}.`);
|
|
152
|
-
}
|
|
153
|
-
remaining = plan.slice(resolvedIndex + 1);
|
|
139
|
+
if (initial.status === "validating") {
|
|
140
|
+
return this.#recoverValidating(initial, workspace, project.path);
|
|
154
141
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
let current = initial;
|
|
143
|
+
// A check DurableJob is the source of truth for a running attempt that
|
|
144
|
+
// already bound one: never re-apply commits or spawn a second job. The
|
|
145
|
+
// job's terminal wakeup drives the resume through `integration continue`.
|
|
146
|
+
if (current.status === "running" && current.jobId !== undefined) {
|
|
147
|
+
return this.#resumeCheckJob(current, workspace, prepared.path, project.path, gate);
|
|
158
148
|
}
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
try {
|
|
150
|
+
if (options.remoteBaseline !== undefined) {
|
|
151
|
+
const mergeRemote = this.git.mergeRemoteIntoWorktree;
|
|
152
|
+
if (mergeRemote === undefined) {
|
|
153
|
+
throw new Error("Integration Git workspace does not support remote baseline reconciliation.");
|
|
154
|
+
}
|
|
155
|
+
await mergeRemote.call(this.git, {
|
|
156
|
+
repositoryPath: prepared.path,
|
|
157
|
+
remoteUrl: options.remoteBaseline.remoteUrl,
|
|
158
|
+
branch: options.remoteBaseline.branch
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// A remote-baseline Attempt starts from the exact previously committed
|
|
162
|
+
// Task head, so its ChangeSets are already represented by that tree.
|
|
163
|
+
// Keep their IDs as provenance/check evidence, but never cherry-pick the
|
|
164
|
+
// source commits again. A manual continuation of a remote merge carries
|
|
165
|
+
// the same semantic marker in its conflict report.
|
|
166
|
+
const remoteOnly = options.remoteBaseline !== undefined
|
|
167
|
+
|| (current.resolution?.action === "manual-resolution"
|
|
168
|
+
&& current.conflict?.summary.startsWith(REMOTE_BASELINE_CONFLICT_PREFIX));
|
|
169
|
+
const plan = remoteOnly
|
|
170
|
+
? []
|
|
171
|
+
: await integrationCommitPlan(this.store, task.id, project.path, current.changeSetIds, current.expectedHead);
|
|
172
|
+
let remaining = plan;
|
|
173
|
+
if (current.resolution?.action === "manual-resolution"
|
|
174
|
+
&& current.conflict?.summary.startsWith(REMOTE_BASELINE_CONFLICT_PREFIX)) {
|
|
175
|
+
await completeRemoteBaselineResolution(prepared.path);
|
|
176
|
+
}
|
|
177
|
+
else if (current.resolution?.action === "manual-resolution") {
|
|
178
|
+
const resolvedCommit = await completeManualResolution(prepared.path);
|
|
179
|
+
const resolvedIndex = plan.findIndex(({ commit }) => commit === resolvedCommit);
|
|
180
|
+
if (resolvedIndex < 0) {
|
|
181
|
+
throw new Error(`Manual resolution commit is not part of the Integration plan: ${resolvedCommit}.`);
|
|
182
|
+
}
|
|
183
|
+
remaining = plan.slice(resolvedIndex + 1);
|
|
184
|
+
}
|
|
185
|
+
const conflict = await this.#applyCommits(current, workspace, prepared.path, remaining);
|
|
186
|
+
if (conflict !== undefined) {
|
|
187
|
+
return conflict;
|
|
188
|
+
}
|
|
189
|
+
// Static preflight: fail before any expensive check when the target
|
|
190
|
+
// moved or its worktree is dirty, so the check commands never run on a
|
|
191
|
+
// target that cannot be advanced. advanceTargetRef re-verifies both
|
|
192
|
+
// after the checks, so a move during the gate is still fenced at CAS.
|
|
193
|
+
await assertTargetReadyForChecks(project.path, current.targetRef, current.expectedHead);
|
|
194
|
+
if (gate !== undefined) {
|
|
195
|
+
return this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, project.path, gate);
|
|
196
|
+
}
|
|
197
|
+
if (this.jobPort !== undefined && current.checkCommands.length > 0) {
|
|
198
|
+
return this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, project.path);
|
|
199
|
+
}
|
|
200
|
+
const checkedHead = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
|
|
201
|
+
const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path);
|
|
202
|
+
const afterHead = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
|
|
203
|
+
if (afterHead !== checkedHead) {
|
|
204
|
+
return this.#fail(current, new Error(`Integration workspace moved during the checks: ${afterHead} != checked ${checkedHead}.`), "integration", workspace);
|
|
205
|
+
}
|
|
206
|
+
if (checkResults.some((check) => check.outcome === "failed")) {
|
|
207
|
+
current = updateIntegrationAttempt(current, {
|
|
208
|
+
status: "failed",
|
|
209
|
+
checks: checkResults
|
|
210
|
+
}, this.now());
|
|
211
|
+
this.store.saveIntegrationAttempt(task.id, current);
|
|
212
|
+
return this.#terminalResult("failed", current, workspace);
|
|
213
|
+
}
|
|
214
|
+
const candidateCommit = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
|
|
161
215
|
current = updateIntegrationAttempt(current, {
|
|
162
|
-
status: "
|
|
216
|
+
status: "validating",
|
|
217
|
+
candidateCommit,
|
|
163
218
|
checks: checkResults
|
|
164
219
|
}, this.now());
|
|
165
220
|
this.store.saveIntegrationAttempt(task.id, current);
|
|
166
|
-
|
|
221
|
+
await advanceTargetRef(project.path, current.targetRef, candidateCommit, current.expectedHead);
|
|
222
|
+
const committed = updateIntegrationAttempt(current, { status: "committed" }, this.now());
|
|
223
|
+
this.store.saveIntegrationAttempt(task.id, committed);
|
|
224
|
+
return this.#terminalResult("committed", committed, workspace);
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error instanceof RemoteBaselineConflictError) {
|
|
228
|
+
const pending = requireLeaderDecision(current, {
|
|
229
|
+
affectedPaths: error.affectedPaths,
|
|
230
|
+
summary: error.message
|
|
231
|
+
}, this.now());
|
|
232
|
+
this.store.saveIntegrationAttempt(task.id, pending);
|
|
233
|
+
return { status: "blocked", attempt: pending, workspace };
|
|
234
|
+
}
|
|
235
|
+
if (current.status === "validating" && current.candidateCommit !== undefined) {
|
|
236
|
+
const target = await resolveRef(project.path, current.targetRef);
|
|
237
|
+
if (target === current.candidateCommit) {
|
|
238
|
+
const committed = updateIntegrationAttempt(current, { status: "committed" }, this.now());
|
|
239
|
+
this.store.saveIntegrationAttempt(task.id, committed);
|
|
240
|
+
return this.#terminalResult("committed", committed, workspace);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return this.#fail(current, error, "integration", workspace);
|
|
167
244
|
}
|
|
168
|
-
const candidateCommit = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
|
|
169
|
-
current = updateIntegrationAttempt(current, {
|
|
170
|
-
status: "validating",
|
|
171
|
-
candidateCommit,
|
|
172
|
-
checks: checkResults
|
|
173
|
-
}, this.now());
|
|
174
|
-
this.store.saveIntegrationAttempt(task.id, current);
|
|
175
|
-
await advanceTargetRef(project.path, current.targetRef, candidateCommit, current.expectedHead);
|
|
176
|
-
const committed = updateIntegrationAttempt(current, { status: "committed" }, this.now());
|
|
177
|
-
this.store.saveIntegrationAttempt(task.id, committed);
|
|
178
|
-
return this.#terminalResult("committed", committed, workspace);
|
|
179
245
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
finally {
|
|
247
|
+
release();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async cleanup(integration) {
|
|
251
|
+
// The whole cleanup is one Git transaction against the Project's
|
|
252
|
+
// repository, mirroring integrate(): a concurrent `project migrate` must
|
|
253
|
+
// not switch the catalog path or remove the old checkout mid-cleanup.
|
|
254
|
+
const release = acquireProjectMaintenanceLocks(this.home, [integration.projectId]);
|
|
255
|
+
try {
|
|
256
|
+
const task = this.store.getTask(integration.taskId);
|
|
257
|
+
if (task === null || !task.projectBindings.some(({ projectId }) => projectId === integration.projectId)) {
|
|
258
|
+
throw new Error(`Integration Task Project is unavailable: ${integration.id}.`);
|
|
259
|
+
}
|
|
260
|
+
const project = this.store.getProject(integration.projectId);
|
|
261
|
+
if (project === null)
|
|
262
|
+
throw new Error(`Project not found: ${integration.projectId}.`);
|
|
263
|
+
const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
|
|
264
|
+
if (managedWorkspace !== null) {
|
|
265
|
+
const runtime = this.#runtimePreparation(integration, managedWorkspace);
|
|
266
|
+
this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
|
|
188
267
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
268
|
+
const result = await this.git.removeIntegrationWorktree({
|
|
269
|
+
repositoryPath: project.path,
|
|
270
|
+
container: join(this.worktreeRoot, project.name),
|
|
271
|
+
taskSegment: taskWorkspaceRefSegment(task),
|
|
272
|
+
integrationId: integration.id,
|
|
273
|
+
discardChanges: integration.status === "failed"
|
|
274
|
+
});
|
|
275
|
+
if (result !== "dirty") {
|
|
276
|
+
if (managedWorkspace !== null) {
|
|
277
|
+
this.#resourceRegistrar().markWorkspaceDeleted(managedWorkspace);
|
|
195
278
|
}
|
|
279
|
+
await rm(integrationCheckDirectory(this.home, task.id, integration.id), {
|
|
280
|
+
recursive: true,
|
|
281
|
+
force: true
|
|
282
|
+
});
|
|
283
|
+
this.store.removeManagedWorkspace({
|
|
284
|
+
type: "integration-attempt",
|
|
285
|
+
taskId: integration.taskId,
|
|
286
|
+
integrationAttemptId: integration.id
|
|
287
|
+
});
|
|
196
288
|
}
|
|
197
|
-
return
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
release();
|
|
198
293
|
}
|
|
199
294
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Hand the check commands to a Controller-owned DurableJob. The job runs
|
|
297
|
+
* the same isolated environment the in-process checks used; the attempt
|
|
298
|
+
* stays `running` with its jobId until the job's terminal wakeup resumes
|
|
299
|
+
* it, so a Leader exit mid-checks leaves no running/no-check zombie.
|
|
300
|
+
*/
|
|
301
|
+
async #startCheckJob(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
|
|
302
|
+
const runtime = this.#runtimePreparation(attempt, managedWorkspace);
|
|
303
|
+
this.runtimeIsolation.activate(runtime);
|
|
304
|
+
const environment = await integrationCheckEnvironment(this.environment, runtime);
|
|
305
|
+
const head = await gitLine(["-C", path, "rev-parse", "HEAD^{commit}"]);
|
|
306
|
+
const steps = gate === undefined
|
|
307
|
+
? attempt.checkCommands.map((command, index) => ({
|
|
308
|
+
name: `check-${index + 1}`,
|
|
309
|
+
command,
|
|
310
|
+
timeoutMs: 30 * 60_000
|
|
311
|
+
}))
|
|
312
|
+
: [
|
|
313
|
+
...planBootstrapJobSteps(gate.plan).map((step) => ({
|
|
314
|
+
...step,
|
|
315
|
+
timeoutMs: 30 * 60_000
|
|
316
|
+
})),
|
|
317
|
+
...planL2JobSteps(gate.plan).map((step) => ({
|
|
318
|
+
...step,
|
|
319
|
+
timeoutMs: 30 * 60_000
|
|
320
|
+
}))
|
|
321
|
+
];
|
|
322
|
+
// Persist the gate identity before starting the job so a plan edit
|
|
323
|
+
// during the gate never misattributes the evidence on resume.
|
|
324
|
+
let persisted = attempt;
|
|
325
|
+
if (gate !== undefined) {
|
|
326
|
+
persisted = updateIntegrationAttempt(attempt, {
|
|
327
|
+
gatePlanDigest: gate.planDigest,
|
|
328
|
+
gateToolchainDigest: gate.toolchainDigest
|
|
329
|
+
}, this.now());
|
|
330
|
+
this.store.saveIntegrationAttempt(attempt.taskId, persisted);
|
|
204
331
|
}
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
332
|
+
const job = await this.jobPort.startCheckJob({
|
|
333
|
+
taskId: attempt.taskId,
|
|
334
|
+
integrationId: attempt.id,
|
|
335
|
+
projectId: attempt.projectId,
|
|
336
|
+
head,
|
|
337
|
+
workspace: path,
|
|
338
|
+
env: environment,
|
|
339
|
+
steps
|
|
340
|
+
});
|
|
341
|
+
const bound = recordIntegrationCheckJob(persisted, job.id, this.now());
|
|
342
|
+
this.store.saveIntegrationAttempt(attempt.taskId, bound);
|
|
343
|
+
if (job.status !== "queued" && job.status !== "running") {
|
|
344
|
+
// Idempotent re-entry after a Leader exit in the bind window: the
|
|
345
|
+
// Controller returned the already-terminal job. Converge through the
|
|
346
|
+
// normal resume path instead of a checks-running zombie.
|
|
347
|
+
return this.#resumeCheckJob(bound, workspace, path, repositoryPath, gate);
|
|
348
|
+
}
|
|
349
|
+
return { status: "checks-running", attempt: bound, workspace, job };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Resume a running attempt whose check job is already bound. An active job
|
|
353
|
+
* reports checks-running without side effects; a terminal job finalizes the
|
|
354
|
+
* attempt through the same validating/committed or failed path as the
|
|
355
|
+
* in-process checks. `unknown-needs-attention` fails closed: the attempt
|
|
356
|
+
* fails and the target ref never advances.
|
|
357
|
+
*/
|
|
358
|
+
async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
|
|
359
|
+
const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
|
|
360
|
+
if (job.status === "queued" || job.status === "running") {
|
|
361
|
+
return { status: "checks-running", attempt, workspace, job };
|
|
362
|
+
}
|
|
363
|
+
const planStyle = gate !== undefined
|
|
364
|
+
|| (job.steps?.some((step) => step.name.startsWith("bootstrap-") || step.name.startsWith("gate-")) ?? false);
|
|
365
|
+
const checks = planStyle
|
|
366
|
+
? checkResultsFromGateJob(job, this.home)
|
|
367
|
+
: checkResultsFromJob(attempt, job);
|
|
368
|
+
const managedWorkspace = this.store.getIntegrationWorkspace(attempt.taskId, attempt.id);
|
|
209
369
|
if (managedWorkspace !== null) {
|
|
210
|
-
const runtime = this.#runtimePreparation(
|
|
211
|
-
this.runtimeIsolation.cleanup(runtime,
|
|
370
|
+
const runtime = this.#runtimePreparation(attempt, managedWorkspace);
|
|
371
|
+
this.runtimeIsolation.cleanup(runtime, checks.some((check) => check.outcome === "failed") ? "failure" : "completion");
|
|
212
372
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
373
|
+
// Issue 08: record the GateArtifact for a plan-gated attempt. The
|
|
374
|
+
// identity is recomputed from the current plan and the job's exact
|
|
375
|
+
// checked head; a plan/toolchain change since the job started yields a
|
|
376
|
+
// different key, so the artifact is never misattributed (the attempt
|
|
377
|
+
// still converges from the job's own evidence).
|
|
378
|
+
if (gate !== undefined
|
|
379
|
+
&& (job.result?.outcome === "succeeded" || job.result?.outcome === "failed")) {
|
|
380
|
+
// Use the digests captured at job start so a plan edit during the
|
|
381
|
+
// gate never misattributes the evidence.
|
|
382
|
+
const recordGate = attempt.gatePlanDigest !== undefined
|
|
383
|
+
? Object.freeze({
|
|
384
|
+
...gate,
|
|
385
|
+
planDigest: attempt.gatePlanDigest,
|
|
386
|
+
toolchainDigest: attempt.gateToolchainDigest ?? gate.toolchainDigest
|
|
387
|
+
})
|
|
388
|
+
: gate;
|
|
389
|
+
const identity = gateIdentityForCandidate({
|
|
390
|
+
projectId: attempt.projectId,
|
|
391
|
+
gate: recordGate,
|
|
392
|
+
level: "L2",
|
|
393
|
+
commit: job.head,
|
|
394
|
+
targetRef: attempt.targetRef,
|
|
395
|
+
baseHead: attempt.expectedHead
|
|
229
396
|
});
|
|
397
|
+
try {
|
|
398
|
+
const artifact = await recordGateArtifactFromJob(this.store, this.home, identity, gate.plan, job, this.now());
|
|
399
|
+
if (checks.every((check) => check.outcome !== "failed")) {
|
|
400
|
+
checks.push(...checkResultsFromGateArtifact(artifact));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
// A failed artifact import (e.g. a lost job log) must not fake
|
|
405
|
+
// evidence: the attempt fails closed without a reusable artifact.
|
|
406
|
+
return this.#fail(attempt, error instanceof Error ? error : new Error(String(error)), "gate-artifact", workspace);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (checks.some((check) => check.outcome === "failed")) {
|
|
410
|
+
const failed = updateIntegrationAttempt(attempt, {
|
|
411
|
+
status: "failed",
|
|
412
|
+
checks
|
|
413
|
+
}, this.now());
|
|
414
|
+
this.store.saveIntegrationAttempt(attempt.taskId, failed);
|
|
415
|
+
return this.#terminalResult("failed", failed, workspace);
|
|
416
|
+
}
|
|
417
|
+
const candidateCommit = await gitLine(["-C", path, "rev-parse", "HEAD^{commit}"]);
|
|
418
|
+
if (candidateCommit !== job.head) {
|
|
419
|
+
// The job proved the checks at one SHA; the workspace has since moved.
|
|
420
|
+
// Advancing the target ref would publish unchecked code, so fail closed.
|
|
421
|
+
return this.#fail(attempt, new Error(`Integration workspace moved since the check ran: ${candidateCommit} != checked ${job.head}.`), "integration", workspace);
|
|
230
422
|
}
|
|
231
|
-
|
|
423
|
+
const validating = updateIntegrationAttempt(attempt, {
|
|
424
|
+
status: "validating",
|
|
425
|
+
candidateCommit,
|
|
426
|
+
checks
|
|
427
|
+
}, this.now());
|
|
428
|
+
this.store.saveIntegrationAttempt(attempt.taskId, validating);
|
|
429
|
+
await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.expectedHead);
|
|
430
|
+
const committed = updateIntegrationAttempt(validating, { status: "committed" }, this.now());
|
|
431
|
+
this.store.saveIntegrationAttempt(attempt.taskId, committed);
|
|
432
|
+
return this.#terminalResult("committed", committed, workspace);
|
|
232
433
|
}
|
|
233
434
|
async #runChecks(attempt, workspace, path) {
|
|
234
435
|
if (attempt.checkCommands.length === 0)
|
|
@@ -248,6 +449,95 @@ export class GitIntegrationService {
|
|
|
248
449
|
this.runtimeIsolation.cleanup(runtime, cleanupReason);
|
|
249
450
|
}
|
|
250
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Issue 08: the VerificationPlan gate for a configured Project.
|
|
454
|
+
*
|
|
455
|
+
* Enforce mode rejects ad-hoc full-suite checks before the gate. Reuse mode
|
|
456
|
+
* returns an existing successful artifact for the same identity tuple
|
|
457
|
+
* (project + exact commit + plan digest + toolchain digest + target
|
|
458
|
+
* boundary); record mode always runs and only counts shadow potential
|
|
459
|
+
* reuses. The gate itself runs as bootstrap + L2 DurableJob steps (or
|
|
460
|
+
* in-process when no Controller job port is available) and records a
|
|
461
|
+
* self-contained GateArtifact. The final CAS in
|
|
462
|
+
* {@link #finalizeGateSuccess} still fences a target that moves during the
|
|
463
|
+
* gate.
|
|
464
|
+
*/
|
|
465
|
+
async #runVerificationGate(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
|
|
466
|
+
if (gate.mode === "enforce") {
|
|
467
|
+
try {
|
|
468
|
+
assertNoAdHocFullSuiteChecks(gate.plan, attempt.checkCommands);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
return this.#fail(attempt, error instanceof Error ? error : new Error(String(error)), "verification-plan", workspace);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const candidateCommit = await gitLine(["-C", path, "rev-parse", "HEAD^{commit}"]);
|
|
475
|
+
const identity = gateIdentityForCandidate({
|
|
476
|
+
projectId: attempt.projectId,
|
|
477
|
+
gate,
|
|
478
|
+
level: "L2",
|
|
479
|
+
commit: candidateCommit,
|
|
480
|
+
targetRef: attempt.targetRef,
|
|
481
|
+
baseHead: attempt.expectedHead
|
|
482
|
+
});
|
|
483
|
+
if (gate.mode !== "record") {
|
|
484
|
+
const existing = await lookupReusableGateArtifact(this.store, identity);
|
|
485
|
+
if (existing !== null) {
|
|
486
|
+
touchGateArtifact(this.store, recordGateArtifactReuse(existing, this.now()));
|
|
487
|
+
const checks = checkResultsFromGateArtifact(existing, true);
|
|
488
|
+
return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// Record mode: observe the potential reuse without skipping the gate.
|
|
493
|
+
const existing = await lookupReusableGateArtifact(this.store, identity);
|
|
494
|
+
if (existing !== null) {
|
|
495
|
+
touchGateArtifact(this.store, recordGateArtifactPotentialReuse(existing, this.now()));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (this.jobPort !== undefined) {
|
|
499
|
+
return this.#startCheckJob(attempt, workspace, path, managedWorkspace, repositoryPath, gate);
|
|
500
|
+
}
|
|
501
|
+
// Jobless fallback (queue processing without a Controller): run the
|
|
502
|
+
// plan gate in-process and record the artifact directly.
|
|
503
|
+
const runtime = this.#runtimePreparation(attempt, managedWorkspace);
|
|
504
|
+
this.runtimeIsolation.activate(runtime);
|
|
505
|
+
let cleanupReason = "failure";
|
|
506
|
+
try {
|
|
507
|
+
const environment = await integrationCheckEnvironment(this.environment, runtime);
|
|
508
|
+
const steps = [
|
|
509
|
+
...planBootstrapJobSteps(gate.plan),
|
|
510
|
+
...planL2JobSteps(gate.plan)
|
|
511
|
+
];
|
|
512
|
+
const outcomes = await runGateStepsInProcess(path, steps, environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
|
|
513
|
+
const succeeded = outcomes.length === steps.length
|
|
514
|
+
&& outcomes.every((outcome) => outcome.exitCode === 0 && outcome.signal === null && !outcome.timedOut);
|
|
515
|
+
const artifact = await recordGateArtifactFromStepOutcomes(this.store, identity, gate.plan, outcomes, succeeded, this.now());
|
|
516
|
+
const checks = checkResultsFromGateArtifact(artifact);
|
|
517
|
+
cleanupReason = succeeded ? "completion" : "failure";
|
|
518
|
+
if (!succeeded) {
|
|
519
|
+
const failed = updateIntegrationAttempt(attempt, { status: "failed", checks }, this.now());
|
|
520
|
+
this.store.saveIntegrationAttempt(attempt.taskId, failed);
|
|
521
|
+
return this.#terminalResult("failed", failed, workspace);
|
|
522
|
+
}
|
|
523
|
+
return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks);
|
|
524
|
+
}
|
|
525
|
+
finally {
|
|
526
|
+
this.runtimeIsolation.cleanup(runtime, cleanupReason);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async #finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks) {
|
|
530
|
+
const validating = updateIntegrationAttempt(attempt, {
|
|
531
|
+
status: "validating",
|
|
532
|
+
candidateCommit,
|
|
533
|
+
checks
|
|
534
|
+
}, this.now());
|
|
535
|
+
this.store.saveIntegrationAttempt(attempt.taskId, validating);
|
|
536
|
+
await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.expectedHead);
|
|
537
|
+
const committed = updateIntegrationAttempt(validating, { status: "committed" }, this.now());
|
|
538
|
+
this.store.saveIntegrationAttempt(attempt.taskId, committed);
|
|
539
|
+
return this.#terminalResult("committed", committed, workspace);
|
|
540
|
+
}
|
|
251
541
|
#runtimePreparation(attempt, workspace) {
|
|
252
542
|
return this.runtimeIsolation.preflight({
|
|
253
543
|
workspace,
|
|
@@ -258,6 +548,13 @@ export class GitIntegrationService {
|
|
|
258
548
|
}
|
|
259
549
|
async #applyCommits(attempt, workspace, candidatePath, commits) {
|
|
260
550
|
for (const { changeSetId, commit } of commits) {
|
|
551
|
+
// Fast-forward when the commit is a direct descendant of HEAD: this
|
|
552
|
+
// preserves the original commit SHA, which exact-SHA review evidence
|
|
553
|
+
// relies on. Fall back to cherry-pick when the target moved since the
|
|
554
|
+
// ChangeSet was based (the commit is no longer a direct descendant).
|
|
555
|
+
if (await gitSucceeds(["-C", candidatePath, "merge", "--ff-only", commit])) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
261
558
|
try {
|
|
262
559
|
await git(["-C", candidatePath, "cherry-pick", commit]);
|
|
263
560
|
}
|
|
@@ -409,6 +706,65 @@ async function completeRemoteBaselineResolution(path) {
|
|
|
409
706
|
"commit", "--no-edit"
|
|
410
707
|
]);
|
|
411
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* Map a terminal check job back to the attempt's CheckResult[] shape. The
|
|
711
|
+
* runner stops at the first failing step, so unreached checks are "skipped"
|
|
712
|
+
* except for an unproven (unknown) job, which fails closed on the first
|
|
713
|
+
* missing step so the attempt never passes without evidence. A job that ended
|
|
714
|
+
* without a single failing step (cancelled before any step, or any other
|
|
715
|
+
* non-succeeded outcome) also fails closed: the target ref must never advance
|
|
716
|
+
* on an unproven check.
|
|
717
|
+
*/
|
|
718
|
+
function checkResultsFromJob(attempt, job) {
|
|
719
|
+
const steps = new Map((job.result?.steps ?? []).map((step) => [step.name, step]));
|
|
720
|
+
const checks = attempt.checkCommands.map((command, index) => {
|
|
721
|
+
const name = `check-${index + 1}`;
|
|
722
|
+
const step = steps.get(name);
|
|
723
|
+
const logPath = step === undefined
|
|
724
|
+
? undefined
|
|
725
|
+
: `${job.artifactsLocator}/logs/${step.logPath}`;
|
|
726
|
+
const outputReference = logPath === undefined ? {} : { logPath };
|
|
727
|
+
if (step !== undefined && !step.timedOut && step.exitCode === 0 && step.signal === null) {
|
|
728
|
+
return { name: command, outcome: "passed", ...outputReference };
|
|
729
|
+
}
|
|
730
|
+
if (step !== undefined) {
|
|
731
|
+
const reason = step.timedOut
|
|
732
|
+
? "Command timed out after 1800 seconds."
|
|
733
|
+
: step.signal !== null
|
|
734
|
+
? `Command terminated by ${step.signal}.`
|
|
735
|
+
: `Command exited with code ${step.exitCode}.`;
|
|
736
|
+
return { name: command, outcome: "failed", details: reason, ...outputReference };
|
|
737
|
+
}
|
|
738
|
+
if (job.result?.outcome === "unknown-needs-attention") {
|
|
739
|
+
return {
|
|
740
|
+
name: command,
|
|
741
|
+
outcome: "failed",
|
|
742
|
+
details: `Check job unknown-needs-attention: ${job.result.unknownReason ?? "runner outcome is unproven"}.`
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return { name: command, outcome: "skipped" };
|
|
746
|
+
});
|
|
747
|
+
if (checks.length > 0
|
|
748
|
+
&& !checks.some((check) => check.outcome === "failed")
|
|
749
|
+
&& job.result?.outcome !== "succeeded") {
|
|
750
|
+
checks[0] = {
|
|
751
|
+
name: checks[0].name,
|
|
752
|
+
outcome: "failed",
|
|
753
|
+
details: failClosedJobDetails(job)
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
return checks;
|
|
757
|
+
}
|
|
758
|
+
function failClosedJobDetails(job) {
|
|
759
|
+
const outcome = job.result?.outcome ?? job.status;
|
|
760
|
+
if (outcome === "cancelled") {
|
|
761
|
+
return "Check job was cancelled before it proved the checks.";
|
|
762
|
+
}
|
|
763
|
+
if (outcome === "timed-out") {
|
|
764
|
+
return "Check job timed out before it proved the checks.";
|
|
765
|
+
}
|
|
766
|
+
return `Check job ended ${outcome} without proving the checks.`;
|
|
767
|
+
}
|
|
412
768
|
async function runChecks(path, commands, home, taskId, integrationId, environment) {
|
|
413
769
|
if (commands.length === 0)
|
|
414
770
|
return [];
|
|
@@ -643,6 +999,31 @@ async function resolveRef(repositoryPath, ref) {
|
|
|
643
999
|
`${fullTargetRef(ref)}^{commit}`
|
|
644
1000
|
]);
|
|
645
1001
|
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Static preflight before the (potentially expensive) checks: the target ref
|
|
1004
|
+
* must still equal the expected head and a checked-out target worktree must be
|
|
1005
|
+
* clean. A failure here means the check commands must not run. The post-check
|
|
1006
|
+
* {@link advanceTargetRef} re-verifies both before the CAS, so a target that
|
|
1007
|
+
* moves during the gate is still fenced.
|
|
1008
|
+
*/
|
|
1009
|
+
async function assertTargetReadyForChecks(repositoryPath, targetRef, expectedHead) {
|
|
1010
|
+
const current = await resolveRef(repositoryPath, targetRef);
|
|
1011
|
+
if (current !== expectedHead) {
|
|
1012
|
+
throw new Error(`Target moved to ${current}; expected ${expectedHead}.`);
|
|
1013
|
+
}
|
|
1014
|
+
const checkedOutPaths = await checkedOutWorktreePaths(repositoryPath, fullTargetRef(targetRef));
|
|
1015
|
+
if (checkedOutPaths.length > 1) {
|
|
1016
|
+
throw new Error(`Integration target is checked out in multiple worktrees: ${targetRef}.`);
|
|
1017
|
+
}
|
|
1018
|
+
if (checkedOutPaths.length === 1) {
|
|
1019
|
+
const status = await git([
|
|
1020
|
+
"-C", checkedOutPaths[0], "status", "--porcelain=v1", "--untracked-files=all"
|
|
1021
|
+
]);
|
|
1022
|
+
if (status.trim().length > 0) {
|
|
1023
|
+
throw new Error(`Integration target worktree is not clean: ${checkedOutPaths[0]}.`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
646
1027
|
async function advanceTargetRef(repositoryPath, targetRef, candidateCommit, expectedHead) {
|
|
647
1028
|
const ref = fullTargetRef(targetRef);
|
|
648
1029
|
const checkedOutPaths = await checkedOutWorktreePaths(repositoryPath, ref);
|