@zq-silk/yui 0.15.9 → 0.15.12

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 (160) hide show
  1. package/ARCHITECTURE.md +8 -4
  2. package/ARCHITECTURE.zh-CN.md +5 -2
  3. package/README.md +13 -5
  4. package/dist/agent/launchEnvironment.js +7 -0
  5. package/dist/agentRun/agentRun.js +3 -0
  6. package/dist/cli/commandCatalog.js +64 -16
  7. package/dist/cli/interactionPolicy.js +7 -3
  8. package/dist/cli/managedDiagnostics.js +1 -1
  9. package/dist/cli/updateOrchestrator.js +24 -1
  10. package/dist/cli/updatePorts.js +7 -3
  11. package/dist/cli/upgradeCommand.js +42 -2
  12. package/dist/cli.js +381 -107
  13. package/dist/commands/executionAuditCommands.js +10 -0
  14. package/dist/commands/globalRoleCommands.js +339 -4
  15. package/dist/commands/projectCommands.js +50 -22
  16. package/dist/commands/releaseCommands.js +18 -0
  17. package/dist/commands/taskActor.js +25 -0
  18. package/dist/commands/taskCommands.js +586 -96
  19. package/dist/commands/taskIntegrationCommands.js +19 -39
  20. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  21. package/dist/commands/taskOverviewCommand.js +4 -3
  22. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  23. package/dist/commands/taskPublicationCommands.js +11 -2
  24. package/dist/commands/taskPublicationVerifyCommand.js +23 -39
  25. package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
  26. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  27. package/dist/context/runContextPack.js +3 -0
  28. package/dist/context/taskCatalog.js +187 -0
  29. package/dist/context/taskContext.js +55 -6
  30. package/dist/controller/agentHostObservation.js +155 -0
  31. package/dist/controller/clientRuntime.js +17 -2
  32. package/dist/controller/controller.js +14 -2
  33. package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
  34. package/dist/controller/globalInputDelivery.js +132 -0
  35. package/dist/controller/jobControl.js +6 -2
  36. package/dist/controller/providerRetryAdmission.js +100 -0
  37. package/dist/controller/providerRetryDelivery.js +218 -0
  38. package/dist/controller/resourceInventory.js +14 -4
  39. package/dist/controller/resourceInventoryLinux.js +2 -6
  40. package/dist/controller/runtime.js +117 -7
  41. package/dist/controller/runtimeEventInbox.js +32 -3
  42. package/dist/controller/runtimeEventProcessor.js +26 -6
  43. package/dist/controller/runtimeHookRunFence.js +75 -19
  44. package/dist/controller/structuredProviderObservation.js +133 -70
  45. package/dist/coordination/workMailboxQueue.js +5 -0
  46. package/dist/execution/workItemExecutionProjection.js +1 -1
  47. package/dist/executor/agentExecutor.js +64 -4
  48. package/dist/executor/executorRegistry.js +3 -0
  49. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  50. package/dist/integration/deliveryObligation.js +2 -1
  51. package/dist/integration/gitIntegrationService.js +329 -386
  52. package/dist/integration/integrationAttempt.js +30 -4
  53. package/dist/integration/integrationQueueService.js +7 -7
  54. package/dist/integration/integrationSourceApplication.js +323 -0
  55. package/dist/lifecycle/exactRunTerminalization.js +4 -1
  56. package/dist/message/globalInterrupt.js +33 -0
  57. package/dist/message/globalProviderRetry.js +15 -0
  58. package/dist/message/inputControlResolution.js +106 -0
  59. package/dist/message/message.js +367 -0
  60. package/dist/message/messageContinuation.js +126 -3
  61. package/dist/message/taskInterrupt.js +34 -0
  62. package/dist/observability/executionAudit.js +19 -0
  63. package/dist/observability/orchestrationMetrics.js +1 -1
  64. package/dist/release/releaseHandover.js +22 -0
  65. package/dist/release/releaseWorkflowPorts.js +15 -7
  66. package/dist/repository/gitWorkspace.js +430 -107
  67. package/dist/repository/projectMaintenanceLock.js +75 -18
  68. package/dist/repository/taskWorkspaceCoordinator.js +182 -101
  69. package/dist/repository/taskWorkspacePreparer.js +205 -72
  70. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  71. package/dist/repository/workspaceCleanupInspection.js +187 -0
  72. package/dist/resources/resourceDiscovery.js +3 -2
  73. package/dist/runtime/agentError.js +5 -3
  74. package/dist/runtime/agentHost.js +179 -82
  75. package/dist/runtime/agentHostCompatibility.js +127 -0
  76. package/dist/runtime/agentHostProtocol.js +53 -0
  77. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  78. package/dist/runtime/codexAppServerRuntime.js +34 -3
  79. package/dist/runtime/executionEnvironment.js +0 -19
  80. package/dist/runtime/launchBroker.js +6 -0
  81. package/dist/runtime/providerControl.js +5 -1
  82. package/dist/runtime/providerRetry.js +198 -0
  83. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  84. package/dist/runtime/sessionReconciliation.js +4 -4
  85. package/dist/runtime/sessionTokenMetrics.js +15 -5
  86. package/dist/runtime/structuredProviderHost.js +6 -2
  87. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  88. package/dist/runtime/taskUsageMetrics.js +275 -0
  89. package/dist/runtime/tmuxAdapters.js +5 -3
  90. package/dist/scheduler/activeRoleRunDelivery.js +12 -0
  91. package/dist/scheduler/leaderWakeupProcessor.js +5 -0
  92. package/dist/scheduler/operatorEvent.js +4 -0
  93. package/dist/scheduler/taskExecutionProjection.js +38 -6
  94. package/dist/scheduler/taskObservabilityProjection.js +6 -44
  95. package/dist/scheduler/wakeReason.js +7 -1
  96. package/dist/scheduler/wakeupQueue.js +2 -0
  97. package/dist/setup/setupCommand.js +26 -8
  98. package/dist/storage/homeLayout.js +130 -0
  99. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  100. package/dist/storage/migrations/integrationContinuation.js +104 -0
  101. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  102. package/dist/storage/sqliteSchema.js +167 -4
  103. package/dist/storage/sqliteStore.js +57 -1
  104. package/dist/storage/storageVersions.js +1 -1
  105. package/dist/storage/storeRpc.js +2 -0
  106. package/dist/storage/taskCatalog.js +123 -0
  107. package/dist/storage/taskStore.js +2 -0
  108. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  109. package/dist/task/archiveDiagnostics.js +129 -0
  110. package/dist/task/archivePreflight.js +124 -0
  111. package/dist/task/nextAction.js +44 -11
  112. package/dist/task/publicationAdoption.js +56 -0
  113. package/dist/task/publicationReference.js +10 -0
  114. package/dist/task/remoteDelivery.js +31 -16
  115. package/dist/web/assets/client/app.js +147 -17
  116. package/dist/web/assets/client/components.js +56 -13
  117. package/dist/web/assets/client/i18n.js +78 -4
  118. package/dist/web/assets/client/taskSurface.js +108 -1
  119. package/dist/web/assets/client/view.js +39 -8
  120. package/dist/web/assets/shell.js +29 -0
  121. package/dist/web/assets/styles/layout.js +8 -1
  122. package/dist/web/assets/styles/widgets.js +12 -0
  123. package/dist/web/webServer.js +131 -4
  124. package/dist/web/webSnapshot.js +16 -6
  125. package/dist/web/webTaskSurface.js +222 -5
  126. package/dist/workspace/cleanupInspection.js +63 -0
  127. package/dist/workspace/workItemChangeSetManager.js +111 -35
  128. package/docs/agent-result-consumption.md +4 -0
  129. package/docs/agent-result-consumption.zh-CN.md +3 -0
  130. package/docs/agent-runtime-drivers.md +7 -0
  131. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  132. package/docs/architecture/README.md +2 -0
  133. package/docs/architecture/README.zh-CN.md +3 -1
  134. package/docs/architecture/capabilities-and-resources.md +30 -5
  135. package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
  136. package/docs/managed-turn-and-session-runtime.md +47 -0
  137. package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
  138. package/docs/observability/README.md +62 -0
  139. package/docs/observability/README.zh-CN.md +47 -0
  140. package/docs/project-refresh.md +77 -0
  141. package/docs/project-refresh.zh-CN.md +59 -0
  142. package/docs/provider-retry.md +70 -0
  143. package/docs/release-workflow.md +39 -0
  144. package/docs/release-workflow.zh-CN.md +29 -0
  145. package/docs/sqlite-control-plane-design.md +223 -1
  146. package/docs/task-delivery.md +133 -13
  147. package/docs/task-delivery.zh-CN.md +99 -10
  148. package/docs/task-discovery.md +102 -0
  149. package/docs/task-discovery.zh-CN.md +86 -0
  150. package/docs/testing/verification-levels.md +40 -0
  151. package/docs/testing/verification-levels.zh-CN.md +23 -0
  152. package/i18n/README.zh-CN.md +13 -7
  153. package/package.json +1 -1
  154. package/skills/yui-leader/references/execution.md +154 -51
  155. package/skills/yui-leader/references/integration.md +52 -2
  156. package/skills/yui-operator/SKILL.md +19 -3
  157. package/skills/yui-reviewer/SKILL.md +4 -0
  158. package/skills/yui-runtime/SKILL.md +42 -0
  159. package/skills/yui-runtime/references/publication.md +42 -0
  160. package/skills/yui-runtime/references/recovery.md +24 -0
@@ -6,18 +6,21 @@ import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { selectEnvironment } from "../agent/launchEnvironment.js";
8
8
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
9
+ import { durableJobIdempotencyKey } from "../job/durableJob.js";
10
+ import { applyIntegrationSource, assertIntegrationCandidate, assertRecordedSourceCandidate } from "./integrationSourceApplication.js";
9
11
  import { planBootstrapJobSteps, planL2JobSteps } from "../verification/verificationPlan.js";
10
12
  import { assertNoAdHocFullSuiteChecks, checkResultsFromGateArtifact, checkResultsFromGateJob, gateIdentityForCandidate, lookupReusableGateArtifact, recordGateArtifactFromJob, recordGateArtifactFromStepOutcomes, resolveVerificationGate, runGateStepsInProcess } from "../verification/verificationGateService.js";
11
13
  import { recordGateArtifactPotentialReuse, recordGateArtifactReuse } from "../verification/gateArtifact.js";
12
14
  import { touchGateArtifact } from "../verification/gateArtifactStore.js";
13
- import { NodeGitWorkspace, RemoteBaselineConflictError } from "../repository/gitWorkspace.js";
14
- import { resolveWorktreeRoot } from "../repository/taskWorkspacePreparer.js";
15
+ import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
16
+ import { integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
15
17
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
16
18
  import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
17
19
  import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
20
+ import { managedIntegrationRuntimeRoot } from "../storage/homeLayout.js";
18
21
  import { advanceTaskProjectCommit } from "../task/task.js";
19
22
  import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
20
- import { recordIntegrationCheckJob, requireResolutionDecision, updateIntegrationAttempt } from "./integrationAttempt.js";
23
+ import { recordIntegrationCheckJob, updateIntegrationAttempt } from "./integrationAttempt.js";
21
24
  import { createManagedWorkspace } from "../worktree/managedWorkspace.js";
22
25
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
23
26
  import { readRuntimeIdentity } from "../release/runtimeRelease.js";
@@ -52,16 +55,12 @@ const INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES = [
52
55
  "CURL_CA_BUNDLE",
53
56
  "REQUESTS_CA_BUNDLE"
54
57
  ];
55
- export const REMOTE_BASELINE_CONFLICT_PREFIX = "Upstream rebase conflicts";
56
- const WORK_ITEM_MERGE_CONFLICT_PREFIX = "WorkItem merge conflicts";
57
- const MANUAL_INTEGRATION_PREFIX = "Manual WorkItem integration";
58
58
  export class GitIntegrationService {
59
59
  store;
60
60
  git;
61
61
  now;
62
62
  jobPort;
63
63
  home;
64
- worktreeRoot;
65
64
  environment;
66
65
  runtimeIsolation;
67
66
  #resourceRegistrarValue;
@@ -71,50 +70,64 @@ export class GitIntegrationService {
71
70
  this.now = now;
72
71
  this.jobPort = jobPort;
73
72
  this.home = resolve(home);
74
- this.worktreeRoot = resolveWorktreeRoot(home, store.getConfig().defaultWorkspace);
75
73
  this.environment = { ...environment };
76
74
  this.runtimeIsolation = runtimeIsolation;
77
75
  }
78
76
  #resourceRegistrar() {
79
77
  return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
80
78
  }
81
- async integrate(taskId, integrationId) {
82
- const initial = requireIntegration(this.store, taskId, integrationId);
79
+ async integrate(taskId, integrationId, signal) {
80
+ let initial = requireIntegration(this.store, taskId, integrationId);
81
+ if (!["running", "conflicted", "blocked", "validating"].includes(initial.status)) {
82
+ throw new Error(`Integration cannot continue from ${initial.status}.`);
83
+ }
83
84
  // The whole Integration is one Git transaction against the Project's
84
85
  // repository: worktree creation, cherry-picks, checks, and the target ref
85
86
  // CAS. A concurrent `project migrate` must not switch the catalog path or
86
87
  // remove the old checkout mid-Integration, so the per-Project maintenance
87
88
  // fence is held across every Git effect and released only on exit.
88
- const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
89
+ const release = await acquireProjectMaintenanceLocks(this.home, [initial.projectId], { signal });
89
90
  try {
91
+ // A concurrent abort may have settled the attempt while this caller was
92
+ // acquiring the same Project fence. Never execute a pre-fence snapshot.
93
+ const currentUnderFence = requireIntegration(this.store, taskId, integrationId);
94
+ if (currentUnderFence.projectId !== initial.projectId
95
+ || !["running", "conflicted", "blocked", "validating"].includes(currentUnderFence.status)) {
96
+ throw new Error("Integration changed before execution admission; read its current record.");
97
+ }
98
+ initial = currentUnderFence;
90
99
  const task = this.store.getTask(initial.taskId);
91
100
  if (task === null || !task.projectBindings.some(({ projectId }) => projectId === initial.projectId)) {
92
101
  throw new Error(`Integration Task Project is unavailable: ${initial.taskId}.`);
93
102
  }
94
- if (task.status !== "active") {
103
+ if (task.status !== "active" || task.executionGate.state !== "enabled") {
95
104
  throw new Error(`Integration Task is not active: ${task.id}/${task.status}.`);
96
105
  }
97
106
  const project = this.store.getProject(initial.projectId);
98
107
  if (project === null)
99
108
  throw new Error(`Project not found: ${initial.projectId}.`);
109
+ if (project.status !== "active")
110
+ throw new Error(`Integration Project is not active: ${project.id}.`);
100
111
  const taskWorkspace = this.store.getTaskWorkspace(task.id);
101
- const taskRepository = taskWorkspace?.entries.find(({ projectId }) => projectId === project.id)?.path;
112
+ const taskEntry = taskWorkspace?.entries.find(({ projectId }) => projectId === project.id);
113
+ const taskRepository = taskEntry?.path;
102
114
  if (taskWorkspace === null
103
115
  || taskWorkspace.owner.type !== "task"
116
+ || taskEntry === undefined
104
117
  || taskRepository === undefined) {
105
118
  throw new Error(`Integration Task clone is unavailable: ${task.id}/${project.id}.`);
106
119
  }
107
120
  // Issue 08: a Project with a VerificationPlan gates through its plan
108
121
  // (bootstrap + L2) and reuses exact-SHA artifacts; an unconfigured
109
122
  // Project keeps the existing explicit check path unchanged.
110
- const gate = resolveVerificationGate(project, this.environment);
111
123
  let prepared;
112
124
  let workspace;
113
125
  let managedWorkspace;
114
126
  try {
115
127
  prepared = await this.git.ensureIntegrationWorktree({
116
128
  repositoryPath: taskRepository,
117
- container: join(this.worktreeRoot, project.name),
129
+ container: integrationWorkspaceRoot(this.home, task.id, initial.id),
130
+ directory: taskEntry.directory,
118
131
  taskSegment: taskWorkspaceRefSegment(task),
119
132
  integrationId: initial.id,
120
133
  baseRef: initial.beforeCommit
@@ -126,6 +139,12 @@ export class GitIntegrationService {
126
139
  baseCommit: prepared.baseCommit
127
140
  };
128
141
  const existingWorkspace = this.store.getIntegrationWorkspace(task.id, initial.id);
142
+ if (existingWorkspace !== null && (resolve(existingWorkspace.root) !== resolve(prepared.path)
143
+ || existingWorkspace.entries.length !== 1
144
+ || existingWorkspace.entries[0]?.projectId !== project.id
145
+ || existingWorkspace.entries[0]?.access !== "write"
146
+ || existingWorkspace.entries[0]?.branch !== prepared.branch))
147
+ throw new Error("Integration managed workspace identity changed.");
129
148
  managedWorkspace = existingWorkspace ?? createManagedWorkspace({
130
149
  owner: {
131
150
  type: "integration-attempt",
@@ -135,7 +154,7 @@ export class GitIntegrationService {
135
154
  root: prepared.path,
136
155
  entries: [{
137
156
  projectId: project.id,
138
- directory: project.name,
157
+ directory: taskEntry.directory,
139
158
  access: "write",
140
159
  path: prepared.path,
141
160
  branch: prepared.branch,
@@ -150,19 +169,26 @@ export class GitIntegrationService {
150
169
  return this.#fail(initial, error, "integration-preparation");
151
170
  }
152
171
  if (initial.status === "validating") {
153
- return this.#recoverValidating(initial, workspace, taskRepository);
172
+ return await this.#recoverValidating(initial, workspace, managedWorkspace, taskRepository);
154
173
  }
174
+ const gate = resolveVerificationGate(project, this.environment);
155
175
  let current = initial;
156
176
  // A check DurableJob is the source of truth for a running attempt that
157
177
  // already bound one: never re-apply commits or spawn a second job. The
158
178
  // job's terminal wakeup drives the resume through `integration continue`.
159
- if (current.status === "running" && current.jobId !== undefined) {
160
- return this.#resumeCheckJob(current, workspace, prepared.path, taskRepository, gate);
161
- }
162
179
  try {
163
- const conflict = await this.#applySource(current, workspace, prepared.path, project.remoteUrl);
164
- if (conflict !== undefined) {
165
- return conflict;
180
+ current = await applyIntegrationSource({
181
+ attempt: current, workspace, remoteUrl: project.remoteUrl,
182
+ git: this.git, store: this.store, now: this.now
183
+ });
184
+ if (current.status === "conflicted" || current.status === "blocked") {
185
+ return { status: current.status, attempt: current, workspace };
186
+ }
187
+ await assertIntegrationCandidate(prepared.path, current.candidateCommit, workspace.branch);
188
+ if (current.jobId !== undefined || current.checkInputDigest !== undefined) {
189
+ if (this.jobPort === undefined)
190
+ throw new Error("Integration requires its Controller Job port to resume.");
191
+ return await this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
166
192
  }
167
193
  // Static preflight: fail before any expensive check when the target
168
194
  // moved or its worktree is dirty, so the check commands never run on a
@@ -170,17 +196,17 @@ export class GitIntegrationService {
170
196
  // after the checks, so a move during the gate is still fenced at CAS.
171
197
  await assertTargetReadyForChecks(taskRepository, current.targetRef, current.beforeCommit);
172
198
  if (gate !== undefined) {
173
- return this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
199
+ return await this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
174
200
  }
175
201
  if (this.jobPort !== undefined && current.checkCommands.length > 0) {
176
- return this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository);
202
+ return await this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository);
177
203
  }
204
+ const spec = await this.#checkSpecification(current, managedWorkspace, gate);
205
+ current = updateIntegrationAttempt(current, { checkInputDigest: spec.inputDigest }, this.now());
206
+ this.store.saveIntegrationAttempt(task.id, current);
178
207
  const checkedHead = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
179
- const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path);
180
- const afterHead = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
181
- if (afterHead !== checkedHead) {
182
- return this.#fail(current, new Error(`Integration workspace moved during the checks: ${afterHead} != checked ${checkedHead}.`), "integration", workspace);
183
- }
208
+ const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path, spec.environment);
209
+ await assertIntegrationCandidate(prepared.path, checkedHead, workspace.branch);
184
210
  if (checkResults.some((check) => check.outcome === "failed")) {
185
211
  current = updateIntegrationAttempt(current, {
186
212
  status: "failed",
@@ -201,18 +227,14 @@ export class GitIntegrationService {
201
227
  return this.#terminalResult("committed", committed, workspace);
202
228
  }
203
229
  catch (error) {
204
- if (error instanceof RemoteBaselineConflictError) {
205
- const pending = requireResolutionDecision(current, {
206
- affectedPaths: error.affectedPaths,
207
- summary: error.message
208
- }, this.now());
209
- this.store.saveIntegrationAttempt(task.id, pending);
210
- return { status: "blocked", attempt: pending, workspace };
211
- }
230
+ // Git/Job effects may already exist even if the following save failed.
231
+ // Keep the last durable cursor/candidate for exact Agent-directed retry.
232
+ current = requireIntegration(this.store, taskId, integrationId);
212
233
  if (current.status === "validating" && current.candidateCommit !== undefined) {
213
234
  const target = await resolveRef(taskRepository, current.targetRef);
214
235
  if (target === current.candidateCommit) {
215
236
  try {
237
+ await assertIntegrationCandidate(workspace.path, current.candidateCommit, workspace.branch);
216
238
  await assertTargetReadyForChecks(taskRepository, current.targetRef, current.candidateCommit);
217
239
  const committed = this.#recordCommitted(current);
218
240
  return this.#terminalResult("committed", committed, workspace);
@@ -224,19 +246,117 @@ export class GitIntegrationService {
224
246
  }
225
247
  }
226
248
  }
227
- return this.#fail(current, error, "integration", workspace);
249
+ const diagnosed = updateIntegrationAttempt(current, {
250
+ summary: error instanceof Error ? error.message : String(error)
251
+ }, this.now());
252
+ this.store.saveIntegrationAttempt(taskId, diagnosed);
253
+ return { status: diagnosed.status === "conflicted" ? "conflicted" : "blocked", attempt: diagnosed, workspace };
254
+ }
255
+ }
256
+ finally {
257
+ release();
258
+ }
259
+ }
260
+ /** Abandon under the same fence as Git/CAS, not just a status whitelist.
261
+ * A delivered validating candidate is recorded as committed; a provably
262
+ * unadvanced one may be abandoned without requiring obsolete check settings. */
263
+ async abort(taskId, integrationId, reason, authorize, signal) {
264
+ const initial = requireIntegration(this.store, taskId, integrationId);
265
+ const release = await acquireProjectMaintenanceLocks(this.home, [initial.projectId], { signal });
266
+ try {
267
+ const attempt = requireIntegration(this.store, taskId, integrationId);
268
+ authorize(this.store, taskId);
269
+ const task = this.store.getTask(taskId);
270
+ if (task?.status !== "active" || attempt.projectId !== initial.projectId
271
+ || !task.projectBindings.some(binding => binding.projectId === attempt.projectId)) {
272
+ throw new Error("Integration Task/Project is not available for abort.");
273
+ }
274
+ if (!["running", "blocked", "conflicted", "validating"].includes(attempt.status)) {
275
+ throw new Error(`Integration cannot be aborted from ${attempt.status}: ${attempt.id}.`);
276
+ }
277
+ if (reason.trim() === "")
278
+ throw new Error("Integration abort reason is required.");
279
+ const jobs = this.store.listDurableJobs(taskId).filter(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === integrationId);
280
+ const workspace = this.store.getIntegrationWorkspace(taskId, integrationId);
281
+ if (jobs.some(job => job.taskId !== taskId || job.projectId !== attempt.projectId
282
+ || job.workspace !== workspace?.root)) {
283
+ throw new Error("Integration Job Project/workspace identity changed; inspect before abort.");
284
+ }
285
+ if (attempt.jobId !== undefined && !jobs.some(job => job.id === attempt.jobId)) {
286
+ throw new Error("Integration Job binding does not match its owner; inspect before abort.");
287
+ }
288
+ if (attempt.status === "validating") {
289
+ if (attempt.candidateCommit === undefined || attempt.checks === undefined) {
290
+ throw new Error("Validating Integration has no candidate/check evidence; inspect before settlement.");
291
+ }
292
+ const main = this.store.getTaskWorkspace(taskId);
293
+ const mainEntry = main?.entries.find(entry => entry.projectId === attempt.projectId && entry.access === "write");
294
+ const workspaceEntry = workspace?.entries.find(entry => entry.projectId === attempt.projectId && entry.access === "write");
295
+ const project = this.store.getProject(attempt.projectId);
296
+ if (main?.owner.type !== "task" || mainEntry === undefined || workspace === null
297
+ || project === null || project.status !== "active"
298
+ || workspace.owner.type !== "integration-attempt" || workspace.owner.taskId !== taskId
299
+ || workspace.owner.integrationAttemptId !== integrationId
300
+ || workspaceEntry?.path !== workspace.root) {
301
+ throw new Error("Integration target/workspace ownership is unavailable for settlement.");
302
+ }
303
+ if (jobs.some(job => ["queued", "running", "unknown-needs-attention"].includes(job.status))) {
304
+ throw new Error("Validating Integration still has an active or unproven Job; settle that exact Job first.");
305
+ }
306
+ if (attempt.jobId !== undefined) {
307
+ assertCheckJobIdentity(attempt, jobs.find(job => job.id === attempt.jobId), workspace.root);
308
+ }
309
+ const target = await resolveRef(mainEntry.path, attempt.targetRef);
310
+ if (target === attempt.candidateCommit && target !== attempt.beforeCommit) {
311
+ await assertTargetReadyForChecks(mainEntry.path, attempt.targetRef, target);
312
+ return this.#recordCommitted(attempt, authorize);
313
+ }
314
+ if (target !== attempt.beforeCommit) {
315
+ throw new Error(`Target moved to ${target}; cannot prove this Integration is unadvanced. Inspect before settlement.`);
316
+ }
317
+ }
318
+ for (const job of jobs) {
319
+ if (this.jobPort !== undefined && (job.status === "queued" || job.status === "running")) {
320
+ await this.jobPort.cancelJob(taskId, job.id);
321
+ }
228
322
  }
323
+ return this.store.transaction(tx => {
324
+ authorize(tx, taskId);
325
+ const current = requireIntegration(tx, taskId, integrationId);
326
+ if (JSON.stringify(current) !== JSON.stringify(attempt)) {
327
+ throw new Error("Integration changed during abort; inspect its current record.");
328
+ }
329
+ const aborted = updateIntegrationAttempt(current, {
330
+ status: "failed",
331
+ checks: [...(current.checks ?? []), { name: "aborted", outcome: "failed", details: reason }]
332
+ }, this.now());
333
+ tx.saveIntegrationAttempt(taskId, aborted);
334
+ return aborted;
335
+ });
229
336
  }
230
337
  finally {
231
338
  release();
232
339
  }
233
340
  }
234
- async cleanup(integration) {
341
+ async cleanup(integration, options = {}) {
235
342
  // The whole cleanup is one Git transaction against the Project's
236
343
  // repository, mirroring integrate(): a concurrent `project migrate` must
237
344
  // not switch the catalog path or remove the old checkout mid-cleanup.
238
- const release = acquireProjectMaintenanceLocks(this.home, [integration.projectId]);
345
+ const release = await acquireProjectMaintenanceLocks(this.home, [integration.projectId], { signal: options.signal });
239
346
  try {
347
+ options.authorize?.();
348
+ const current = requireIntegration(this.store, integration.taskId, integration.id);
349
+ if (current.projectId !== integration.projectId
350
+ || !["committed", "superseded", "failed"].includes(current.status)) {
351
+ throw new Error("Integration changed before cleanup; read its current record.");
352
+ }
353
+ integration = current;
354
+ const activeJob = this.store.listDurableJobs(integration.taskId).find(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === integration.id
355
+ && (job.status === "queued" || job.status === "running"
356
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
357
+ if (activeJob !== undefined) {
358
+ throw new Error(`Integration ${integration.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
359
+ }
240
360
  const task = this.store.getTask(integration.taskId);
241
361
  if (task === null || !task.projectBindings.some(({ projectId }) => projectId === integration.projectId)) {
242
362
  throw new Error(`Integration Task Project is unavailable: ${integration.id}.`);
@@ -253,9 +373,18 @@ export class GitIntegrationService {
253
373
  const runtime = this.#runtimePreparation(integration, managedWorkspace);
254
374
  this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
255
375
  }
376
+ const recorded = managedWorkspace?.entries[0];
377
+ const directory = recorded?.directory
378
+ ?? task.projectBindings.find(({ projectId }) => projectId === integration.projectId)?.directory;
379
+ if (directory === undefined) {
380
+ throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
381
+ }
256
382
  const result = await this.git.removeIntegrationWorktree({
257
383
  repositoryPath: taskRepository,
258
- container: join(this.worktreeRoot, project.name),
384
+ container: recorded !== undefined
385
+ ? dirname(recorded.path)
386
+ : integrationWorkspaceRoot(this.home, task.id, integration.id),
387
+ directory,
259
388
  taskSegment: taskWorkspaceRefSegment(task),
260
389
  integrationId: integration.id,
261
390
  discardChanges: integration.status === "failed"
@@ -287,26 +416,15 @@ export class GitIntegrationService {
287
416
  * it, so a Leader exit mid-checks leaves no running/no-check zombie.
288
417
  */
289
418
  async #startCheckJob(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
290
- const runtime = this.#runtimePreparation(attempt, managedWorkspace);
291
- const head = await gitLine(["-C", path, "rev-parse", "HEAD^{commit}"]);
292
- const steps = gate === undefined
293
- ? attempt.checkCommands.map((command, index) => ({
294
- name: `check-${index + 1}`,
295
- command,
296
- timeoutMs: 30 * 60_000
297
- }))
298
- : [
299
- ...planBootstrapJobSteps(gate.plan).map((step) => ({
300
- ...step,
301
- timeoutMs: 30 * 60_000
302
- })),
303
- ...planL2JobSteps(gate.plan).map((step) => ({
304
- ...step,
305
- timeoutMs: 30 * 60_000
306
- }))
307
- ];
308
- const releaseId = integrationRuntimeReleaseIdentity(this.home);
309
- if (gate === undefined && releaseId !== null) {
419
+ if (attempt.status !== "running" || attempt.candidateCommit === undefined) {
420
+ throw new Error(`Integration check admission requires a running, proven candidate: ${attempt.id}.`);
421
+ }
422
+ await assertIntegrationCandidate(path, attempt.candidateCommit, workspace.branch);
423
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
424
+ const { runtime, head, steps, environment, inputDigest, releaseId } = await this.#checkSpecification(attempt, managedWorkspace, gate);
425
+ const ownedJobs = this.store.listDurableJobs(attempt.taskId).filter(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === attempt.id);
426
+ if (gate === undefined && releaseId !== null && attempt.checkInputDigest === undefined
427
+ && ownedJobs.length === 0 && attempt.jobId === undefined) {
310
428
  const reusable = findReusableIntegrationCheckEvidence({
311
429
  taskId: attempt.taskId,
312
430
  projectId: attempt.projectId,
@@ -320,25 +438,28 @@ export class GitIntegrationService {
320
438
  logPathFor: (job, relativeLogPath) => (join(job.artifactsLocator, "logs", relativeLogPath))
321
439
  });
322
440
  if (reusable !== null) {
323
- return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, head, [...reusable.checks]);
441
+ return this.#finalizeGateSuccess(updateIntegrationAttempt(attempt, { checkInputDigest: inputDigest }, this.now()), workspace, repositoryPath, head, [...reusable.checks]);
324
442
  }
325
443
  }
326
- this.runtimeIsolation.activate(runtime);
327
- const baseEnvironment = await integrationCheckEnvironment(this.environment, runtime);
328
- const environment = Object.freeze({
329
- ...baseEnvironment,
330
- ...(releaseId === null ? {} : { [INTEGRATION_RUNTIME_RELEASE_ENV]: releaseId })
331
- });
444
+ if (attempt.checkInputDigest !== undefined && attempt.checkInputDigest !== inputDigest) {
445
+ throw new Error("Integration check conditions changed since admission; inspect the original Job, then abort or start a new attempt.");
446
+ }
447
+ if (attempt.jobId !== undefined) {
448
+ return this.#resumeCheckJob(attempt, workspace, path, repositoryPath, gate);
449
+ }
332
450
  // Persist the gate identity before starting the job so a plan edit
333
451
  // during the gate never misattributes the evidence on resume.
334
- let persisted = attempt;
335
- if (gate !== undefined) {
336
- persisted = updateIntegrationAttempt(attempt, {
452
+ const persisted = updateIntegrationAttempt(attempt, {
453
+ checkInputDigest: inputDigest,
454
+ ...(gate === undefined ? {} : {
337
455
  gatePlanDigest: gate.planDigest,
338
456
  gateToolchainDigest: gate.toolchainDigest
339
- }, this.now());
340
- this.store.saveIntegrationAttempt(attempt.taskId, persisted);
341
- }
457
+ })
458
+ }, this.now());
459
+ this.store.saveIntegrationAttempt(attempt.taskId, persisted);
460
+ // All domain admission and durable candidate identity precede Job effects.
461
+ this.runtimeIsolation.activate(runtime);
462
+ await prepareIntegrationRuntimeHome(runtime, this.home);
342
463
  const job = await this.jobPort.startCheckJob({
343
464
  taskId: attempt.taskId,
344
465
  integrationId: attempt.id,
@@ -348,6 +469,7 @@ export class GitIntegrationService {
348
469
  env: environment,
349
470
  steps
350
471
  });
472
+ assertCheckJobIdentity(persisted, job, path);
351
473
  const bound = recordIntegrationCheckJob(persisted, job.id, this.now());
352
474
  this.store.saveIntegrationAttempt(attempt.taskId, bound);
353
475
  if (job.status !== "queued" && job.status !== "running") {
@@ -367,12 +489,17 @@ export class GitIntegrationService {
367
489
  */
368
490
  async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
369
491
  const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
492
+ assertCheckJobIdentity(attempt, job, path);
493
+ await assertIntegrationCandidate(path, job.head, workspace.branch);
494
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
495
+ if (gate?.planDigest !== attempt.gatePlanDigest
496
+ || gate?.toolchainDigest !== attempt.gateToolchainDigest) {
497
+ throw new Error("Integration verification plan/toolchain changed; the original Job cannot prove the current gate.");
498
+ }
370
499
  if (job.status === "queued" || job.status === "running") {
371
500
  return { status: "checks-running", attempt, workspace, job };
372
501
  }
373
- const planStyle = gate !== undefined
374
- || (job.steps?.some((step) => step.name.startsWith("bootstrap-") || step.name.startsWith("gate-")) ?? false);
375
- const checks = planStyle
502
+ const checks = gate !== undefined
376
503
  ? checkResultsFromGateJob(job, this.home)
377
504
  : checkResultsFromJob(attempt, job);
378
505
  const managedWorkspace = this.store.getIntegrationWorkspace(attempt.taskId, attempt.id);
@@ -380,25 +507,13 @@ export class GitIntegrationService {
380
507
  const runtime = this.#runtimePreparation(attempt, managedWorkspace);
381
508
  this.runtimeIsolation.cleanup(runtime, checks.some((check) => check.outcome === "failed") ? "failure" : "completion");
382
509
  }
383
- // Issue 08: record the GateArtifact for a plan-gated attempt. The
384
- // identity is recomputed from the current plan and the job's exact
385
- // checked head; a plan/toolchain change since the job started yields a
386
- // different key, so the artifact is never misattributed (the attempt
387
- // still converges from the job's own evidence).
510
+ // The current plan/toolchain was matched to admission above. Record only
511
+ // that exact identity, never reinterpret an old Job under a changed plan.
388
512
  if (gate !== undefined
389
513
  && (job.result?.outcome === "succeeded" || job.result?.outcome === "failed")) {
390
- // Use the digests captured at job start so a plan edit during the
391
- // gate never misattributes the evidence.
392
- const recordGate = attempt.gatePlanDigest !== undefined
393
- ? Object.freeze({
394
- ...gate,
395
- planDigest: attempt.gatePlanDigest,
396
- toolchainDigest: attempt.gateToolchainDigest ?? gate.toolchainDigest
397
- })
398
- : gate;
399
514
  const identity = gateIdentityForCandidate({
400
515
  projectId: attempt.projectId,
401
- gate: recordGate,
516
+ gate,
402
517
  level: "L2",
403
518
  commit: job.head,
404
519
  targetRef: attempt.targetRef,
@@ -424,30 +539,16 @@ export class GitIntegrationService {
424
539
  this.store.saveIntegrationAttempt(attempt.taskId, failed);
425
540
  return this.#terminalResult("failed", failed, workspace);
426
541
  }
427
- const candidateCommit = await gitLine(["-C", path, "rev-parse", "HEAD^{commit}"]);
428
- if (candidateCommit !== job.head) {
429
- // The job proved the checks at one SHA; the workspace has since moved.
430
- // Advancing the target ref would publish unchecked code, so fail closed.
431
- return this.#fail(attempt, new Error(`Integration workspace moved since the check ran: ${candidateCommit} != checked ${job.head}.`), "integration", workspace);
432
- }
433
- const validating = updateIntegrationAttempt(attempt, {
434
- status: "validating",
435
- candidateCommit,
436
- checks
437
- }, this.now());
438
- this.store.saveIntegrationAttempt(attempt.taskId, validating);
439
- await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.beforeCommit);
440
- const committed = this.#recordCommitted(validating);
441
- return this.#terminalResult("committed", committed, workspace);
542
+ return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, job.head, checks);
442
543
  }
443
- async #runChecks(attempt, workspace, path) {
544
+ async #runChecks(attempt, workspace, path, environment) {
444
545
  if (attempt.checkCommands.length === 0)
445
546
  return [];
446
547
  const runtime = this.#runtimePreparation(attempt, workspace);
447
548
  this.runtimeIsolation.activate(runtime);
448
549
  let cleanupReason = "failure";
449
550
  try {
450
- const environment = await integrationCheckEnvironment(this.environment, runtime);
551
+ await prepareIntegrationRuntimeHome(runtime, this.home);
451
552
  const checks = await runChecks(path, attempt.checkCommands, this.home, attempt.taskId, attempt.id, environment);
452
553
  cleanupReason = checks.some(({ outcome }) => outcome === "failed")
453
554
  ? "failure"
@@ -472,6 +573,12 @@ export class GitIntegrationService {
472
573
  * gate.
473
574
  */
474
575
  async #runVerificationGate(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
576
+ const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
577
+ attempt = updateIntegrationAttempt(attempt, {
578
+ checkInputDigest: spec.inputDigest,
579
+ gatePlanDigest: gate.planDigest, gateToolchainDigest: gate.toolchainDigest
580
+ }, this.now());
581
+ this.store.saveIntegrationAttempt(attempt.taskId, attempt);
475
582
  if (gate.mode === "enforce") {
476
583
  try {
477
584
  assertNoAdHocFullSuiteChecks(gate.plan, attempt.checkCommands);
@@ -513,12 +620,12 @@ export class GitIntegrationService {
513
620
  this.runtimeIsolation.activate(runtime);
514
621
  let cleanupReason = "failure";
515
622
  try {
516
- const environment = await integrationCheckEnvironment(this.environment, runtime);
623
+ await prepareIntegrationRuntimeHome(runtime, this.home);
517
624
  const steps = [
518
625
  ...planBootstrapJobSteps(gate.plan),
519
626
  ...planL2JobSteps(gate.plan)
520
627
  ];
521
- const outcomes = await runGateStepsInProcess(path, steps, environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
628
+ const outcomes = await runGateStepsInProcess(path, steps, spec.environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
522
629
  const succeeded = outcomes.length === steps.length
523
630
  && outcomes.every((outcome) => outcome.exitCode === 0 && outcome.signal === null && !outcome.timedOut);
524
631
  const artifact = await recordGateArtifactFromStepOutcomes(this.store, identity, gate.plan, outcomes, succeeded, this.now());
@@ -536,6 +643,7 @@ export class GitIntegrationService {
536
643
  }
537
644
  }
538
645
  async #finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks) {
646
+ await assertIntegrationCandidate(workspace.path, candidateCommit, workspace.branch);
539
647
  const validating = updateIntegrationAttempt(attempt, {
540
648
  status: "validating",
541
649
  candidateCommit,
@@ -552,175 +660,75 @@ export class GitIntegrationService {
552
660
  allowExactActive: true
553
661
  });
554
662
  }
555
- async #applySource(attempt, workspace, candidatePath, remoteUrl) {
556
- if (attempt.source.kind === "historical-change-sets") {
557
- throw new Error(`Historical Integration cannot be resumed under the current source contract: ${attempt.id}.`);
558
- }
559
- if (attempt.source.kind === "upstream") {
560
- if (remoteUrl === undefined) {
561
- throw new Error(`Project has no remote URL for upstream Integration: ${attempt.projectId}.`);
562
- }
563
- if (attempt.resolution?.action === "manual-resolution"
564
- && attempt.conflict?.summary.startsWith(REMOTE_BASELINE_CONFLICT_PREFIX)) {
565
- await continueUpstreamRebase(candidatePath);
566
- return undefined;
567
- }
568
- const fetchRemote = this.git.fetchRemoteHeadIntoWorktree;
569
- if (fetchRemote === undefined) {
570
- throw new Error("Integration Git workspace cannot fetch an upstream source.");
571
- }
572
- const fetched = await fetchRemote.call(this.git, {
573
- repositoryPath: candidatePath,
574
- remoteUrl,
575
- branch: attempt.source.branch
576
- });
577
- if (fetched.commit !== attempt.source.remoteCommit) {
578
- throw new Error(`Upstream branch moved before Integration: expected ${attempt.source.remoteCommit}, fetched ${fetched.commit}.`);
579
- }
580
- if (await this.git.isAncestor(candidatePath, attempt.source.remoteCommit, attempt.beforeCommit)) {
581
- return undefined;
582
- }
583
- try {
584
- await git([
585
- "-C", candidatePath,
586
- "-c", "user.name=Yui",
587
- "-c", "user.email=yui@local",
588
- "rebase", "--onto",
589
- attempt.source.remoteCommit,
590
- attempt.source.taskBaseCommit,
591
- workspace.branch
592
- ]);
593
- }
594
- catch (error) {
595
- const affectedPaths = await conflictedPaths(candidatePath);
596
- if (affectedPaths.length === 0)
597
- throw error;
598
- const pending = requireResolutionDecision(attempt, {
599
- affectedPaths,
600
- summary: `${REMOTE_BASELINE_CONFLICT_PREFIX} in ${attempt.targetRef}.`
601
- }, this.now());
602
- this.store.saveIntegrationAttempt(attempt.taskId, pending);
603
- return { status: "blocked", attempt: pending, workspace };
604
- }
605
- return undefined;
606
- }
607
- if (attempt.source.strategy === "manual") {
608
- if (attempt.resolution?.action === "manual-resolution"
609
- && attempt.conflict?.summary.startsWith(MANUAL_INTEGRATION_PREFIX)) {
610
- if (!await this.git.isClean(candidatePath)) {
611
- throw new Error("Manual Integration workspace must be clean and committed.");
612
- }
613
- return undefined;
614
- }
615
- const pending = requireResolutionDecision(attempt, {
616
- affectedPaths: [],
617
- summary: `${MANUAL_INTEGRATION_PREFIX}; apply the selected WorkItem result in the Integration workspace.`
618
- }, this.now());
619
- this.store.saveIntegrationAttempt(attempt.taskId, pending);
620
- return { status: "blocked", attempt: pending, workspace };
621
- }
622
- if (attempt.source.strategy === "ff") {
623
- await git([
624
- "-C", candidatePath,
625
- "merge", "--ff-only",
626
- attempt.source.resultCommit
627
- ]);
628
- return undefined;
629
- }
630
- if (attempt.source.strategy === "merge") {
631
- if (attempt.resolution?.action === "manual-resolution"
632
- && attempt.conflict?.summary.startsWith(WORK_ITEM_MERGE_CONFLICT_PREFIX)) {
633
- await completeMergeResolution(candidatePath);
634
- return undefined;
635
- }
636
- try {
637
- await git([
638
- "-C", candidatePath,
639
- "-c", "user.name=Yui",
640
- "-c", "user.email=yui@local",
641
- "merge", "--no-edit", "--no-ff",
642
- attempt.source.resultCommit
643
- ]);
644
- }
645
- catch (error) {
646
- const affectedPaths = await conflictedPaths(candidatePath);
647
- if (affectedPaths.length === 0)
648
- throw error;
649
- const pending = requireResolutionDecision(attempt, {
650
- affectedPaths,
651
- summary: `${WORK_ITEM_MERGE_CONFLICT_PREFIX} in ${attempt.targetRef}.`
652
- }, this.now());
653
- this.store.saveIntegrationAttempt(attempt.taskId, pending);
654
- return { status: "blocked", attempt: pending, workspace };
655
- }
656
- return undefined;
657
- }
658
- const commits = await commitsBetween(candidatePath, attempt.source.startCommit, attempt.source.resultCommit, attempt.source.workItemId);
659
- let remaining = commits;
660
- if (attempt.resolution?.action === "manual-resolution") {
661
- const resolvedCommit = await completeCherryPickResolution(candidatePath);
662
- const resolvedIndex = commits.findIndex(({ commit }) => commit === resolvedCommit);
663
- if (resolvedIndex < 0) {
664
- throw new Error(`Manual resolution commit is not part of the WorkItem result: ${resolvedCommit}.`);
665
- }
666
- remaining = commits.slice(resolvedIndex + 1);
667
- }
668
- for (const { label, commit } of remaining) {
669
- // Fast-forward when the commit is a direct descendant of HEAD: this
670
- // preserves the original commit SHA, which exact-SHA review evidence
671
- // relies on. Fall back to cherry-pick when the target moved since the
672
- // WorkItem was based (the commit is no longer a direct descendant).
673
- if (await gitSucceeds(["-C", candidatePath, "merge", "--ff-only", commit])) {
674
- continue;
675
- }
676
- try {
677
- await git(["-C", candidatePath, "cherry-pick", commit]);
678
- }
679
- catch {
680
- const affectedPaths = (await git([
681
- "-C", candidatePath, "diff", "--name-only", "--diff-filter=U"
682
- ])).trim().split("\n").filter(Boolean);
683
- if (affectedPaths.length === 0
684
- && await isEmptyCherryPick(candidatePath, commit)) {
685
- await git(["-C", candidatePath, "cherry-pick", "--skip"]);
686
- continue;
687
- }
688
- const pending = requireResolutionDecision(attempt, {
689
- affectedPaths,
690
- summary: `${label} conflicts with ${attempt.targetRef}.`
691
- }, this.now());
692
- this.store.saveIntegrationAttempt(attempt.taskId, pending);
693
- return { status: "blocked", attempt: pending, workspace };
694
- }
695
- }
696
- return undefined;
663
+ /** The same specification is compared before first execution, bound-Job
664
+ * consumption and an unadvanced validating CAS. Computing it is read-only:
665
+ * a changed condition must not recreate a completed runtime or start a Job. */
666
+ async #checkSpecification(attempt, managedWorkspace, gate) {
667
+ const runtime = this.#runtimePreparation(attempt, managedWorkspace);
668
+ const head = attempt.candidateCommit;
669
+ if (head === undefined)
670
+ throw new Error("Integration check specification requires a candidate.");
671
+ const steps = gate === undefined
672
+ ? attempt.checkCommands.map((command, index) => ({
673
+ name: `check-${index + 1}`, command, timeoutMs: 30 * 60_000
674
+ }))
675
+ : [...planBootstrapJobSteps(gate.plan), ...planL2JobSteps(gate.plan)]
676
+ .map(step => ({ ...step, timeoutMs: 30 * 60_000 }));
677
+ const releaseId = integrationRuntimeReleaseIdentity(this.home);
678
+ const environment = Object.freeze({
679
+ ...integrationCheckEnvironment(this.environment, runtime, this.home),
680
+ ...(releaseId === null ? {} : { [INTEGRATION_RUNTIME_RELEASE_ENV]: releaseId })
681
+ });
682
+ const inputDigest = durableJobIdempotencyKey({
683
+ owner: { kind: "integration-attempt", integrationAttemptId: attempt.id },
684
+ projectId: attempt.projectId, head, workspace: managedWorkspace.root,
685
+ env: environment, steps
686
+ });
687
+ return { runtime, head, steps, environment, inputDigest, releaseId };
697
688
  }
698
- async #recoverValidating(attempt, workspace, repositoryPath) {
689
+ async #recoverValidating(attempt, workspace, managedWorkspace, repositoryPath) {
699
690
  if (attempt.candidateCommit === undefined || attempt.checks === undefined) {
700
691
  return this.#fail(attempt, new Error("Validating Integration is missing its candidate commit or checks."), "integration-recovery", workspace);
701
692
  }
702
693
  const target = await resolveRef(repositoryPath, attempt.targetRef);
703
- if (target === attempt.beforeCommit) {
704
- try {
705
- await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
694
+ // An already-applied CAS is a delivery fact, not a request to validate
695
+ // again under today's settings. No-op candidates still need current checks.
696
+ if (target !== attempt.candidateCommit || target === attempt.beforeCommit) {
697
+ if (target !== attempt.beforeCommit) {
698
+ throw new Error(`Target moved to ${target}; Integration CAS outcome needs inspection.`);
706
699
  }
707
- catch (error) {
708
- return this.#fail(attempt, error, "integration-recovery", workspace);
700
+ await assertIntegrationCandidate(workspace.path, attempt.candidateCommit, workspace.branch);
701
+ assertRecordedSourceCandidate(attempt, workspace);
702
+ const project = this.store.getProject(attempt.projectId);
703
+ if (project === null)
704
+ throw new Error("Integration Project is unavailable for check validation.");
705
+ const gate = resolveVerificationGate(project, this.environment);
706
+ const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
707
+ if (attempt.checkInputDigest !== spec.inputDigest
708
+ || gate?.planDigest !== attempt.gatePlanDigest
709
+ || gate?.toolchainDigest !== attempt.gateToolchainDigest) {
710
+ throw new Error("Integration check conditions changed after validation; preserve the evidence and abort or use a new attempt.");
709
711
  }
710
- }
711
- else if (target !== attempt.candidateCommit) {
712
- return this.#fail(attempt, new Error(`Target moved to ${target}; expected ${attempt.beforeCommit}.`), "integration-recovery", workspace);
712
+ if (attempt.jobId !== undefined) {
713
+ const job = this.store.getDurableJob(attempt.taskId, attempt.jobId);
714
+ if (job === null || job.status !== "succeeded") {
715
+ throw new Error("Validating Integration has no successful bound Job evidence.");
716
+ }
717
+ assertCheckJobIdentity(attempt, job, workspace.path);
718
+ }
719
+ await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
713
720
  }
714
721
  await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.candidateCommit);
715
722
  const committed = this.#recordCommitted(attempt);
716
723
  return this.#terminalResult("committed", committed, workspace);
717
724
  }
718
- #recordCommitted(attempt) {
725
+ #recordCommitted(attempt, authorize) {
719
726
  if (attempt.candidateCommit === undefined) {
720
727
  throw new Error(`Committed Integration has no candidate commit: ${attempt.id}.`);
721
728
  }
722
729
  const candidateCommit = attempt.candidateCommit;
723
730
  return this.store.transaction((tx) => {
731
+ authorize?.(tx, attempt.taskId);
724
732
  const task = tx.getTask(attempt.taskId);
725
733
  if (task === null)
726
734
  throw new Error(`Task not found: ${attempt.taskId}.`);
@@ -769,6 +777,27 @@ export class GitIntegrationService {
769
777
  };
770
778
  }
771
779
  }
780
+ function assertCheckJobIdentity(attempt, job, path) {
781
+ if (job.taskId !== attempt.taskId
782
+ || job.owner.kind !== "integration-attempt" || job.owner.integrationAttemptId !== attempt.id
783
+ || job.projectId !== attempt.projectId || job.workspace !== path
784
+ || job.head !== attempt.candidateCommit
785
+ || (attempt.jobId !== undefined && job.id !== attempt.jobId)
786
+ || attempt.checkInputDigest === undefined
787
+ || durableJobIdempotencyKey(job) !== attempt.checkInputDigest) {
788
+ throw new Error("Integration Job identity does not match its admitted Task, source candidate, Project, workspace and check specification.");
789
+ }
790
+ if (job.status === "succeeded") {
791
+ const results = job.result?.steps;
792
+ if (job.result?.outcome !== "succeeded" || results?.length !== job.steps.length
793
+ || job.steps.some((step, index) => {
794
+ const result = results[index];
795
+ return result?.name !== step.name || result.head !== job.head
796
+ || result.exitCode !== 0 || result.signal !== null || result.timedOut;
797
+ }))
798
+ throw new Error("Integration Job success lacks exact candidate step evidence.");
799
+ }
800
+ }
772
801
  function integrationSummary(attempt) {
773
802
  const unchanged = attempt.beforeCommit === attempt.candidateCommit;
774
803
  if (attempt.source.kind === "upstream") {
@@ -796,111 +825,6 @@ function integrationSummary(attempt) {
796
825
  ? `WorkItem ${attempt.source.workItemId} result was already represented; Task head was unchanged.`
797
826
  : `Integrated WorkItem ${attempt.source.workItemId} with ${attempt.source.strategy}.`;
798
827
  }
799
- async function isEmptyCherryPick(path, commit) {
800
- let cherryPickHead;
801
- try {
802
- cherryPickHead = await gitLine([
803
- "-C", path, "rev-parse", "--verify", "CHERRY_PICK_HEAD^{commit}"
804
- ]);
805
- }
806
- catch {
807
- return false;
808
- }
809
- return cherryPickHead === commit
810
- && await gitSucceeds(["-C", path, "diff", "--cached", "--quiet"]);
811
- }
812
- async function commitsBetween(repositoryPath, startCommit, resultCommit, workItemId) {
813
- const commits = (await git([
814
- "-C", repositoryPath, "rev-list", "--reverse",
815
- `${startCommit}..${resultCommit}`
816
- ])).trim().split("\n").filter(Boolean);
817
- const plan = [];
818
- for (const commit of commits) {
819
- if (await gitSucceeds([
820
- "-C", repositoryPath,
821
- "merge-base", "--is-ancestor", commit, "HEAD"
822
- ]))
823
- continue;
824
- plan.push({ label: `WorkItem ${workItemId} commit ${commit}`, commit });
825
- }
826
- return plan;
827
- }
828
- async function completeCherryPickResolution(path) {
829
- const unmerged = (await git(["-C", path, "diff", "--name-only", "--diff-filter=U"])).trim();
830
- if (unmerged.length > 0) {
831
- throw new Error(`Manual resolution is incomplete: ${unmerged.split("\n").join(", ")}.`);
832
- }
833
- let cherryPickHead;
834
- try {
835
- cherryPickHead = await gitLine([
836
- "-C", path, "rev-parse", "--verify", "CHERRY_PICK_HEAD"
837
- ]);
838
- }
839
- catch {
840
- throw new Error("Manual resolution has no active cherry-pick.");
841
- }
842
- const emptyResolution = await gitSucceeds(["-C", path, "diff", "--cached", "--quiet"]);
843
- if (emptyResolution) {
844
- await git(["-C", path, "cherry-pick", "--skip"]);
845
- }
846
- else {
847
- await git(["-C", path, "-c", "user.name=Yui", "-c", "user.email=yui@local",
848
- "cherry-pick", "--continue"]);
849
- }
850
- return cherryPickHead;
851
- }
852
- async function completeMergeResolution(path) {
853
- const affected = await conflictedPaths(path);
854
- if (affected.length > 0) {
855
- throw new Error(`Manual merge resolution is incomplete: ${affected.join(", ")}.`);
856
- }
857
- try {
858
- await gitLine(["-C", path, "rev-parse", "--verify", "MERGE_HEAD"]);
859
- }
860
- catch {
861
- throw new Error("Manual WorkItem resolution has no active merge.");
862
- }
863
- await git([
864
- "-C", path,
865
- "-c", "user.name=Yui",
866
- "-c", "user.email=yui@local",
867
- "commit", "--no-edit"
868
- ]);
869
- }
870
- async function continueUpstreamRebase(path) {
871
- const affected = await conflictedPaths(path);
872
- if (affected.length > 0) {
873
- throw new RemoteBaselineConflictError(affected, `${REMOTE_BASELINE_CONFLICT_PREFIX}: ${affected.join(", ")}.`);
874
- }
875
- try {
876
- await gitLine(["-C", path, "rev-parse", "--verify", "REBASE_HEAD"]);
877
- }
878
- catch {
879
- throw new Error("Manual upstream resolution has no active rebase.");
880
- }
881
- try {
882
- await git([
883
- "-C", path,
884
- "-c", "user.name=Yui",
885
- "-c", "user.email=yui@local",
886
- "-c", "core.editor=true",
887
- "rebase", "--continue"
888
- ]);
889
- }
890
- catch (error) {
891
- const nextAffected = await conflictedPaths(path);
892
- if (nextAffected.length > 0) {
893
- throw new RemoteBaselineConflictError(nextAffected, `${REMOTE_BASELINE_CONFLICT_PREFIX}: ${nextAffected.join(", ")}.`, { cause: error });
894
- }
895
- throw error;
896
- }
897
- }
898
- async function conflictedPaths(path) {
899
- return (await git([
900
- "-C", path,
901
- "diff", "--name-only", "--diff-filter=U"
902
- ])).trim().split("\n").filter(Boolean);
903
- }
904
828
  /**
905
829
  * Map a terminal check job back to the attempt's CheckResult[] shape. The
906
830
  * runner stops at the first failing step, so unreached checks are "skipped"
@@ -1048,27 +972,41 @@ async function spawnCheck(command, cwd, outputFd, environment) {
1048
972
  clearTimeout(timeout);
1049
973
  return { ...completion, timedOut };
1050
974
  }
1051
- async function integrationCheckEnvironment(source, runtime) {
1052
- const home = join(runtime.descriptor.roots.data, "home");
975
+ async function prepareIntegrationRuntimeHome(runtime, home) {
976
+ const homeDirectory = join(runtime.descriptor.roots.data, "home");
1053
977
  try {
1054
- await mkdir(home, { mode: 0o700 });
978
+ await mkdir(homeDirectory, { mode: 0o700 });
1055
979
  }
1056
980
  catch (error) {
1057
981
  if (!isNodeCode(error, "EEXIST"))
1058
982
  throw error;
1059
983
  }
1060
- const homeMetadata = await lstat(home);
984
+ const homeMetadata = await lstat(homeDirectory);
1061
985
  if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) {
1062
986
  throw new Error("Integration runtime HOME is not an owned directory.");
1063
987
  }
988
+ // The tmux socket endpoint is the one approved short-path IPC exception; every
989
+ // other temp consumer (TMPDIR/TMP/TEMP) stays on the Home-side runtime tmp.
990
+ const tmuxSocketRoot = integrationTmuxSocketRoot(home);
991
+ try {
992
+ await mkdir(tmuxSocketRoot, { mode: 0o700 });
993
+ }
994
+ catch (error) {
995
+ if (!isNodeCode(error, "EEXIST"))
996
+ throw error;
997
+ }
998
+ }
999
+ function integrationCheckEnvironment(source, runtime, home) {
1000
+ const homeDirectory = join(runtime.descriptor.roots.data, "home");
1001
+ const tmuxSocketRoot = integrationTmuxSocketRoot(home);
1064
1002
  return Object.freeze({
1065
1003
  ...selectEnvironment(source, INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES),
1066
1004
  PATH: source.PATH || `${dirname(process.execPath)}:/usr/local/bin:/usr/bin:/bin`,
1067
- HOME: home,
1005
+ HOME: homeDirectory,
1068
1006
  TMPDIR: runtime.descriptor.roots.temporary,
1069
1007
  TMP: runtime.descriptor.roots.temporary,
1070
1008
  TEMP: runtime.descriptor.roots.temporary,
1071
- TMUX_TMPDIR: runtime.descriptor.roots.temporary,
1009
+ TMUX_TMPDIR: tmuxSocketRoot,
1072
1010
  ...runtime.environment
1073
1011
  });
1074
1012
  }
@@ -1080,18 +1018,32 @@ function isNodeCode(error, code) {
1080
1018
  }
1081
1019
  function defaultIntegrationRuntimeIsolation(home, homeId) {
1082
1020
  const controlHome = resolve(home);
1021
+ const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
1083
1022
  return new FileTaskRuntimeIsolation({
1084
- runtimeRoot: integrationRuntimeRoot(controlHome),
1023
+ // The integration check's provider data/cache/tmp live in a dedicated Home
1024
+ // partition — the same isolation contract every Task runtime obeys — not in
1025
+ // a system-wide `/tmp` root. Only the tmux socket ENDPOINT stays a short
1026
+ // `/tmp` path (see `integrationCheckEnvironment`) for the `sockaddr_un`
1027
+ // budget; ordinary runtime state is now Yui-managed under Home.
1028
+ runtimeRoot,
1085
1029
  pathLayout: "compact",
1086
1030
  controlPlane: {
1087
1031
  yuiHome: controlHome,
1032
+ managedRuntimeRoot: runtimeRoot,
1088
1033
  controllerSocketPath: controllerSocketPath(homeId),
1089
1034
  tmuxNamespace: yuiTmuxServerName(controlHome),
1090
1035
  globalInstallPaths: [process.execPath]
1091
1036
  }
1092
1037
  });
1093
1038
  }
1094
- function integrationRuntimeRoot(home) {
1039
+ /**
1040
+ * Short `/tmp` directory that holds ONLY the integration check's tmux socket
1041
+ * endpoint. tmux uses a `sockaddr_un` path whose length budget cannot absorb a
1042
+ * deep Home path, so this single IPC endpoint is the one approved exception to
1043
+ * the unified-Home contract. All other integration runtime state (data, cache,
1044
+ * ordinary temp) lives in the Home partition from `managedIntegrationRuntimeRoot`.
1045
+ */
1046
+ function integrationTmuxSocketRoot(home) {
1095
1047
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1096
1048
  const homeDigest = createHash("sha256").update(resolve(home)).digest("hex").slice(0, 16);
1097
1049
  return join("/tmp", `yi-${uid.toString(36)}-${homeDigest}`);
@@ -1175,15 +1127,6 @@ async function git(args) {
1175
1127
  });
1176
1128
  }
1177
1129
  }
1178
- async function gitSucceeds(args) {
1179
- try {
1180
- await git(args);
1181
- return true;
1182
- }
1183
- catch {
1184
- return false;
1185
- }
1186
- }
1187
1130
  async function gitLine(args) {
1188
1131
  const value = (await git(args)).trim();
1189
1132
  if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(value)) {