@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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. 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
- constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home)) {
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
- const task = this.store.getTask(initial.taskId);
66
- if (task === null || !task.projectBindings.some(({ projectId }) => projectId === initial.projectId)) {
67
- throw new Error(`Integration Task Project is unavailable: ${initial.taskId}.`);
68
- }
69
- if (task.status !== "active") {
70
- throw new Error(`Integration Task is not active: ${task.id}/${task.status}.`);
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
- if (options.remoteBaseline !== undefined) {
121
- const mergeRemote = this.git.mergeRemoteIntoWorktree;
122
- if (mergeRemote === undefined) {
123
- throw new Error("Integration Git workspace does not support remote baseline reconciliation.");
124
- }
125
- await mergeRemote.call(this.git, {
126
- repositoryPath: prepared.path,
127
- remoteUrl: options.remoteBaseline.remoteUrl,
128
- branch: options.remoteBaseline.branch
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
- // A remote-baseline Attempt starts from the exact previously committed
132
- // Task head, so its ChangeSets are already represented by that tree.
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
- else if (current.resolution?.action === "manual-resolution") {
148
- const resolvedCommit = await completeManualResolution(prepared.path);
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
- const conflict = await this.#applyCommits(current, workspace, prepared.path, remaining);
156
- if (conflict !== undefined) {
157
- return conflict;
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
- const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path);
160
- if (checkResults.some((check) => check.outcome === "failed")) {
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: "failed",
216
+ status: "validating",
217
+ candidateCommit,
163
218
  checks: checkResults
164
219
  }, this.now());
165
220
  this.store.saveIntegrationAttempt(task.id, current);
166
- return this.#terminalResult("failed", current, workspace);
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
- catch (error) {
181
- if (error instanceof RemoteBaselineConflictError) {
182
- const pending = requireLeaderDecision(current, {
183
- affectedPaths: error.affectedPaths,
184
- summary: error.message
185
- }, this.now());
186
- this.store.saveIntegrationAttempt(task.id, pending);
187
- return { status: "blocked", attempt: pending, workspace };
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
- if (current.status === "validating" && current.candidateCommit !== undefined) {
190
- const target = await resolveRef(project.path, current.targetRef);
191
- if (target === current.candidateCommit) {
192
- const committed = updateIntegrationAttempt(current, { status: "committed" }, this.now());
193
- this.store.saveIntegrationAttempt(task.id, committed);
194
- return this.#terminalResult("committed", committed, workspace);
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 this.#fail(current, error, "integration", workspace);
289
+ return result;
290
+ }
291
+ finally {
292
+ release();
198
293
  }
199
294
  }
200
- async cleanup(integration) {
201
- const task = this.store.getTask(integration.taskId);
202
- if (task === null || !task.projectBindings.some(({ projectId }) => projectId === integration.projectId)) {
203
- throw new Error(`Integration Task Project is unavailable: ${integration.id}.`);
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 project = this.store.getProject(integration.projectId);
206
- if (project === null)
207
- throw new Error(`Project not found: ${integration.projectId}.`);
208
- const managedWorkspace = this.store.getIntegrationWorkspace(integration.taskId, integration.id);
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(integration, managedWorkspace);
211
- this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
370
+ const runtime = this.#runtimePreparation(attempt, managedWorkspace);
371
+ this.runtimeIsolation.cleanup(runtime, checks.some((check) => check.outcome === "failed") ? "failure" : "completion");
212
372
  }
213
- const result = await this.git.removeIntegrationWorktree({
214
- repositoryPath: project.path,
215
- container: join(this.worktreeRoot, project.name),
216
- taskSegment: taskWorkspaceRefSegment(task),
217
- integrationId: integration.id,
218
- discardChanges: integration.status === "failed"
219
- });
220
- if (result !== "dirty") {
221
- await rm(integrationCheckDirectory(this.home, task.id, integration.id), {
222
- recursive: true,
223
- force: true
224
- });
225
- this.store.removeManagedWorkspace({
226
- type: "integration-attempt",
227
- taskId: integration.taskId,
228
- integrationAttemptId: integration.id
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
- return result;
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);