@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -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,7 +70,6 @@ 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
  }
@@ -79,7 +77,10 @@ export class GitIntegrationService {
79
77
  return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
80
78
  }
81
79
  async integrate(taskId, integrationId) {
82
- const initial = requireIntegration(this.store, taskId, integrationId);
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
@@ -87,34 +88,46 @@ export class GitIntegrationService {
87
88
  // fence is held across every Git effect and released only on exit.
88
89
  const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
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,8 +246,93 @@ 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) {
264
+ const initial = requireIntegration(this.store, taskId, integrationId);
265
+ const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
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
+ }
228
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
+ }
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();
@@ -253,9 +360,18 @@ export class GitIntegrationService {
253
360
  const runtime = this.#runtimePreparation(integration, managedWorkspace);
254
361
  this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
255
362
  }
363
+ const recorded = managedWorkspace?.entries[0];
364
+ const directory = recorded?.directory
365
+ ?? task.projectBindings.find(({ projectId }) => projectId === integration.projectId)?.directory;
366
+ if (directory === undefined) {
367
+ throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
368
+ }
256
369
  const result = await this.git.removeIntegrationWorktree({
257
370
  repositoryPath: taskRepository,
258
- container: join(this.worktreeRoot, project.name),
371
+ container: recorded !== undefined
372
+ ? dirname(recorded.path)
373
+ : integrationWorkspaceRoot(this.home, task.id, integration.id),
374
+ directory,
259
375
  taskSegment: taskWorkspaceRefSegment(task),
260
376
  integrationId: integration.id,
261
377
  discardChanges: integration.status === "failed"
@@ -287,26 +403,15 @@ export class GitIntegrationService {
287
403
  * it, so a Leader exit mid-checks leaves no running/no-check zombie.
288
404
  */
289
405
  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) {
406
+ if (attempt.status !== "running" || attempt.candidateCommit === undefined) {
407
+ throw new Error(`Integration check admission requires a running, proven candidate: ${attempt.id}.`);
408
+ }
409
+ await assertIntegrationCandidate(path, attempt.candidateCommit, workspace.branch);
410
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
411
+ const { runtime, head, steps, environment, inputDigest, releaseId } = await this.#checkSpecification(attempt, managedWorkspace, gate);
412
+ const ownedJobs = this.store.listDurableJobs(attempt.taskId).filter(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === attempt.id);
413
+ if (gate === undefined && releaseId !== null && attempt.checkInputDigest === undefined
414
+ && ownedJobs.length === 0 && attempt.jobId === undefined) {
310
415
  const reusable = findReusableIntegrationCheckEvidence({
311
416
  taskId: attempt.taskId,
312
417
  projectId: attempt.projectId,
@@ -320,25 +425,28 @@ export class GitIntegrationService {
320
425
  logPathFor: (job, relativeLogPath) => (join(job.artifactsLocator, "logs", relativeLogPath))
321
426
  });
322
427
  if (reusable !== null) {
323
- return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, head, [...reusable.checks]);
428
+ return this.#finalizeGateSuccess(updateIntegrationAttempt(attempt, { checkInputDigest: inputDigest }, this.now()), workspace, repositoryPath, head, [...reusable.checks]);
324
429
  }
325
430
  }
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
- });
431
+ if (attempt.checkInputDigest !== undefined && attempt.checkInputDigest !== inputDigest) {
432
+ throw new Error("Integration check conditions changed since admission; inspect the original Job, then abort or start a new attempt.");
433
+ }
434
+ if (attempt.jobId !== undefined) {
435
+ return this.#resumeCheckJob(attempt, workspace, path, repositoryPath, gate);
436
+ }
332
437
  // Persist the gate identity before starting the job so a plan edit
333
438
  // during the gate never misattributes the evidence on resume.
334
- let persisted = attempt;
335
- if (gate !== undefined) {
336
- persisted = updateIntegrationAttempt(attempt, {
439
+ const persisted = updateIntegrationAttempt(attempt, {
440
+ checkInputDigest: inputDigest,
441
+ ...(gate === undefined ? {} : {
337
442
  gatePlanDigest: gate.planDigest,
338
443
  gateToolchainDigest: gate.toolchainDigest
339
- }, this.now());
340
- this.store.saveIntegrationAttempt(attempt.taskId, persisted);
341
- }
444
+ })
445
+ }, this.now());
446
+ this.store.saveIntegrationAttempt(attempt.taskId, persisted);
447
+ // All domain admission and durable candidate identity precede Job effects.
448
+ this.runtimeIsolation.activate(runtime);
449
+ await prepareIntegrationRuntimeHome(runtime, this.home);
342
450
  const job = await this.jobPort.startCheckJob({
343
451
  taskId: attempt.taskId,
344
452
  integrationId: attempt.id,
@@ -348,6 +456,7 @@ export class GitIntegrationService {
348
456
  env: environment,
349
457
  steps
350
458
  });
459
+ assertCheckJobIdentity(persisted, job, path);
351
460
  const bound = recordIntegrationCheckJob(persisted, job.id, this.now());
352
461
  this.store.saveIntegrationAttempt(attempt.taskId, bound);
353
462
  if (job.status !== "queued" && job.status !== "running") {
@@ -367,12 +476,17 @@ export class GitIntegrationService {
367
476
  */
368
477
  async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
369
478
  const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
479
+ assertCheckJobIdentity(attempt, job, path);
480
+ await assertIntegrationCandidate(path, job.head, workspace.branch);
481
+ await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
482
+ if (gate?.planDigest !== attempt.gatePlanDigest
483
+ || gate?.toolchainDigest !== attempt.gateToolchainDigest) {
484
+ throw new Error("Integration verification plan/toolchain changed; the original Job cannot prove the current gate.");
485
+ }
370
486
  if (job.status === "queued" || job.status === "running") {
371
487
  return { status: "checks-running", attempt, workspace, job };
372
488
  }
373
- const planStyle = gate !== undefined
374
- || (job.steps?.some((step) => step.name.startsWith("bootstrap-") || step.name.startsWith("gate-")) ?? false);
375
- const checks = planStyle
489
+ const checks = gate !== undefined
376
490
  ? checkResultsFromGateJob(job, this.home)
377
491
  : checkResultsFromJob(attempt, job);
378
492
  const managedWorkspace = this.store.getIntegrationWorkspace(attempt.taskId, attempt.id);
@@ -380,25 +494,13 @@ export class GitIntegrationService {
380
494
  const runtime = this.#runtimePreparation(attempt, managedWorkspace);
381
495
  this.runtimeIsolation.cleanup(runtime, checks.some((check) => check.outcome === "failed") ? "failure" : "completion");
382
496
  }
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).
497
+ // The current plan/toolchain was matched to admission above. Record only
498
+ // that exact identity, never reinterpret an old Job under a changed plan.
388
499
  if (gate !== undefined
389
500
  && (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
501
  const identity = gateIdentityForCandidate({
400
502
  projectId: attempt.projectId,
401
- gate: recordGate,
503
+ gate,
402
504
  level: "L2",
403
505
  commit: job.head,
404
506
  targetRef: attempt.targetRef,
@@ -424,30 +526,16 @@ export class GitIntegrationService {
424
526
  this.store.saveIntegrationAttempt(attempt.taskId, failed);
425
527
  return this.#terminalResult("failed", failed, workspace);
426
528
  }
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);
529
+ return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, job.head, checks);
442
530
  }
443
- async #runChecks(attempt, workspace, path) {
531
+ async #runChecks(attempt, workspace, path, environment) {
444
532
  if (attempt.checkCommands.length === 0)
445
533
  return [];
446
534
  const runtime = this.#runtimePreparation(attempt, workspace);
447
535
  this.runtimeIsolation.activate(runtime);
448
536
  let cleanupReason = "failure";
449
537
  try {
450
- const environment = await integrationCheckEnvironment(this.environment, runtime);
538
+ await prepareIntegrationRuntimeHome(runtime, this.home);
451
539
  const checks = await runChecks(path, attempt.checkCommands, this.home, attempt.taskId, attempt.id, environment);
452
540
  cleanupReason = checks.some(({ outcome }) => outcome === "failed")
453
541
  ? "failure"
@@ -472,6 +560,12 @@ export class GitIntegrationService {
472
560
  * gate.
473
561
  */
474
562
  async #runVerificationGate(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
563
+ const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
564
+ attempt = updateIntegrationAttempt(attempt, {
565
+ checkInputDigest: spec.inputDigest,
566
+ gatePlanDigest: gate.planDigest, gateToolchainDigest: gate.toolchainDigest
567
+ }, this.now());
568
+ this.store.saveIntegrationAttempt(attempt.taskId, attempt);
475
569
  if (gate.mode === "enforce") {
476
570
  try {
477
571
  assertNoAdHocFullSuiteChecks(gate.plan, attempt.checkCommands);
@@ -513,12 +607,12 @@ export class GitIntegrationService {
513
607
  this.runtimeIsolation.activate(runtime);
514
608
  let cleanupReason = "failure";
515
609
  try {
516
- const environment = await integrationCheckEnvironment(this.environment, runtime);
610
+ await prepareIntegrationRuntimeHome(runtime, this.home);
517
611
  const steps = [
518
612
  ...planBootstrapJobSteps(gate.plan),
519
613
  ...planL2JobSteps(gate.plan)
520
614
  ];
521
- const outcomes = await runGateStepsInProcess(path, steps, environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
615
+ const outcomes = await runGateStepsInProcess(path, steps, spec.environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
522
616
  const succeeded = outcomes.length === steps.length
523
617
  && outcomes.every((outcome) => outcome.exitCode === 0 && outcome.signal === null && !outcome.timedOut);
524
618
  const artifact = await recordGateArtifactFromStepOutcomes(this.store, identity, gate.plan, outcomes, succeeded, this.now());
@@ -536,6 +630,7 @@ export class GitIntegrationService {
536
630
  }
537
631
  }
538
632
  async #finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks) {
633
+ await assertIntegrationCandidate(workspace.path, candidateCommit, workspace.branch);
539
634
  const validating = updateIntegrationAttempt(attempt, {
540
635
  status: "validating",
541
636
  candidateCommit,
@@ -552,175 +647,75 @@ export class GitIntegrationService {
552
647
  allowExactActive: true
553
648
  });
554
649
  }
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;
650
+ /** The same specification is compared before first execution, bound-Job
651
+ * consumption and an unadvanced validating CAS. Computing it is read-only:
652
+ * a changed condition must not recreate a completed runtime or start a Job. */
653
+ async #checkSpecification(attempt, managedWorkspace, gate) {
654
+ const runtime = this.#runtimePreparation(attempt, managedWorkspace);
655
+ const head = attempt.candidateCommit;
656
+ if (head === undefined)
657
+ throw new Error("Integration check specification requires a candidate.");
658
+ const steps = gate === undefined
659
+ ? attempt.checkCommands.map((command, index) => ({
660
+ name: `check-${index + 1}`, command, timeoutMs: 30 * 60_000
661
+ }))
662
+ : [...planBootstrapJobSteps(gate.plan), ...planL2JobSteps(gate.plan)]
663
+ .map(step => ({ ...step, timeoutMs: 30 * 60_000 }));
664
+ const releaseId = integrationRuntimeReleaseIdentity(this.home);
665
+ const environment = Object.freeze({
666
+ ...integrationCheckEnvironment(this.environment, runtime, this.home),
667
+ ...(releaseId === null ? {} : { [INTEGRATION_RUNTIME_RELEASE_ENV]: releaseId })
668
+ });
669
+ const inputDigest = durableJobIdempotencyKey({
670
+ owner: { kind: "integration-attempt", integrationAttemptId: attempt.id },
671
+ projectId: attempt.projectId, head, workspace: managedWorkspace.root,
672
+ env: environment, steps
673
+ });
674
+ return { runtime, head, steps, environment, inputDigest, releaseId };
697
675
  }
698
- async #recoverValidating(attempt, workspace, repositoryPath) {
676
+ async #recoverValidating(attempt, workspace, managedWorkspace, repositoryPath) {
699
677
  if (attempt.candidateCommit === undefined || attempt.checks === undefined) {
700
678
  return this.#fail(attempt, new Error("Validating Integration is missing its candidate commit or checks."), "integration-recovery", workspace);
701
679
  }
702
680
  const target = await resolveRef(repositoryPath, attempt.targetRef);
703
- if (target === attempt.beforeCommit) {
704
- try {
705
- await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
681
+ // An already-applied CAS is a delivery fact, not a request to validate
682
+ // again under today's settings. No-op candidates still need current checks.
683
+ if (target !== attempt.candidateCommit || target === attempt.beforeCommit) {
684
+ if (target !== attempt.beforeCommit) {
685
+ throw new Error(`Target moved to ${target}; Integration CAS outcome needs inspection.`);
706
686
  }
707
- catch (error) {
708
- return this.#fail(attempt, error, "integration-recovery", workspace);
687
+ await assertIntegrationCandidate(workspace.path, attempt.candidateCommit, workspace.branch);
688
+ assertRecordedSourceCandidate(attempt, workspace);
689
+ const project = this.store.getProject(attempt.projectId);
690
+ if (project === null)
691
+ throw new Error("Integration Project is unavailable for check validation.");
692
+ const gate = resolveVerificationGate(project, this.environment);
693
+ const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
694
+ if (attempt.checkInputDigest !== spec.inputDigest
695
+ || gate?.planDigest !== attempt.gatePlanDigest
696
+ || gate?.toolchainDigest !== attempt.gateToolchainDigest) {
697
+ throw new Error("Integration check conditions changed after validation; preserve the evidence and abort or use a new attempt.");
709
698
  }
710
- }
711
- else if (target !== attempt.candidateCommit) {
712
- return this.#fail(attempt, new Error(`Target moved to ${target}; expected ${attempt.beforeCommit}.`), "integration-recovery", workspace);
699
+ if (attempt.jobId !== undefined) {
700
+ const job = this.store.getDurableJob(attempt.taskId, attempt.jobId);
701
+ if (job === null || job.status !== "succeeded") {
702
+ throw new Error("Validating Integration has no successful bound Job evidence.");
703
+ }
704
+ assertCheckJobIdentity(attempt, job, workspace.path);
705
+ }
706
+ await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
713
707
  }
714
708
  await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.candidateCommit);
715
709
  const committed = this.#recordCommitted(attempt);
716
710
  return this.#terminalResult("committed", committed, workspace);
717
711
  }
718
- #recordCommitted(attempt) {
712
+ #recordCommitted(attempt, authorize) {
719
713
  if (attempt.candidateCommit === undefined) {
720
714
  throw new Error(`Committed Integration has no candidate commit: ${attempt.id}.`);
721
715
  }
722
716
  const candidateCommit = attempt.candidateCommit;
723
717
  return this.store.transaction((tx) => {
718
+ authorize?.(tx, attempt.taskId);
724
719
  const task = tx.getTask(attempt.taskId);
725
720
  if (task === null)
726
721
  throw new Error(`Task not found: ${attempt.taskId}.`);
@@ -769,6 +764,27 @@ export class GitIntegrationService {
769
764
  };
770
765
  }
771
766
  }
767
+ function assertCheckJobIdentity(attempt, job, path) {
768
+ if (job.taskId !== attempt.taskId
769
+ || job.owner.kind !== "integration-attempt" || job.owner.integrationAttemptId !== attempt.id
770
+ || job.projectId !== attempt.projectId || job.workspace !== path
771
+ || job.head !== attempt.candidateCommit
772
+ || (attempt.jobId !== undefined && job.id !== attempt.jobId)
773
+ || attempt.checkInputDigest === undefined
774
+ || durableJobIdempotencyKey(job) !== attempt.checkInputDigest) {
775
+ throw new Error("Integration Job identity does not match its admitted Task, source candidate, Project, workspace and check specification.");
776
+ }
777
+ if (job.status === "succeeded") {
778
+ const results = job.result?.steps;
779
+ if (job.result?.outcome !== "succeeded" || results?.length !== job.steps.length
780
+ || job.steps.some((step, index) => {
781
+ const result = results[index];
782
+ return result?.name !== step.name || result.head !== job.head
783
+ || result.exitCode !== 0 || result.signal !== null || result.timedOut;
784
+ }))
785
+ throw new Error("Integration Job success lacks exact candidate step evidence.");
786
+ }
787
+ }
772
788
  function integrationSummary(attempt) {
773
789
  const unchanged = attempt.beforeCommit === attempt.candidateCommit;
774
790
  if (attempt.source.kind === "upstream") {
@@ -796,111 +812,6 @@ function integrationSummary(attempt) {
796
812
  ? `WorkItem ${attempt.source.workItemId} result was already represented; Task head was unchanged.`
797
813
  : `Integrated WorkItem ${attempt.source.workItemId} with ${attempt.source.strategy}.`;
798
814
  }
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
815
  /**
905
816
  * Map a terminal check job back to the attempt's CheckResult[] shape. The
906
817
  * runner stops at the first failing step, so unreached checks are "skipped"
@@ -1048,27 +959,41 @@ async function spawnCheck(command, cwd, outputFd, environment) {
1048
959
  clearTimeout(timeout);
1049
960
  return { ...completion, timedOut };
1050
961
  }
1051
- async function integrationCheckEnvironment(source, runtime) {
1052
- const home = join(runtime.descriptor.roots.data, "home");
962
+ async function prepareIntegrationRuntimeHome(runtime, home) {
963
+ const homeDirectory = join(runtime.descriptor.roots.data, "home");
1053
964
  try {
1054
- await mkdir(home, { mode: 0o700 });
965
+ await mkdir(homeDirectory, { mode: 0o700 });
1055
966
  }
1056
967
  catch (error) {
1057
968
  if (!isNodeCode(error, "EEXIST"))
1058
969
  throw error;
1059
970
  }
1060
- const homeMetadata = await lstat(home);
971
+ const homeMetadata = await lstat(homeDirectory);
1061
972
  if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) {
1062
973
  throw new Error("Integration runtime HOME is not an owned directory.");
1063
974
  }
975
+ // The tmux socket endpoint is the one approved short-path IPC exception; every
976
+ // other temp consumer (TMPDIR/TMP/TEMP) stays on the Home-side runtime tmp.
977
+ const tmuxSocketRoot = integrationTmuxSocketRoot(home);
978
+ try {
979
+ await mkdir(tmuxSocketRoot, { mode: 0o700 });
980
+ }
981
+ catch (error) {
982
+ if (!isNodeCode(error, "EEXIST"))
983
+ throw error;
984
+ }
985
+ }
986
+ function integrationCheckEnvironment(source, runtime, home) {
987
+ const homeDirectory = join(runtime.descriptor.roots.data, "home");
988
+ const tmuxSocketRoot = integrationTmuxSocketRoot(home);
1064
989
  return Object.freeze({
1065
990
  ...selectEnvironment(source, INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES),
1066
991
  PATH: source.PATH || `${dirname(process.execPath)}:/usr/local/bin:/usr/bin:/bin`,
1067
- HOME: home,
992
+ HOME: homeDirectory,
1068
993
  TMPDIR: runtime.descriptor.roots.temporary,
1069
994
  TMP: runtime.descriptor.roots.temporary,
1070
995
  TEMP: runtime.descriptor.roots.temporary,
1071
- TMUX_TMPDIR: runtime.descriptor.roots.temporary,
996
+ TMUX_TMPDIR: tmuxSocketRoot,
1072
997
  ...runtime.environment
1073
998
  });
1074
999
  }
@@ -1080,18 +1005,32 @@ function isNodeCode(error, code) {
1080
1005
  }
1081
1006
  function defaultIntegrationRuntimeIsolation(home, homeId) {
1082
1007
  const controlHome = resolve(home);
1008
+ const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
1083
1009
  return new FileTaskRuntimeIsolation({
1084
- runtimeRoot: integrationRuntimeRoot(controlHome),
1010
+ // The integration check's provider data/cache/tmp live in a dedicated Home
1011
+ // partition — the same isolation contract every Task runtime obeys — not in
1012
+ // a system-wide `/tmp` root. Only the tmux socket ENDPOINT stays a short
1013
+ // `/tmp` path (see `integrationCheckEnvironment`) for the `sockaddr_un`
1014
+ // budget; ordinary runtime state is now Yui-managed under Home.
1015
+ runtimeRoot,
1085
1016
  pathLayout: "compact",
1086
1017
  controlPlane: {
1087
1018
  yuiHome: controlHome,
1019
+ managedRuntimeRoot: runtimeRoot,
1088
1020
  controllerSocketPath: controllerSocketPath(homeId),
1089
1021
  tmuxNamespace: yuiTmuxServerName(controlHome),
1090
1022
  globalInstallPaths: [process.execPath]
1091
1023
  }
1092
1024
  });
1093
1025
  }
1094
- function integrationRuntimeRoot(home) {
1026
+ /**
1027
+ * Short `/tmp` directory that holds ONLY the integration check's tmux socket
1028
+ * endpoint. tmux uses a `sockaddr_un` path whose length budget cannot absorb a
1029
+ * deep Home path, so this single IPC endpoint is the one approved exception to
1030
+ * the unified-Home contract. All other integration runtime state (data, cache,
1031
+ * ordinary temp) lives in the Home partition from `managedIntegrationRuntimeRoot`.
1032
+ */
1033
+ function integrationTmuxSocketRoot(home) {
1095
1034
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
1096
1035
  const homeDigest = createHash("sha256").update(resolve(home)).digest("hex").slice(0, 16);
1097
1036
  return join("/tmp", `yi-${uid.toString(36)}-${homeDigest}`);
@@ -1175,15 +1114,6 @@ async function git(args) {
1175
1114
  });
1176
1115
  }
1177
1116
  }
1178
- async function gitSucceeds(args) {
1179
- try {
1180
- await git(args);
1181
- return true;
1182
- }
1183
- catch {
1184
- return false;
1185
- }
1186
- }
1187
1117
  async function gitLine(args) {
1188
1118
  const value = (await git(args)).trim();
1189
1119
  if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(value)) {