@zq-silk/yui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -27,6 +27,13 @@ export async function reconcileTaskRemoteBaselines(taskId, store, home, options
27
27
  || git.mergeRemoteIntoWorktree === undefined) {
28
28
  throw usageError(`Task ${task.id} cannot verify remote baselines: managed Git workspace support is unavailable.`);
29
29
  }
30
+ // A completed Task-final Review that exactly covers the current frozen heads
31
+ // is the final review evidence. Do not reconcile the remote after a clean
32
+ // review, as merging a moved remote would change the reviewed head and
33
+ // invalidate the review.
34
+ if (await hasCompletedTaskFinalReviewForCurrentCandidate(store, task, workspace, git)) {
35
+ return [];
36
+ }
30
37
  const plans = [];
31
38
  for (const binding of task.projectBindings) {
32
39
  const project = store.getProject(binding.projectId);
@@ -41,6 +48,13 @@ export async function reconcileTaskRemoteBaselines(taskId, store, home, options
41
48
  }
42
49
  const currentCommit = (await git.inspect(entry.path, "HEAD")).baseCommit;
43
50
  const previousIntegration = latestCommittedIntegration(store, task.id, project.id);
51
+ // A completed Task-final Review for the exact current head freezes the
52
+ // Task baseline: the delivery has been reviewed and approved, so the
53
+ // completion gate must not merge remote changes that would invalidate
54
+ // the review evidence.
55
+ if (hasFrozenTaskBaseline(store, task.id, project.id, currentCommit)) {
56
+ continue;
57
+ }
44
58
  // Let the existing exact-head Task-final gate report local post-
45
59
  // Integration drift before attempting any network operation. This keeps
46
60
  // completion read-only for an already-invalid candidate and preserves its
@@ -70,6 +84,13 @@ export async function reconcileTaskRemoteBaselines(taskId, store, home, options
70
84
  const reconciled = [];
71
85
  for (const plan of plans) {
72
86
  const attempt = store.transaction((tx) => {
87
+ // Re-check the frozen baseline inside the transaction: a Task-final
88
+ // Review may have completed during the async remote fetch, freezing
89
+ // the head after the initial check. Creating a new Integration here
90
+ // would move the reviewed head and invalidate the review evidence.
91
+ if (hasFrozenTaskBaseline(tx, task.id, plan.project.id, plan.currentCommit)) {
92
+ return null;
93
+ }
73
94
  const created = createIntegrationAttempt({
74
95
  id: tx.nextIntegrationAttemptId(task.id),
75
96
  taskId: task.id,
@@ -82,11 +103,25 @@ export async function reconcileTaskRemoteBaselines(taskId, store, home, options
82
103
  tx.saveIntegrationAttempt(task.id, created);
83
104
  return created;
84
105
  });
106
+ if (attempt === null)
107
+ continue;
85
108
  const baseline = {
86
109
  remoteUrl: plan.project.remoteUrl,
87
110
  branch: plan.remote.branch
88
111
  };
89
- const result = await new GitIntegrationService(home, store, git, options.now ?? (() => new Date()), options.environment).integrate(task.id, attempt.id, { remoteBaseline: baseline });
112
+ const result = await new GitIntegrationService(home, store, git, options.now ?? (() => new Date()), options.environment, undefined, options.jobPort).integrate(task.id, attempt.id, { remoteBaseline: baseline });
113
+ // rr6/f3: A moved remote with non-empty checks spawns a DurableJob. This
114
+ // is a pending completion outcome, not a failure: name the exact
115
+ // Integration and Job and the exact continuation command (mirroring the
116
+ // `task integration` checks-running message) so the caller can resume the
117
+ // same remote-reconciliation attempt instead of being blocked by the
118
+ // preflight's active-job/unresolved-Integration gates.
119
+ if (result.status === "checks-running") {
120
+ throw usageError(`Remote baseline reconciliation for ${task.id}/${plan.project.id} is running checks as `
121
+ + `Integration ${result.attempt.id} (DurableJob ${result.job.id}); run `
122
+ + `'yui task integration continue ${task.id}/${result.attempt.id}' when the job finishes, `
123
+ + `then retry task complete.`);
124
+ }
90
125
  if (result.status !== "committed" || result.attempt.candidateCommit === undefined) {
91
126
  const detail = result.attempt.checks?.find(({ outcome }) => outcome === "failed")?.details
92
127
  ?? result.attempt.conflict?.summary
@@ -123,9 +158,51 @@ function requireTaskWorkspace(store, task) {
123
158
  }
124
159
  return workspace;
125
160
  }
161
+ /**
162
+ * Check whether the latest Task-final ReviewRound is completed and its
163
+ * immutable candidate exactly matches the current actual heads for every
164
+ * Project in the Task. When this holds, the reviewed head is the final
165
+ * head and remote reconciliation must not change it.
166
+ */
167
+ async function hasCompletedTaskFinalReviewForCurrentCandidate(store, task, workspace, git) {
168
+ const rounds = store.listReviewRounds(task.id)
169
+ .filter((round) => (round.scope ?? "work-item") === "task")
170
+ .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
171
+ const latest = rounds.at(-1);
172
+ if (latest === undefined || latest.status !== "completed")
173
+ return false;
174
+ if (latest.taskCandidate === undefined)
175
+ return false;
176
+ const candidateProjects = new Map(latest.taskCandidate.projects.map((entry) => [entry.projectId, entry.commit]));
177
+ for (const binding of task.projectBindings) {
178
+ const reviewedCommit = candidateProjects.get(binding.projectId);
179
+ if (reviewedCommit === undefined)
180
+ return false;
181
+ const entry = workspaceProjectEntry(workspace, binding.projectId);
182
+ if (entry === undefined || entry.access !== "write")
183
+ return false;
184
+ const actualHead = (await git.inspect(entry.path, "HEAD")).baseCommit;
185
+ if (actualHead !== reviewedCommit)
186
+ return false;
187
+ }
188
+ return true;
189
+ }
126
190
  function latestCommittedIntegration(store, taskId, projectId) {
127
191
  return store.listIntegrationAttempts(taskId)
128
192
  .filter((attempt) => attempt.projectId === projectId && attempt.status === "committed")
129
193
  .sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }))
130
194
  .at(-1);
131
195
  }
196
+ /**
197
+ * A Task baseline is frozen when a completed (clean) Task-final ReviewRound
198
+ * reviewed the exact current head for the given Project. Once frozen, the
199
+ * completion gate skips remote baseline reconciliation to prevent merging
200
+ * unreviewed remote changes into the approved delivery.
201
+ */
202
+ function hasFrozenTaskBaseline(store, taskId, projectId, currentCommit) {
203
+ return store.listReviewRounds(taskId)
204
+ .some((round) => ((round.scope ?? "work-item") === "task"
205
+ && round.status === "completed"
206
+ && round.taskCandidate !== undefined
207
+ && round.taskCandidate.projects.some((entry) => (entry.projectId === projectId && entry.commit === currentCommit))));
208
+ }
@@ -3,6 +3,7 @@ import { taskMessageAuthorLabel } from "../message/message.js";
3
3
  import { formatTimestamp } from "../output/timePresentation.js";
4
4
  import { isRoleRunStalled, latestStallProgressAt } from "../scheduler/roleRunStall.js";
5
5
  import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
6
+ import { projectNextAction } from "../task/nextAction.js";
6
7
  import { inspectTaskRoleSessionRecovery } from "./taskRoleRuntimeStatus.js";
7
8
  import { summarizeExecutionGroup } from "../execution/executionGroup.js";
8
9
  import { currentWorkItemExecutionGroup } from "../workItem/workItem.js";
@@ -31,6 +32,14 @@ export function runTaskContextCommand(args, store) {
31
32
  throw new Error(`Task execution projection disappeared: ${task.id}.`);
32
33
  }
33
34
  const roleSessionSets = reader.listRoleSessionSets(task.id);
35
+ const agentRuns = chronological(reader.listAgentRuns(task.id));
36
+ const reviewRounds = chronological(reader.listReviewRounds(task.id));
37
+ const changeSets = chronological(reader.listChangeSets(task.id));
38
+ const integrations = chronological(reader.listIntegrationAttempts(task.id));
39
+ const nextActionFacts = reader.readNextActionFacts(task.id);
40
+ if (nextActionFacts === null) {
41
+ throw new Error(`Task next-action facts disappeared: ${task.id}.`);
42
+ }
34
43
  return {
35
44
  task,
36
45
  execution,
@@ -44,17 +53,18 @@ export function runTaskContextCommand(args, store) {
44
53
  roleSessionSets,
45
54
  roleSessionRecoveries: roles.map((role) => (inspectTaskRoleSessionRecovery(task.id, role.name, reader))),
46
55
  workItems,
47
- agentRuns: chronological(reader.listAgentRuns(task.id)),
48
- reviewRounds: chronological(reader.listReviewRounds(task.id)),
49
- changeSets: chronological(reader.listChangeSets(task.id)),
50
- integrations: chronological(reader.listIntegrationAttempts(task.id)),
56
+ agentRuns,
57
+ reviewRounds,
58
+ changeSets,
59
+ integrations,
51
60
  messages: reader.listMessages(task.id),
52
61
  openInputRequests: inputRequests.filter((request) => request.status === "open"),
53
62
  resolvedInputRequests: inputRequests.filter((request) => request.status !== "open"),
54
- events: reader.listEvents(task.id)
63
+ events: reader.listEvents(task.id),
64
+ nextAction: projectNextAction(nextActionFacts)
55
65
  };
56
66
  });
57
- const { task, execution, reviewConfig, brief, activeDecisions, milestones, roles, managedWorkspaces, roleSessionSets, roleSessionRecoveries, workItems, agentRuns, reviewRounds, changeSets, integrations, messages, openInputRequests, resolvedInputRequests, events } = data;
67
+ const { task, execution, reviewConfig, brief, activeDecisions, milestones, roles, managedWorkspaces, roleSessionSets, roleSessionRecoveries, workItems, agentRuns, reviewRounds, changeSets, integrations, messages, openInputRequests, resolvedInputRequests, events, nextAction } = data;
58
68
  const timeZone = store.getConfig().timeZone;
59
69
  const displayedActiveDecisions = activeDecisions.slice(-RECENT_RECORD_LIMIT);
60
70
  const displayedWorkItems = currentAndRecentWorkItems(workItems);
@@ -67,6 +77,23 @@ export function runTaskContextCommand(args, store) {
67
77
  `Task context: ${task.id}`,
68
78
  `Title: ${compactText(task.title)}`,
69
79
  `Status: ${task.status}`,
80
+ "Next action:",
81
+ ` ${nextAction.kind}: ${nextAction.reason}`,
82
+ ...(nextAction.refs.length === 0
83
+ ? []
84
+ : [` Refs: ${nextAction.refs.map(({ kind, id }) => `${kind} ${id}`).join(", ")}`]),
85
+ ...(nextAction.recommendedCommand === undefined
86
+ ? []
87
+ : [` Recommended: ${nextAction.recommendedCommand}`]),
88
+ ...(nextAction.judgmentRequired === undefined
89
+ ? []
90
+ : [` Judgment: ${nextAction.judgmentRequired}`]),
91
+ ...(nextAction.alternatives === undefined || nextAction.alternatives.length === 0
92
+ ? []
93
+ : [
94
+ " Alternatives:",
95
+ ...nextAction.alternatives.map((alternative) => ` ${alternative.kind}: ${alternative.reason}`)
96
+ ]),
70
97
  ...(task.description === undefined ? [] : [`Description: ${compactText(task.description)}`]),
71
98
  ...(task.priority === undefined ? [] : [`Priority: ${task.priority}`]),
72
99
  ...(task.tags === undefined ? [] : [`Tags: ${task.tags.join(", ")}`]),
@@ -261,7 +261,7 @@ function assertInputCancelOrigin(store, request, environment) {
261
261
  }
262
262
  return "leader";
263
263
  }
264
- function isCurrentGlobalOperator(store, environment) {
264
+ export function isCurrentGlobalOperator(store, environment) {
265
265
  if (environment.YUI_SESSION_SCOPE !== "global"
266
266
  || environment.YUI_ROLE !== "operator"
267
267
  || environment.YUI_TASK_ID !== undefined)
@@ -3,23 +3,27 @@ import { resolveProject } from "../repository/project.js";
3
3
  import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
4
4
  import { usageError } from "../errors/cliError.js";
5
5
  import { defaultTableWidth, renderTable } from "../output/table.js";
6
- import { createIntegrationAttempt, recordResolutionDecision, updateIntegrationAttempt } from "../integration/integrationAttempt.js";
6
+ import { createIntegrationAttempt, recordResolutionDecision, supersedeIntegration, updateIntegrationAttempt } from "../integration/integrationAttempt.js";
7
7
  import { GitIntegrationService } from "../integration/gitIntegrationService.js";
8
+ import { runTaskIntegrationQueueCommand } from "./taskIntegrationQueueCommands.js";
8
9
  import { taskActor } from "./taskActor.js";
9
10
  import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
11
+ import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
10
12
  export async function runTaskIntegrationCommand(args, store, home, options = {}) {
11
13
  const now = options.now ?? (() => new Date());
12
14
  const [command, ...rest] = args;
13
15
  if (command === "start")
14
- return start(rest, store, home, now, options.environment);
16
+ return start(rest, store, home, now, options);
15
17
  if (command === "continue") {
16
- return continueIntegration(rest, store, home, now, options.environment);
18
+ return continueIntegration(rest, store, home, now, options);
17
19
  }
18
20
  if (command === "resolve") {
19
21
  return resolveDecision(rest, store, now, options.environment);
20
22
  }
21
23
  if (command === "abort")
22
- return abortIntegration(rest, store, now(), options.environment);
24
+ return abortIntegration(rest, store, now(), options);
25
+ if (command === "supersede")
26
+ return supersedeIntegrationCommand(rest, store, now(), options.environment);
23
27
  if (command === "cleanup") {
24
28
  return cleanupIntegration(rest, store, home, options.environment);
25
29
  }
@@ -27,6 +31,9 @@ export async function runTaskIntegrationCommand(args, store, home, options = {})
27
31
  return list(rest, store);
28
32
  if (command === "show")
29
33
  return show(rest, store, options.environment);
34
+ if (command === "queue") {
35
+ return runTaskIntegrationQueueCommand(rest, store, home, options);
36
+ }
30
37
  throw usageError(command === undefined
31
38
  ? "Task Integration command is required."
32
39
  : `Unknown command: task integration ${command}`);
@@ -36,9 +43,24 @@ async function cleanupIntegration(args, store, home, environment) {
36
43
  throw usageError("Task Integration cleanup usage: yui task integration cleanup <task>/<integration>.");
37
44
  }
38
45
  const integration = requireIntegration(store, args[0], environment);
39
- if (integration.status !== "committed" && integration.status !== "failed") {
46
+ if (integration.status !== "committed"
47
+ && integration.status !== "superseded"
48
+ && integration.status !== "failed") {
40
49
  throw usageError(`Integration is not terminal: ${integration.id}/${integration.status}.`);
41
50
  }
51
+ // rr4/finding-5: An Integration Attempt with an active DurableJob cannot be
52
+ // cleaned up — the runner may still be using its worktree. Block on queued,
53
+ // running, and unacknowledged unknown-needs-attention jobs owned by it.
54
+ const activeIntegrationJob = store.listDurableJobs(integration.taskId).find((job) => (job.owner.kind === "integration-attempt"
55
+ && job.owner.integrationAttemptId === integration.id
56
+ && (job.status === "queued"
57
+ || job.status === "running"
58
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
59
+ if (activeIntegrationJob !== undefined) {
60
+ throw usageError(`Integration ${integration.id} has an active DurableJob: `
61
+ + `${activeIntegrationJob.id}/${activeIntegrationJob.status}. `
62
+ + "Cancel or acknowledge it before cleanup.");
63
+ }
42
64
  const result = await new GitIntegrationService(home, store).cleanup(integration);
43
65
  if (result === "dirty") {
44
66
  throw usageError(`Integration worktree contains unresolved or uncommitted changes: ${integration.id}. Inspect it before cleanup.`);
@@ -50,7 +72,7 @@ async function cleanupIntegration(args, store, home, environment) {
50
72
  data: { integrationId: integration.id, cleanup: result }
51
73
  };
52
74
  }
53
- async function start(args, store, home, now, environment) {
75
+ async function start(args, store, home, now, options) {
54
76
  const usage = "Task Integration start usage: yui task integration start <task> [--project <project>] --change-set <id> [--change-set <id> ...] [--target <ref>] [--check <command> ...].";
55
77
  const parsed = parseRepeatable(args, new Set(["--change-set", "--check"]), new Set(["--project", "--target"]), usage);
56
78
  if (parsed.positionals.length !== 1)
@@ -70,9 +92,15 @@ async function start(args, store, home, now, environment) {
70
92
  throw usageError(`ChangeSet not found: ${id}.`);
71
93
  return changeSet;
72
94
  });
73
- const reviewEvidence = new Set(store.listReviewRounds(task.id)
74
- .flatMap(({ evidenceCommit }) => evidenceCommit === undefined ? [] : [evidenceCommit]));
75
- const reviewSource = changeSets.find(({ headCommit }) => reviewEvidence.has(headCommit));
95
+ // Only diagnostic evidence commits (a reviewer's own commit on top of the
96
+ // frozen base) are barred from becoming an Integration source. A clean
97
+ // review attests the frozen base itself (evidenceCommit === reviewBaseCommit),
98
+ // which is the candidate's own head and a legitimate source.
99
+ const diagnosticEvidence = new Set(store.listReviewRounds(task.id)
100
+ .flatMap(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit
101
+ ? [evidenceCommit]
102
+ : []));
103
+ const reviewSource = changeSets.find(({ headCommit }) => diagnosticEvidence.has(headCommit));
76
104
  if (reviewSource !== undefined) {
77
105
  throw usageError(`ReviewRound evidence commit cannot become an Integration source: ${reviewSource.id}.`);
78
106
  }
@@ -101,7 +129,16 @@ async function start(args, store, home, now, environment) {
101
129
  throw usageError(`Task main worktree is not ready; reconcile the Task first: ${task.id}.`);
102
130
  }
103
131
  const expectedHead = (await new NodeGitWorkspace().inspect(project.path, targetRef)).baseCommit;
132
+ // The duplicate/budget preflight runs inside the same transaction as the
133
+ // attempt insert: on the single-writer SQLite backend the check and the
134
+ // record creation are atomic, so a concurrent Leader cannot sneak a
135
+ // duplicate Integration between the guard and the write.
104
136
  const integration = store.transaction((tx) => {
137
+ const guard = runDeliveryGuardPreflight(tx, task.id, {
138
+ kind: "integration-start",
139
+ projectId: project.id,
140
+ changeSetIds
141
+ }, { environment: options.environment, budget: true });
105
142
  const created = createIntegrationAttempt({
106
143
  id: tx.nextIntegrationAttemptId(task.id),
107
144
  taskId: task.id,
@@ -112,32 +149,38 @@ async function start(args, store, home, now, environment) {
112
149
  checkCommands: parsed.many.get("--check") ?? []
113
150
  }, now());
114
151
  tx.saveIntegrationAttempt(task.id, created);
115
- return created;
152
+ return { attempt: created, guard };
116
153
  });
117
- return runIntegration(store, home, integration, now, environment);
154
+ const result = await runIntegration(store, home, integration.attempt, now, options);
155
+ return {
156
+ ...result,
157
+ output: withGuardWarnings(integration.guard, result.output)
158
+ };
118
159
  }
119
- async function continueIntegration(args, store, home, now, environment) {
160
+ async function continueIntegration(args, store, home, now, options) {
120
161
  const usage = "Task Integration continue usage: yui task integration continue <task>/<integration>.";
121
162
  const parsed = parseRepeatable(args, new Set(), new Set(), usage);
122
163
  if (parsed.positionals.length !== 1)
123
164
  throw usageError(usage);
124
- const integration = requireIntegration(store, parsed.positionals[0], environment);
165
+ const integration = requireIntegration(store, parsed.positionals[0], options.environment);
125
166
  requireActiveIntegrationTask(store, integration);
126
167
  if (integration.status !== "validating"
168
+ && integration.status !== "running"
127
169
  && (integration.status !== "blocked"
128
170
  || integration.resolution?.action !== "manual-resolution")) {
129
171
  throw usageError(`Integration is not ready to continue: ${integration.id}/${integration.status}.`);
130
172
  }
131
- return runIntegration(store, home, integration, now, environment);
173
+ return runIntegration(store, home, integration, now, options);
132
174
  }
133
- async function runIntegration(store, home, integration, now, environment) {
134
- const result = await new GitIntegrationService(home, store, undefined, now, environment)
135
- .integrate(integration.taskId, integration.id);
175
+ async function runIntegration(store, home, integration, now, options) {
176
+ const result = await new GitIntegrationService(home, store, undefined, now, options.environment, undefined, options.jobPort).integrate(integration.taskId, integration.id);
136
177
  const output = result.status === "committed"
137
178
  ? `Integrated ${result.attempt.changeSetIds.join(", ")} into ${result.attempt.targetRef} with CAS (${result.attempt.id})\n`
138
179
  : result.status === "blocked"
139
180
  ? `Integration ${result.attempt.id} requires a Leader resolution decision in ${result.workspace.path}\n`
140
- : `Integration ${result.attempt.id} failed; target ref was not advanced\n`;
181
+ : result.status === "checks-running"
182
+ ? `Integration ${result.attempt.id} checks are running as DurableJob ${result.job.id}; run 'yui task integration continue ${result.attempt.taskId}/${result.attempt.id}' when the job finishes\n`
183
+ : `Integration ${result.attempt.id} failed; target ref was not advanced\n`;
141
184
  return { output, data: result };
142
185
  }
143
186
  function resolveDecision(args, store, now, environment) {
@@ -166,12 +209,12 @@ function resolveDecision(args, store, now, environment) {
166
209
  data: { integration: resolved }
167
210
  };
168
211
  }
169
- function abortIntegration(args, store, now, environment) {
212
+ async function abortIntegration(args, store, now, options) {
170
213
  const usage = "Task Integration abort usage: yui task integration abort <task>/<integration> --reason <text>.";
171
214
  const parsed = parseRepeatable(args, new Set(), new Set(["--reason"]), usage);
172
215
  if (parsed.positionals.length !== 1)
173
216
  throw usageError(usage);
174
- const integration = requireIntegration(store, parsed.positionals[0], environment);
217
+ const integration = requireIntegration(store, parsed.positionals[0], options.environment);
175
218
  requireActiveIntegrationTask(store, integration);
176
219
  if (integration.status !== "running" && integration.status !== "blocked") {
177
220
  throw usageError(`Integration cannot be aborted from ${integration.status}: ${integration.id}.`);
@@ -179,18 +222,77 @@ function abortIntegration(args, store, now, environment) {
179
222
  const reason = parsed.one.get("--reason");
180
223
  if (reason === undefined)
181
224
  throw usageError(usage);
182
- const aborted = updateIntegrationAttempt(integration, {
183
- status: "failed",
184
- checks: [
185
- ...(integration.checks ?? []),
186
- { name: "aborted", outcome: "failed", details: reason }
187
- ]
188
- }, now);
189
- store.saveIntegrationAttempt(aborted.taskId, aborted);
190
- return {
191
- output: `Aborted Integration ${aborted.id}; start a new Integration Attempt to retry\n`,
192
- data: { integration: aborted }
193
- };
225
+ if (integration.jobId !== undefined && options.jobPort !== undefined) {
226
+ await options.jobPort.cancelJob(integration.taskId, integration.jobId);
227
+ }
228
+ return store.transaction((tx) => {
229
+ // Re-read inside the transaction: a concurrent `continue` can advance the
230
+ // Attempt to validating (and then commit the target) after the initial
231
+ // read. Aborting a validating or committed Attempt would leave the
232
+ // target advanced while the Attempt is failed.
233
+ const current = tx.getIntegrationAttempt(integration.taskId, integration.id);
234
+ if (current === null) {
235
+ throw usageError(`Integration Attempt not found: ${integration.taskId}/${integration.id}.`);
236
+ }
237
+ if (current.status !== "running" && current.status !== "blocked") {
238
+ throw usageError(`Integration cannot be aborted from ${current.status}: ${current.id}.`);
239
+ }
240
+ const aborted = updateIntegrationAttempt(current, {
241
+ status: "failed",
242
+ checks: [
243
+ ...(current.checks ?? []),
244
+ { name: "aborted", outcome: "failed", details: reason }
245
+ ]
246
+ }, now);
247
+ tx.saveIntegrationAttempt(aborted.taskId, aborted);
248
+ return {
249
+ output: `Aborted Integration ${aborted.id}; start a new Integration Attempt to retry\n`,
250
+ data: { integration: aborted }
251
+ };
252
+ });
253
+ }
254
+ function supersedeIntegrationCommand(args, store, now, environment) {
255
+ const usage = "Task Integration supersede usage: yui task integration supersede <task>/<integration> --reason <text>.";
256
+ const parsed = parseRepeatable(args, new Set(), new Set(["--reason"]), usage);
257
+ if (parsed.positionals.length !== 1)
258
+ throw usageError(usage);
259
+ const integration = requireIntegration(store, parsed.positionals[0], environment);
260
+ requireActiveIntegrationTask(store, integration);
261
+ if (integration.status !== "committed") {
262
+ throw usageError(`Integration cannot be superseded from ${integration.status}: ${integration.id}.`);
263
+ }
264
+ const reason = parsed.one.get("--reason");
265
+ if (reason === undefined)
266
+ throw usageError(usage);
267
+ // Only the Task Leader (or Operator/user) may supersede a committed
268
+ // Integration: it rewrites delivery-baseline evidence and audit history.
269
+ taskActor(environment, integration.taskId);
270
+ // A queue-backed committed Attempt cannot be superseded: the queue entry
271
+ // would remain in its current status while its Attempt becomes "superseded",
272
+ // leaving contradictory terminal records that never converge. This covers
273
+ // the crash window where the entry is still "running" or "conflicted" after
274
+ // the Attempt committed.
275
+ const queueBacked = store.listIntegrationQueueEntries(integration.taskId)
276
+ .some((entry) => entry.integrationAttemptId === integration.id);
277
+ if (queueBacked) {
278
+ throw usageError(`Integration ${integration.id} is backed by a queue entry; `
279
+ + "reconcile the queue entry instead of superseding its Attempt.");
280
+ }
281
+ return store.transaction((tx) => {
282
+ const current = tx.getIntegrationAttempt(integration.taskId, integration.id);
283
+ if (current === null) {
284
+ throw usageError(`Integration Attempt not found: ${integration.taskId}/${integration.id}.`);
285
+ }
286
+ if (current.status !== "committed") {
287
+ throw usageError(`Integration cannot be superseded from ${current.status}: ${current.id}.`);
288
+ }
289
+ const superseded = supersedeIntegration(current, reason, now);
290
+ tx.saveIntegrationAttempt(superseded.taskId, superseded);
291
+ return {
292
+ output: `Superseded Integration ${superseded.id}; the next valid committed Integration is now the delivery baseline\n`,
293
+ data: { integration: superseded }
294
+ };
295
+ });
194
296
  }
195
297
  function requireActiveIntegrationTask(store, integration) {
196
298
  const task = store.getTask(integration.taskId);
@@ -237,6 +339,7 @@ function show(args, store, environment) {
237
339
  `Target: ${integration.targetRef}`,
238
340
  `Expected head: ${integration.expectedHead}`,
239
341
  `Candidate: ${integration.candidateCommit ?? "-"}`,
342
+ `Job: ${integration.jobId ?? "-"}`,
240
343
  `ChangeSets: ${integration.changeSetIds.join(", ")}`,
241
344
  `Status: ${integration.status}`,
242
345
  `Conflict: ${integration.conflict?.summary ?? "-"}`,
@@ -273,7 +376,7 @@ function requireIntegration(store, value, environment) {
273
376
  }
274
377
  return attempt;
275
378
  }
276
- function parseRepeatable(args, repeatable, singular, usage) {
379
+ export function parseRepeatable(args, repeatable, singular, usage) {
277
380
  const positionals = [];
278
381
  const many = new Map();
279
382
  const one = new Map();