@zq-silk/yui 0.5.3 → 0.6.1

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 (157) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/agentConfigurationPicker.js +1 -1
  4. package/dist/cli/commandCatalog.js +251 -13
  5. package/dist/cli/updateOrchestrator.js +8 -0
  6. package/dist/cli/updatePorts.js +76 -22
  7. package/dist/cli.js +264 -20
  8. package/dist/commands/configCommands.js +83 -9
  9. package/dist/commands/controllerCommands.js +103 -0
  10. package/dist/commands/deliveryGuardPreflight.js +35 -0
  11. package/dist/commands/durableJobCommands.js +231 -0
  12. package/dist/commands/executionAuditCommands.js +193 -0
  13. package/dist/commands/grantCommands.js +374 -0
  14. package/dist/commands/projectCommands.js +119 -81
  15. package/dist/commands/releaseCommands.js +444 -0
  16. package/dist/commands/resourcesCommands.js +274 -0
  17. package/dist/commands/sessionCommands.js +104 -0
  18. package/dist/commands/taskActor.js +117 -0
  19. package/dist/commands/taskChangeSetCommands.js +60 -0
  20. package/dist/commands/taskCommands.js +610 -201
  21. package/dist/commands/taskCompletionGate.js +78 -1
  22. package/dist/commands/taskContextCommand.js +24 -6
  23. package/dist/commands/taskInputCommands.js +1 -1
  24. package/dist/commands/taskIntegrationCommands.js +136 -33
  25. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  26. package/dist/commands/taskNextActionCommand.js +85 -0
  27. package/dist/commands/taskOverlapCommands.js +120 -0
  28. package/dist/commands/taskOverviewCommand.js +36 -8
  29. package/dist/commands/telemetryCommands.js +330 -0
  30. package/dist/commands/workflowCommands.js +415 -0
  31. package/dist/config/yuiConfig.js +60 -0
  32. package/dist/controller/clientRuntime.js +42 -1
  33. package/dist/controller/controller.js +413 -61
  34. package/dist/controller/controllerMain.js +25 -2
  35. package/dist/controller/domainIdentity.js +16 -8
  36. package/dist/controller/ephemeralResourceReaper.js +2 -1
  37. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  38. package/dist/controller/handoverCandidate.js +168 -0
  39. package/dist/controller/jobClient.js +102 -0
  40. package/dist/controller/jobControl.js +613 -0
  41. package/dist/controller/jobSupervisor.js +498 -0
  42. package/dist/controller/providerHookRunFence.js +34 -5
  43. package/dist/controller/resourceCleanupLinux.js +18 -9
  44. package/dist/controller/resourceInventoryLinux.js +90 -39
  45. package/dist/controller/resourceInventoryRpc.js +85 -0
  46. package/dist/controller/resourceInventoryWorker.js +50 -0
  47. package/dist/controller/runtime.js +238 -22
  48. package/dist/controller/runtimeEventInbox.js +234 -57
  49. package/dist/controller/runtimeEventProcessor.js +549 -42
  50. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  51. package/dist/core/boundedRpc.js +475 -0
  52. package/dist/core/controllerServer.js +416 -27
  53. package/dist/core/controllerTelemetry.js +167 -0
  54. package/dist/doctor/doctor.js +113 -16
  55. package/dist/domain/validation.js +9 -0
  56. package/dist/execution/executionGroup.js +40 -3
  57. package/dist/executor/agentExecutor.js +6 -3
  58. package/dist/executor/effectiveLaunch.js +52 -0
  59. package/dist/executor/executorRegistry.js +50 -0
  60. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  61. package/dist/grant/capabilityGrant.js +282 -0
  62. package/dist/integration/changeSet.js +16 -3
  63. package/dist/integration/changeSetManifest.js +46 -0
  64. package/dist/integration/gitIntegrationService.js +528 -147
  65. package/dist/integration/integrationAttempt.js +54 -5
  66. package/dist/integration/integrationQueueEntry.js +221 -0
  67. package/dist/integration/integrationQueueService.js +955 -0
  68. package/dist/integration/manifestTags.js +99 -0
  69. package/dist/integration/overlapDiagnostics.js +211 -0
  70. package/dist/job/durableJob.js +449 -0
  71. package/dist/job/jobRunner.js +350 -0
  72. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  73. package/dist/lifecycle/providerErrorClass.js +126 -0
  74. package/dist/message/message.js +16 -3
  75. package/dist/observability/executionAudit.js +545 -0
  76. package/dist/observability/faultClassification.js +160 -0
  77. package/dist/observability/runtimeIdentity.js +367 -0
  78. package/dist/release/fakeReleasePorts.js +55 -0
  79. package/dist/release/releaseHandover.js +475 -0
  80. package/dist/release/releaseIdempotencyStore.js +165 -0
  81. package/dist/release/releaseWorkflow.js +459 -0
  82. package/dist/release/releaseWorkflowEngine.js +688 -0
  83. package/dist/release/releaseWorkflowPorts.js +1720 -0
  84. package/dist/release/runtimeRelease.js +495 -0
  85. package/dist/release/workflowFileLock.js +218 -0
  86. package/dist/repository/gitWorkspace.js +177 -1
  87. package/dist/repository/projectMaintenanceLock.js +315 -0
  88. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  89. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  90. package/dist/resources/autoResourceGc.js +116 -0
  91. package/dist/resources/liveReferences.js +574 -0
  92. package/dist/resources/resourceDiscovery.js +477 -0
  93. package/dist/resources/resourceGc.js +645 -0
  94. package/dist/resources/resourceRegistrar.js +256 -0
  95. package/dist/resources/resourceRegistry.js +150 -0
  96. package/dist/resources/resourceRegistryStore.js +41 -0
  97. package/dist/resources/resourceTypes.js +42 -0
  98. package/dist/resources/sqliteResourceRegistry.js +111 -0
  99. package/dist/review/reviewConfig.js +10 -0
  100. package/dist/review/reviewFinding.js +240 -0
  101. package/dist/review/reviewFindingLedger.js +545 -0
  102. package/dist/review/reviewOutcomeClassifier.js +61 -0
  103. package/dist/review/reviewRound.js +56 -4
  104. package/dist/run/agentRun.js +80 -4
  105. package/dist/run/providerRetry.js +84 -0
  106. package/dist/run/providerRetryConfig.js +63 -0
  107. package/dist/run/yieldReceipt.js +65 -0
  108. package/dist/runtime/exactControlPlane.js +79 -2
  109. package/dist/runtime/index.js +4 -0
  110. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  111. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  112. package/dist/runtime/sessionReconciliation.js +93 -0
  113. package/dist/runtime/sessionTerminationGuard.js +211 -0
  114. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  115. package/dist/runtime/tmuxAdapters.js +34 -1
  116. package/dist/scheduler/actionability.js +155 -0
  117. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  118. package/dist/scheduler/activeTaskProgress.js +60 -0
  119. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  120. package/dist/scheduler/roleRunStall.js +135 -29
  121. package/dist/scheduler/taskExecutionProjection.js +11 -0
  122. package/dist/setup/setupCommand.js +27 -4
  123. package/dist/storage/compatibleTaskStore.js +112 -5
  124. package/dist/storage/migration/productionRegistry.js +769 -1
  125. package/dist/storage/persistenceWorker.js +194 -0
  126. package/dist/storage/sqliteSchema.js +705 -0
  127. package/dist/storage/sqliteStore.js +1695 -0
  128. package/dist/storage/storageVersions.js +9 -2
  129. package/dist/storage/storeRpc.js +298 -0
  130. package/dist/storage/taskStore.js +982 -21
  131. package/dist/storage/upgrade/homeClassification.js +157 -12
  132. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  133. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  134. package/dist/storage/upgrade/recordVersions.js +10 -1
  135. package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
  136. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  137. package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
  138. package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
  139. package/dist/task/deliveryGuard.js +226 -0
  140. package/dist/task/nextAction.js +343 -0
  141. package/dist/task/repairWave.js +137 -0
  142. package/dist/task/taskRecordReference.js +6 -1
  143. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  144. package/dist/telemetry/telemetryCompaction.js +251 -0
  145. package/dist/telemetry/telemetryConfig.js +64 -0
  146. package/dist/telemetry/telemetryRouter.js +32 -0
  147. package/dist/telemetry/telemetryStore.js +19 -0
  148. package/dist/telemetry/telemetryWiring.js +33 -0
  149. package/dist/tmux/tmuxManager.js +20 -1
  150. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  151. package/dist/verification/gateArtifact.js +216 -0
  152. package/dist/verification/gateArtifactStore.js +87 -0
  153. package/dist/verification/verificationGateService.js +414 -0
  154. package/dist/verification/verificationPlan.js +308 -0
  155. package/dist/workspace/gitChangeSetCapture.js +12 -2
  156. package/dist/workspace/workItemChangeSetManager.js +60 -3
  157. package/package.json +2 -1
@@ -15,7 +15,11 @@ import { recoverExactAgentRun, terminalizeExactTaskRun, validateExactRunReviewRo
15
15
  import { resetTaskRoleSessionGeneration } from "../lifecycle/taskRoleSessionReset.js";
16
16
  import { copyGlobalRoleToTaskRole, createRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateRole, updateRoleStatus } from "../role/role.js";
17
17
  import { createAgentRun } from "../run/agentRun.js";
18
- import { createReviewRound, createTaskReviewRound, attachReviewExecutionGroup, finishReviewRound, parseReviewYieldReport, recordReviewWorkspaceDisposition, startReviewRound, updateReviewExecutionGroup, validateTaskReviewCandidate } from "../review/reviewRound.js";
18
+ import { matchYieldReceipt } from "../run/yieldReceipt.js";
19
+ import { providerRetryConfig } from "../run/providerRetryConfig.js";
20
+ import { createReviewRound, createTaskReviewRound, attachReviewExecutionGroup, finishReviewRound, parseReviewYieldReport, recordReviewWorkspaceDisposition, retryTaskReviewRound, startReviewRound, updateReviewExecutionGroup, validateTaskReviewCandidate } from "../review/reviewRound.js";
21
+ import { blockingOpenFindings, buildTaskFinalReviewFindingContext, completionGateBlocked, dispositionReviewFinding, planRepairGroups, reconcileReviewFindings, reconcileReviewFindingsAfterReview, reviewFindingLedgerWriteFailed } from "../review/reviewFindingLedger.js";
22
+ import { LEADER_FINDING_DISPOSITIONS } from "../review/reviewFinding.js";
19
23
  import { markYuiRunInput, retagYuiRunInput } from "../run/runIdentity.js";
20
24
  import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
21
25
  import { createTaskBrief, updateTaskBrief } from "../brief/taskBrief.js";
@@ -26,6 +30,7 @@ import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runt
26
30
  import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, updateTaskMetadata } from "../task/task.js";
27
31
  import { formatAgentRunReceiptId, resolveTaskRecordReference } from "../task/taskRecordReference.js";
28
32
  import { resolveProject } from "../repository/project.js";
33
+ import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
29
34
  import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
30
35
  import { addExecutionLane, createExecutionGroup, resolveExecutionGroup, restartExecutionLane, updateExecutionLane } from "../execution/executionGroup.js";
31
36
  import { sameTaskFinalReviewContract, taskFinalReviewConfig, validateTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
@@ -34,10 +39,17 @@ import { hasAgentConfigOptions, parseRoleOptions, patchRoleAgentBinding, roleOpt
34
39
  import { hasRoleLaunchContextOptions, validateConfiguredRoleSkills } from "./roleSkillValidation.js";
35
40
  import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
36
41
  import { runTaskContextCommand } from "./taskContextCommand.js";
42
+ import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
43
+ import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
37
44
  import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel } from "./taskRoleRuntimeStatus.js";
38
45
  import { assertNoOpenInputRequests, openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
46
+ import { runGrantCommand } from "./grantCommands.js";
47
+ import { runWorkflowCommand } from "./workflowCommands.js";
39
48
  import { taskActor as resolveTaskActor, taskLeaderActionRunId } from "./taskActor.js";
40
49
  import { createTaskTerminalNotification } from "../scheduler/operatorNotification.js";
50
+ import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
51
+ import { collectTaskActionability, computeActionabilityDigest, deriveLeaderRunDisposition } from "../scheduler/actionability.js";
52
+ import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
41
53
  import { buildTaskOverview, parseTaskListOptions, renderTaskOverview } from "./taskOverviewCommand.js";
42
54
  const LEADER_ROLE = "leader";
43
55
  /** Exact Task-final identity or evidence changed between queueing and dispatch. */
@@ -140,6 +152,12 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
140
152
  if (incompleteWork !== undefined) {
141
153
  throw usageError(`Task ${task.id} has unaccepted work: ${incompleteWork.id}/${incompleteWork.status}.`);
142
154
  }
155
+ const activeJob = store.listDurableJobs(task.id).find((job) => (job.status === "queued"
156
+ || job.status === "running"
157
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
158
+ if (activeJob !== undefined) {
159
+ throw usageError(`Task ${task.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
160
+ }
143
161
  if (task.requireIntegration) {
144
162
  if (store.listWorkItems(task.id).length === 0) {
145
163
  throw usageError(`Task ${task.id} requires at least one WorkItem before completion.`);
@@ -157,6 +175,11 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
157
175
  if (unresolvedIntegration !== undefined) {
158
176
  throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
159
177
  }
178
+ const unsettledQueueEntry = store.listIntegrationQueueEntries(task.id).find((entry) => (entry.status !== "committed" && entry.status !== "superseded"));
179
+ if (unsettledQueueEntry !== undefined) {
180
+ throw usageError(`Task ${task.id} has an unsettled integration queue entry: `
181
+ + `${unsettledQueueEntry.id}/${unsettledQueueEntry.status}.`);
182
+ }
160
183
  const isolatedWorkspace = store.listManagedWorkspaces(task.id)
161
184
  .find(({ owner }) => owner.type === "work-item");
162
185
  if (isolatedWorkspace?.owner.type === "work-item") {
@@ -204,6 +227,7 @@ export function runTaskCommand(args, store, options = {}) {
204
227
  case "list": return listTaskCommand(rest, store);
205
228
  case "show": return showTaskCommand(rest, store);
206
229
  case "context": return runTaskContextCommand(rest, store);
230
+ case "next-action": return runTaskNextActionCommand(rest, store);
207
231
  case "activate": return output(activateTaskCommand(rest, store, options));
208
232
  case "complete": return completeTaskCommand(rest, store, options);
209
233
  case "reopen": return output(reopenTaskCommand(rest, store, options));
@@ -211,8 +235,11 @@ export function runTaskCommand(args, store, options = {}) {
211
235
  case "retire": return retireTaskCommand(rest, store, options);
212
236
  case "reconcile": return output(reconcileTaskCommand(rest, store, options));
213
237
  case "message": return output(taskMessageCommand(rest, store, options));
238
+ case "wake": return output(taskWakeCommand(rest, store, options));
214
239
  case "project": return taskProjectCommand(rest, store, options);
215
240
  case "input": return runTaskInputCommand(rest, store, options);
241
+ case "grant": return runGrantCommand(rest, store, options);
242
+ case "workflow": return runWorkflowCommand(rest, store, options);
216
243
  case "role": return taskRoleCommand(rest, store, options);
217
244
  case "work": return taskWorkCommand(rest, store, options);
218
245
  case "review": return taskReviewCommand(rest, store, options);
@@ -691,6 +718,22 @@ function completeTaskCommand(args, store, options) {
691
718
  terminalizedLeaderRun
692
719
  };
693
720
  }
721
+ // Issue 06: under `review.findingLedger=enforce`, Task completion fails
722
+ // closed on undispositioned open P1/P2 findings. Run this after final-review
723
+ // preparation so `fixed-pending-review` findings can still request the one
724
+ // re-review for a changed frozen head; once no Review is needed, the gate
725
+ // blocks final completion.
726
+ if (completionGateBlocked(tx, task.id)) {
727
+ const blocking = blockingOpenFindings(tx, task.id);
728
+ if (reviewFindingLedgerWriteFailed(tx, task.id)) {
729
+ throw usageError(`Task ${task.id} cannot complete: the Review finding ledger was unavailable `
730
+ + "while reconciling a semantic Review. Recover the ledger and reconcile the Round "
731
+ + "before completing the Task.");
732
+ }
733
+ throw usageError(`Task ${task.id} has ${blocking.length} undispositioned open P1/P2 finding(s): `
734
+ + `${blocking.map(({ id }) => id).join(", ")}. `
735
+ + "Disposition each finding (yui task review finding dispose) before completing the Task.");
736
+ }
694
737
  const completed = completeTask(task, now, { by: actor, summary });
695
738
  tx.saveTask(completed);
696
739
  tx.clearPendingWakeup(task.id);
@@ -745,29 +788,45 @@ function completeTaskCommand(args, store, options) {
745
788
  function reopenTaskCommand(args, store, options) {
746
789
  exactPositionals(args, 1, "Task reopen usage: yui task reopen <id>.");
747
790
  const now = clock(options);
748
- const result = store.transaction((tx) => {
749
- const task = requireTask(tx, args[0]);
750
- if (task.status === "active")
751
- return { task, changed: false };
752
- if (task.status === "archived")
753
- throw usageError(`Task is archived: ${task.id}.`);
754
- if (task.status !== "completed")
755
- throw usageError(`Task is not completed: ${task.id}.`);
756
- const active = reopenTask(task, now);
757
- tx.saveTask(active);
758
- tx.clearOperatorNotification(task.id);
759
- enqueueWork(tx, leaderMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
760
- enqueueWork(tx, taskMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
761
- recordTaskEvent(tx, task.id, "task.reopened", { status: active.status }, now);
762
- return { task: active, changed: true };
763
- });
764
- if (result.changed) {
765
- notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
766
- notifyMailbox(options.runtime, leaderMailbox(result.task.id), result.task.id);
791
+ // Reopen changes the Task status that archiveLegacyTaskRefs reads before
792
+ // deleting the legacy ref. Take the same per-Project maintenance fence so a
793
+ // reopen cannot commit between archive's status read and its ref deletion:
794
+ // the two operations are mutually exclusive per Project.
795
+ const existing = store.getTask(args[0]);
796
+ const projectIds = existing === null
797
+ ? []
798
+ : existing.projectBindings.map(({ projectId }) => projectId);
799
+ const release = options.yuiHome === undefined || projectIds.length === 0
800
+ ? () => { }
801
+ : acquireProjectMaintenanceLocks(options.yuiHome, projectIds);
802
+ try {
803
+ const result = store.transaction((tx) => {
804
+ const task = requireTask(tx, args[0]);
805
+ if (task.status === "active")
806
+ return { task, changed: false };
807
+ if (task.status === "archived")
808
+ throw usageError(`Task is archived: ${task.id}.`);
809
+ if (task.status !== "completed")
810
+ throw usageError(`Task is not completed: ${task.id}.`);
811
+ const active = reopenTask(task, now);
812
+ tx.saveTask(active);
813
+ tx.clearOperatorNotification(task.id);
814
+ enqueueWork(tx, leaderMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
815
+ enqueueWork(tx, taskMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
816
+ recordTaskEvent(tx, task.id, "task.reopened", { status: active.status }, now);
817
+ return { task: active, changed: true };
818
+ });
819
+ if (result.changed) {
820
+ notifyMailbox(options.runtime, taskMailbox(result.task.id), result.task.id);
821
+ notifyMailbox(options.runtime, leaderMailbox(result.task.id), result.task.id);
822
+ }
823
+ return result.changed
824
+ ? `Reopened task ${result.task.id}\n`
825
+ : `Task ${result.task.id} is already active\n`;
826
+ }
827
+ finally {
828
+ release();
767
829
  }
768
- return result.changed
769
- ? `Reopened task ${result.task.id}\n`
770
- : `Task ${result.task.id} is already active\n`;
771
830
  }
772
831
  function archiveTaskCommand(args, store, options) {
773
832
  const request = validateTaskArchiveRequest(args, store, options);
@@ -781,9 +840,6 @@ function archiveTaskCommand(args, store, options) {
781
840
  && task.status !== "retired") {
782
841
  throw usageError(`Task ${task.id} must be completed or retired before it can be archived.`);
783
842
  }
784
- if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
785
- throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
786
- }
787
843
  assertNoOpenInputRequests(tx, task.id, "archiving the Task");
788
844
  const unresolvedIntegration = tx.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
789
845
  || integration.status === "blocked"
@@ -791,6 +847,15 @@ function archiveTaskCommand(args, store, options) {
791
847
  if (unresolvedIntegration !== undefined) {
792
848
  throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
793
849
  }
850
+ const activeArchiveJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
851
+ || job.status === "running"
852
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
853
+ if (activeArchiveJob !== undefined) {
854
+ throw usageError(`Task ${task.id} has an active DurableJob: ${activeArchiveJob.id}/${activeArchiveJob.status}.`);
855
+ }
856
+ if (task.cwd !== undefined || tx.listManagedWorkspaces(task.id).length > 0) {
857
+ throw usageError(`Task ${task.id} still has managed worktrees; clean them before archiving.`);
858
+ }
794
859
  const activeRole = tx.listRoles(task.id)
795
860
  .find((role) => tx.getActiveAgentRun(task.id, role.name) !== null);
796
861
  if (activeRole !== undefined) {
@@ -851,6 +916,12 @@ function retireTaskCommand(args, store, options) {
851
916
  if (unresolvedIntegration !== undefined) {
852
917
  throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
853
918
  }
919
+ const activeRetireJob = tx.listDurableJobs(task.id).find((job) => (job.status === "queued"
920
+ || job.status === "running"
921
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
922
+ if (activeRetireJob !== undefined) {
923
+ throw usageError(`Task ${task.id} has an active DurableJob: ${activeRetireJob.id}/${activeRetireJob.status}.`);
924
+ }
854
925
  assertTaskRetirementProof(tx, task, options.taskRetirementProof);
855
926
  for (const run of tx.listAgentRuns(task.id).filter(({ status: runStatus }) => (runStatus === "active"))) {
856
927
  const terminal = terminalizeExactTaskRun(tx, {
@@ -982,11 +1053,22 @@ function reconcileTaskCommand(args, store, options) {
982
1053
  function taskMessageCommand(args, store, options) {
983
1054
  const [command, ...rest] = args;
984
1055
  if (command === "send") {
985
- const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->).";
986
- const parsed = parseTail(rest, new Set(["--body-file"]), usage);
1056
+ const usage = "Task message send usage: yui task message send <id> (<body>|--body-file <path|->) [--wake-policy leader|none].";
1057
+ const parsed = parseTail(rest, new Set(["--body-file", "--wake-policy"]), usage);
987
1058
  if (parsed.positionals.length < 1 || parsed.positionals.length > 2)
988
1059
  throw usageError(usage);
989
1060
  const body = readCommandText(parsed.positionals[1], parsed.options.get("--body-file"), "--body", usage);
1061
+ const wakePolicyRaw = parsed.options.get("--wake-policy");
1062
+ let wakePolicy;
1063
+ if (wakePolicyRaw === undefined) {
1064
+ wakePolicy = undefined;
1065
+ }
1066
+ else if (wakePolicyRaw === "leader" || wakePolicyRaw === "none") {
1067
+ wakePolicy = wakePolicyRaw;
1068
+ }
1069
+ else {
1070
+ throw usageError(`--wake-policy must be 'leader' or 'none': ${wakePolicyRaw}.`);
1071
+ }
990
1072
  const now = clock(options);
991
1073
  const result = store.transaction((tx) => {
992
1074
  const task = requireTask(tx, parsed.positionals[0]);
@@ -995,9 +1077,14 @@ function taskMessageCommand(args, store, options) {
995
1077
  const message = actor === "leader"
996
1078
  ? appendMessage(tx, task.id, body, "role-result", { type: "role", roleName: LEADER_ROLE }, now)
997
1079
  : actor === "operator"
998
- ? appendMessage(tx, task.id, body, "operator", { type: "operator" }, now)
999
- : appendMessage(tx, task.id, body, "user", { type: "user" }, now);
1000
- if (task.status === "active" && actor !== "leader") {
1080
+ ? appendMessage(tx, task.id, body, "operator", { type: "operator" }, now, { wakePolicy })
1081
+ : appendMessage(tx, task.id, body, "user", { type: "user" }, now, { wakePolicy });
1082
+ // Issue 05: only `wakePolicy=leader` (the default for backward
1083
+ // compatibility) enqueues Leader work. `wakePolicy=none` persists the
1084
+ // message as context without waking the Leader.
1085
+ if (task.status === "active"
1086
+ && actor !== "leader"
1087
+ && wakePolicy !== "none") {
1001
1088
  enqueueWork(tx, leaderMailbox(task.id), actor === "operator" ? "operator-input" : "user-message", now, [messageRef(task.id, message.id)]);
1002
1089
  }
1003
1090
  return { task, message, actor };
@@ -1032,6 +1119,30 @@ function taskMessageCommand(args, store, options) {
1032
1119
  ? "Task message command is required."
1033
1120
  : `Unknown command: task message ${command}`);
1034
1121
  }
1122
+ /**
1123
+ * Issue 05: force-wake escape hatch. Bypasses the actionability digest and
1124
+ * enqueues exactly one Leader wakeup with an auditable reason. The reason is
1125
+ * truncated to keep the event payload compact.
1126
+ */
1127
+ function taskWakeCommand(args, store, options) {
1128
+ const usage = "Task wake usage: yui task wake <id> --force --reason <text>.";
1129
+ const parsed = parseTail(args, new Set(["--reason"]), usage, new Set(["--force"]));
1130
+ exactPositionals(parsed.positionals, 1, usage);
1131
+ if (!parsed.options.has("--force")) {
1132
+ throw usageError("--force is required to wake a Task.", usage);
1133
+ }
1134
+ const reason = requiredOption(parsed.options, "--reason");
1135
+ const now = clock(options);
1136
+ const task = requireTask(store, parsed.positionals[0]);
1137
+ assertTaskOpen(task);
1138
+ const wakeReason = `force-wake:${truncateEventNote(reason)}`;
1139
+ store.transaction((tx) => {
1140
+ queueLeaderWakeup(tx, task.id, wakeReason, now);
1141
+ recordTaskEvent(tx, task.id, "task.wake-forced", { reason: wakeReason }, now);
1142
+ });
1143
+ notifyMailbox(options.runtime, leaderMailbox(task.id), task.id);
1144
+ return `Woke ${task.id} (${wakeReason})\n`;
1145
+ }
1035
1146
  function taskRoleCommand(args, store, options) {
1036
1147
  const [command, ...rest] = args;
1037
1148
  if (command === "add")
@@ -1448,6 +1559,15 @@ function createWork(args, store, options) {
1448
1559
  if (new Set(baseRefs.map(({ projectId }) => projectId)).size !== baseRefs.length) {
1449
1560
  throw usageError("Each Work Item Project may specify at most one base ref.");
1450
1561
  }
1562
+ const guard = runDeliveryGuardPreflight(tx, task.id, {
1563
+ kind: "create-work-item",
1564
+ scope: {
1565
+ title: parsed.positionals[1],
1566
+ objective: parsed.objective ?? parsed.positionals[1],
1567
+ acceptance: parsed.acceptance,
1568
+ writeProjectIds
1569
+ }
1570
+ }, { environment: options.environment, budget: true });
1451
1571
  const created = createWorkItem(tx.nextWorkItemId(task.id), task.id, {
1452
1572
  title: parsed.positionals[1],
1453
1573
  objective: parsed.objective ?? parsed.positionals[1],
@@ -1459,10 +1579,10 @@ function createWork(args, store, options) {
1459
1579
  }, now);
1460
1580
  tx.saveWorkItem(task.id, created);
1461
1581
  enqueueWork(tx, taskMailbox(task.id), "work-created", now, [workItemRef(task.id, created.id)]);
1462
- return created;
1582
+ return { item: created, guard };
1463
1583
  });
1464
- notifyMailbox(options.runtime, taskMailbox(item.taskId), item.taskId);
1465
- return output(`Created work item ${item.id} for ${item.taskId}\n`, { workItem: item });
1584
+ notifyMailbox(options.runtime, taskMailbox(item.item.taskId), item.item.taskId);
1585
+ return output(withGuardWarnings(item.guard, `Created work item ${item.item.id} for ${item.item.taskId}\n`), { workItem: item.item });
1466
1586
  }
1467
1587
  function updateWorkScope(args, store, options) {
1468
1588
  const usage = "Task work scope usage: yui task work scope <task>/<work> [--project <project> ...].";
@@ -1819,6 +1939,24 @@ function dispatchWork(args, store, options) {
1819
1939
  || prepared.owner.executionLaneId !== lane.id) {
1820
1940
  throw usageError(`Execution Lane workspace identity does not match dispatch: ${runningGroup.id}/${lane.id}.`);
1821
1941
  }
1942
+ if (options.laneDispatchProjectPaths !== undefined) {
1943
+ // The prepared workspaces were created under a held maintenance
1944
+ // fence that this transaction still holds. Re-prove the Task
1945
+ // binding set and exact Project paths match the preparation
1946
+ // snapshot: a migrate in the prepare/adopt gap fails closed here
1947
+ // rather than stranding a Lane on the external checkout.
1948
+ const currentIds = task.projectBindings.map(({ projectId }) => projectId).sort();
1949
+ const proofIds = [...options.laneDispatchProjectPaths.keys()].sort();
1950
+ if (currentIds.length !== proofIds.length
1951
+ || currentIds.some((projectId, index) => projectId !== proofIds[index])) {
1952
+ throw new Error(`Task Project bindings changed during Lane dispatch: ${task.id}.`);
1953
+ }
1954
+ for (const [projectId, preparedPath] of options.laneDispatchProjectPaths) {
1955
+ if (requireProject(tx, projectId).path !== preparedPath) {
1956
+ throw new Error(`Project path changed during Lane dispatch: ${projectId}.`);
1957
+ }
1958
+ }
1959
+ }
1822
1960
  if (tx.getManagedWorkspace(prepared.owner) === null)
1823
1961
  tx.saveManagedWorkspace(prepared);
1824
1962
  }
@@ -2018,10 +2156,16 @@ function acceptWork(args, store, options) {
2018
2156
  if (options.workItemIntegrationProof?.workspace.owner.type === "review-round") {
2019
2157
  throw usageError("A ReviewRound-owned workspace cannot be used for WorkItem acceptance.");
2020
2158
  }
2021
- const evidenceCommits = new Set(tx.listReviewRounds(item.taskId)
2022
- .flatMap(({ evidenceCommit }) => evidenceCommit === undefined ? [] : [evidenceCommit]));
2023
- if (options.workItemIntegrationProof?.projects.some(({ headCommit }) => evidenceCommits.has(headCommit))) {
2024
- throw usageError("A ReviewRound evidence commit cannot be used for WorkItem acceptance.");
2159
+ // Only diagnostic evidence commits (a reviewer's own commit on top of the
2160
+ // frozen base) are barred from WorkItem acceptance. A clean review
2161
+ // attests the frozen base itself (evidenceCommit === reviewBaseCommit),
2162
+ // which is the candidate's own head.
2163
+ const diagnosticEvidence = new Set(tx.listReviewRounds(item.taskId)
2164
+ .flatMap(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit
2165
+ ? [evidenceCommit]
2166
+ : []));
2167
+ if (options.workItemIntegrationProof?.projects.some(({ headCommit }) => diagnosticEvidence.has(headCommit))) {
2168
+ throw usageError("A ReviewRound diagnostic evidence commit cannot be used for WorkItem acceptance.");
2025
2169
  }
2026
2170
  const candidate = requireWorkItemCandidate(item);
2027
2171
  const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
@@ -2175,6 +2319,19 @@ function retireWork(args, store, options) {
2175
2319
  throw usageError("A Work Item cannot replace itself.");
2176
2320
  }
2177
2321
  }
2322
+ // rr4/finding-5: A Work Item with an active DurableJob cannot be retired —
2323
+ // the runner may still be using its workspace. Block on queued, running,
2324
+ // and unacknowledged unknown-needs-attention jobs owned by this Work Item.
2325
+ const activeWorkItemJob = tx.listDurableJobs(task.id).find((job) => (job.owner.kind === "work-item"
2326
+ && job.owner.workItemId === item.id
2327
+ && (job.status === "queued"
2328
+ || job.status === "running"
2329
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
2330
+ if (activeWorkItemJob !== undefined) {
2331
+ throw usageError(`Work Item ${item.id} has an active DurableJob: `
2332
+ + `${activeWorkItemJob.id}/${activeWorkItemJob.status}. `
2333
+ + "Cancel or acknowledge it before retiring.");
2334
+ }
2178
2335
  for (const run of tx.listAgentRuns(task.id).filter((candidate) => (candidate.status === "active" && candidate.workItemId === item.id))) {
2179
2336
  const terminal = terminalizeExactTaskRun(tx, {
2180
2337
  taskId: task.id,
@@ -2313,6 +2470,8 @@ function taskReviewCommand(args, store, options) {
2313
2470
  return retryFailedTaskReviewRound(rest, store, options);
2314
2471
  if (command === "group")
2315
2472
  return resolveReviewExecutionGroup(rest, store, options);
2473
+ if (command === "finding")
2474
+ return reviewFindingCommand(rest, store, options);
2316
2475
  throw usageError(command === undefined
2317
2476
  ? "Task review command is required."
2318
2477
  : `Unknown command: task review ${command}`);
@@ -2355,29 +2514,32 @@ function resolveReviewExecutionGroup(args, store, options) {
2355
2514
  : selectedLaneIds === undefined ? {} : { selectedLaneIds })
2356
2515
  }, now);
2357
2516
  const withGroup = updateReviewExecutionGroup(round, resolved);
2358
- const laneReports = resolved.lanes
2359
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2517
+ const selectedLanes = resolved.lanes
2518
+ .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false);
2519
+ const laneReports = selectedLanes
2360
2520
  .map((lane) => lane.result?.report ?? lane.result?.summary ?? "")
2361
2521
  .filter((report) => report.length > 0);
2362
- const checks = resolved.lanes
2363
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2522
+ const checks = selectedLanes
2364
2523
  .flatMap((lane) => lane.result?.checks ?? [])
2365
2524
  .map(({ name, outcome, details }) => ({
2366
2525
  name,
2367
2526
  outcome,
2368
2527
  ...(details === undefined ? {} : { details })
2369
2528
  }));
2370
- const findings = resolved.lanes
2371
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2529
+ const findings = selectedLanes
2372
2530
  .flatMap((lane) => lane.result?.findings ?? []);
2373
- const evidence = resolved.lanes
2374
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2531
+ const evidence = selectedLanes
2375
2532
  .flatMap((lane) => lane.result?.evidence ?? []);
2376
- const evidenceCommits = [...new Set(resolved.lanes
2377
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2533
+ const evidenceCommits = [...new Set(selectedLanes
2378
2534
  .map((lane) => lane.result?.evidenceCommit)
2379
2535
  .filter((commit) => commit !== undefined))];
2380
- const evidenceCommit = evidenceCommits.length === 1 ? evidenceCommits[0] : undefined;
2536
+ // A Round attests a single tree only when EVERY selected Lane attests it.
2537
+ // A dirty Lane (no evidenceCommit) ran checks on an uncommitted tree, so its
2538
+ // checks cannot be covered by another Lane's base attestation.
2539
+ const allLanesAttest = selectedLanes.every((lane) => lane.result?.evidenceCommit !== undefined);
2540
+ const evidenceCommit = allLanesAttest && evidenceCommits.length === 1
2541
+ ? evidenceCommits[0]
2542
+ : undefined;
2381
2543
  const terminal = finishReviewRound(withGroup, decision === "accept" ? "completed" : "failed", summary, now, {
2382
2544
  report: [
2383
2545
  laneReports.join("\n\n") || summary,
@@ -2393,6 +2555,11 @@ function resolveReviewExecutionGroup(args, store, options) {
2393
2555
  ...(evidenceCommit === undefined ? {} : { evidenceCommit })
2394
2556
  });
2395
2557
  tx.saveReviewRound(task.id, terminal);
2558
+ // Issue 06: a panel-resolved completed Round feeds the finding ledger;
2559
+ // a rejected Round is an execution-attempt failure and is skipped.
2560
+ if (terminal.status === "completed") {
2561
+ reconcileReviewFindingsAfterReview(tx, task.id, terminal.id, now);
2562
+ }
2396
2563
  enqueueWork(tx, leaderMailbox(task.id), "review-group-resolved", now, [
2397
2564
  workItemRef(task.id, round.workItemId)
2398
2565
  ]);
@@ -2400,6 +2567,166 @@ function resolveReviewExecutionGroup(args, store, options) {
2400
2567
  });
2401
2568
  return output(`Resolved Review ExecutionGroup ${result.executionGroup?.id ?? "unknown"} as ${decision}; ReviewRound ${result.id} is ${result.status}.\n`, { reviewRound: result });
2402
2569
  }
2570
+ /**
2571
+ * Issue 06: `yui task review finding` — the cross-Round finding ledger CLI.
2572
+ * Findings are extracted automatically from completed Rounds; these commands
2573
+ * let the Leader inspect the ledger, disposition each finding, and plan the
2574
+ * parallel repair wave.
2575
+ */
2576
+ function reviewFindingCommand(args, store, options) {
2577
+ const [command, ...rest] = args;
2578
+ if (command === "list")
2579
+ return listReviewFindings(rest, store, options);
2580
+ if (command === "dispose")
2581
+ return disposeReviewFindingCommand(rest, store, options);
2582
+ if (command === "repair-wave")
2583
+ return planReviewRepairWave(rest, store, options);
2584
+ if (command === "extract")
2585
+ return extractReviewFindingsCommand(rest, store, options);
2586
+ throw usageError(command === undefined
2587
+ ? "Task review finding command is required."
2588
+ : `Unknown command: task review finding ${command}`);
2589
+ }
2590
+ function listReviewFindings(args, store, options) {
2591
+ const usage = "Task review finding list usage: yui task review finding list <task>.";
2592
+ exactPositionals(args, 1, usage);
2593
+ const task = requireTask(store, args[0]);
2594
+ const findings = store.listReviewFindings(task.id);
2595
+ if (findings.length === 0) {
2596
+ return output(`No review findings recorded for ${task.id}.\n`);
2597
+ }
2598
+ const lines = findings.map((finding) => {
2599
+ const repair = finding.repair === undefined
2600
+ ? ""
2601
+ : `; repair: ${finding.repair.workItemId ?? "?"}${finding.repair.commit === undefined ? "" : `@${finding.repair.commit.slice(0, 12)}`}`;
2602
+ const merge = finding.mergeRequired === true ? " [merge-required]" : "";
2603
+ return `${finding.id} [${finding.severity}/${finding.disposition}] ${finding.title}`
2604
+ + ` (invariant: ${finding.invariant}; first: ${finding.firstReviewRoundId}; last: ${finding.lastReviewRoundId})${repair}${merge}`;
2605
+ });
2606
+ return output(`Review findings for ${task.id}:\n${lines.join("\n")}\n`);
2607
+ }
2608
+ function disposeReviewFindingCommand(args, store, options) {
2609
+ const usage = "Task review finding dispose usage: yui task review finding dispose <task>/<finding> "
2610
+ + "--disposition <fixed-pending-review|verified-fixed|accepted-risk|not-actionable|superseded> "
2611
+ + "[--work-item <id>] [--commit <sha>] [--verification <text>] [--note <text>] [--superseded-by <stable-key>].";
2612
+ const parsed = parseTail(args, new Set(["--disposition", "--work-item", "--commit", "--verification", "--note", "--superseded-by"]), usage);
2613
+ exactPositionals(parsed.positionals, 1, usage);
2614
+ const disposition = requiredOption(parsed.options, "--disposition");
2615
+ if (!LEADER_FINDING_DISPOSITIONS.includes(disposition)) {
2616
+ throw usageError(`Review finding disposition is invalid: ${disposition}.`);
2617
+ }
2618
+ const now = clock(options);
2619
+ const reference = resolveTaskRecordReference(parsed.positionals[0], {
2620
+ kind: "reviewFinding",
2621
+ label: "Review finding"
2622
+ });
2623
+ const result = store.transaction((tx) => {
2624
+ const task = requireTask(tx, reference.taskId);
2625
+ if (task.status !== "active")
2626
+ throw usageError(inactiveTaskMessage(task, "dispositioning a review finding"));
2627
+ if (taskActor(options, task.id) !== "leader") {
2628
+ throw usageError("Only the Task Leader may disposition a review finding.");
2629
+ }
2630
+ const command = {
2631
+ disposition,
2632
+ by: taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome) ?? "leader",
2633
+ ...(parsed.options.get("--note") === undefined ? {} : { note: parsed.options.get("--note") }),
2634
+ ...(parsed.options.get("--work-item") === undefined ? {} : { workItemId: parsed.options.get("--work-item") }),
2635
+ ...(parsed.options.get("--commit") === undefined ? {} : { commit: parsed.options.get("--commit") }),
2636
+ ...(parsed.options.get("--verification") === undefined ? {} : { verification: parsed.options.get("--verification") }),
2637
+ ...(parsed.options.get("--superseded-by") === undefined ? {} : { supersededBy: parsed.options.get("--superseded-by") }),
2638
+ now
2639
+ };
2640
+ return dispositionReviewFinding(tx, task.id, reference.localId, command);
2641
+ });
2642
+ return output(`Dispositioned ${result.id} as ${result.disposition}.\n`);
2643
+ }
2644
+ function planReviewRepairWave(args, store, options) {
2645
+ const usage = "Task review finding repair-wave usage: yui task review finding repair-wave <task> [--create].";
2646
+ const parsed = parseTail(args, new Set(), usage, new Set(["--create"]));
2647
+ exactPositionals(parsed.positionals, 1, usage);
2648
+ const task = requireTask(store, parsed.positionals[0]);
2649
+ const groups = planRepairGroups(store, task.id);
2650
+ if (groups.length === 0) {
2651
+ return output(`No open P1/P2 findings need repair for ${task.id}.\n`);
2652
+ }
2653
+ if (parsed.options.has("--create")) {
2654
+ const now = clock(options);
2655
+ const created = store.transaction((tx) => {
2656
+ const currentTask = requireTask(tx, task.id);
2657
+ if (currentTask.status !== "active") {
2658
+ throw usageError(inactiveTaskMessage(currentTask, "creating a review repair wave"));
2659
+ }
2660
+ if (taskActor(options, currentTask.id) !== "leader") {
2661
+ throw usageError("Only the Task Leader may create a review repair wave.");
2662
+ }
2663
+ const openItems = tx.listWorkItems(currentTask.id)
2664
+ .filter((item) => item.status === "pending" || item.status === "running");
2665
+ return groups.map((group) => {
2666
+ const findingMarkers = group.findingIds.map((id) => `review-finding:${id}`);
2667
+ const existing = openItems.find((item) => isDeepStrictEqual([...item.acceptance].sort(), [...findingMarkers].sort()));
2668
+ if (existing !== undefined)
2669
+ return { group, item: existing, changed: false };
2670
+ const item = createWorkItem(tx.nextWorkItemId(currentTask.id), currentTask.id, {
2671
+ title: `Repair review findings ${group.findingIds.join(", ")}`,
2672
+ objective: [
2673
+ "Repair the following Task-final Review findings as one overlapping group:",
2674
+ ...group.findings.map((finding) => (`- ${finding.id} [${finding.severity}] ${finding.title} `
2675
+ + `(invariant: ${finding.invariant}; evidence: ${finding.evidence.join(" | ") || "see ReviewRound"})`)),
2676
+ "Integrate this repair with the rest of the Task before requesting another Task-final Review."
2677
+ ].join("\n"),
2678
+ acceptance: findingMarkers,
2679
+ writeProjectIds: reviewRepairProjectIds(currentTask, group.affectedPaths)
2680
+ }, now);
2681
+ tx.saveWorkItem(currentTask.id, item);
2682
+ enqueueWork(tx, taskMailbox(currentTask.id), "work-created", now, [
2683
+ workItemRef(currentTask.id, item.id)
2684
+ ]);
2685
+ return { group, item, changed: true };
2686
+ });
2687
+ });
2688
+ const lines = created.map(({ group, item, changed }) => (`wave ${group.groupKey}: ${item.id} ${changed ? "created" : "already open"} `
2689
+ + `(${group.findingIds.join(", ")})`));
2690
+ return output(`Review repair wave for ${task.id} (${groups.length} group(s)):\n${lines.join("\n")}\n`, { groups: created });
2691
+ }
2692
+ const lines = groups.map((group, index) => {
2693
+ const findings = group.findings
2694
+ .map((finding) => `${finding.id} [${finding.severity}] ${finding.title}`)
2695
+ .join("; ");
2696
+ return `wave ${index + 1}: ${findings}`
2697
+ + ` (paths: ${group.affectedPaths.join(", ") || "none"}; invariants: ${group.invariants.join(", ")})`;
2698
+ });
2699
+ return output(`Repair wave for ${task.id} (${groups.length} group(s), run disjoint groups in parallel):\n${lines.join("\n")}\n`);
2700
+ }
2701
+ function reviewRepairProjectIds(task, affectedPaths) {
2702
+ const bindings = task.projectBindings;
2703
+ const matched = bindings.filter((binding) => affectedPaths.some((path) => pathWithinProject(path, binding.directory)));
2704
+ const projectIds = (matched.length > 0 ? matched : bindings).map(({ projectId }) => projectId);
2705
+ return [...new Set(projectIds)];
2706
+ }
2707
+ function pathWithinProject(path, directory) {
2708
+ const normalizedPath = path.replace(/^\.\//u, "").replace(/^\/+/u, "");
2709
+ const normalizedDirectory = directory.replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, "");
2710
+ if (normalizedDirectory.length === 0 || normalizedDirectory === ".")
2711
+ return true;
2712
+ return normalizedPath === normalizedDirectory
2713
+ || normalizedPath.startsWith(`${normalizedDirectory}/`);
2714
+ }
2715
+ function extractReviewFindingsCommand(args, store, options) {
2716
+ const usage = "Task review finding extract usage: yui task review finding extract <task>/<review-round>.";
2717
+ exactPositionals(args, 1, usage);
2718
+ const reference = resolveTaskRecordReference(args[0], {
2719
+ kind: "reviewRound",
2720
+ label: "ReviewRound"
2721
+ });
2722
+ const now = clock(options);
2723
+ const result = store.transaction((tx) => reconcileReviewFindings(tx, reference.taskId, reference.localId, now));
2724
+ if (result.skipped) {
2725
+ return output(`ReviewRound ${result.roundId} produced no findings: ${result.reason ?? "skipped"}\n`);
2726
+ }
2727
+ return output(`Reconciled ${result.created.length + result.updated.length + result.conflicts.length} finding(s) from ${result.roundId}: `
2728
+ + `${result.created.length} created, ${result.updated.length} updated, ${result.conflicts.length} conflict(s).\n`);
2729
+ }
2403
2730
  function requestTaskReviewRound(args, store, options) {
2404
2731
  const usage = "Task review request usage: yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...].";
2405
2732
  const parsed = parseMultiValueTail(args, new Set(["--role", "--strategy"]), new Set(["--lane-role"]), usage);
@@ -2633,9 +2960,12 @@ function retryFailedTaskReviewRound(args, store, options) {
2633
2960
  throw usageError(`ReviewRound ${round.id} is not a failed Task-final ReviewRound.`);
2634
2961
  }
2635
2962
  if (round.reviewerRunId !== undefined) {
2963
+ if (round.status === "completed") {
2964
+ throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
2965
+ }
2636
2966
  throw usageError(`ReviewRound ${round.id} has Reviewer Run ${round.reviewerRunId}; use task run retry instead.`);
2637
2967
  }
2638
- if (round.status !== "failed") {
2968
+ if (round.status !== "failed" && round.status !== "pending") {
2639
2969
  throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
2640
2970
  }
2641
2971
  if (round.taskCandidate === undefined) {
@@ -2667,25 +2997,15 @@ function retryFailedTaskReviewRound(args, store, options) {
2667
2997
  if (roundIndex < 0) {
2668
2998
  throw dataError(`Final ReviewRound is not in Task history: ${round.id}.`);
2669
2999
  }
2670
- const laterRounds = reviewerRounds.slice(roundIndex + 1);
2671
- const existingRetry = reviewerRounds.filter((entry) => (entry.id !== round.id
2672
- && (entry.scope ?? "work-item") === "task"
2673
- && entry.requestedBy === "leader"
2674
- && entry.workItemId === round.workItemId
2675
- && entry.candidateId === round.candidateId
2676
- && entry.reviewerRoleName === round.reviewerRoleName
2677
- && entry.reviewBaseCommit === round.reviewBaseCommit
2678
- && sameTaskFinalReviewContract(entry.taskFinalReviewContract, taskFinalContract)
2679
- && isSameTaskReviewCandidate(entry.taskCandidate, round.taskCandidate)
2680
- && entry.status !== "failed")).at(-1);
2681
- const conflictingLater = laterRounds.find((entry) => entry.id !== existingRetry?.id);
3000
+ // Issue 06: infra retries reuse the same semantic Round ID. Any later
3001
+ // Round supersedes this one; an active Round for the same Reviewer blocks.
3002
+ const conflictingLater = reviewerRounds.slice(roundIndex + 1).at(-1);
2682
3003
  if (conflictingLater !== undefined) {
2683
3004
  throw usageError(`A newer conflicting final ReviewRound already exists after ${round.id}: `
2684
3005
  + `${conflictingLater.id}/${conflictingLater.status}.`);
2685
3006
  }
2686
- assertNoConflictingTaskReviewRound(reviewerRounds, existingRetry?.id);
3007
+ assertNoConflictingTaskReviewRound(reviewerRounds, round.id);
2687
3008
  const activeRound = reviewerRounds.find((entry) => (entry.id !== round.id
2688
- && entry.id !== existingRetry?.id
2689
3009
  && entry.reviewerRoleName === round.reviewerRoleName
2690
3010
  && (entry.status === "pending" || entry.status === "running")));
2691
3011
  if (activeRound !== undefined) {
@@ -2711,62 +3031,27 @@ function retryFailedTaskReviewRound(args, store, options) {
2711
3031
  || mailbox?.pending !== null && mailbox?.pending !== undefined);
2712
3032
  const activeReviewerRuns = tx.listAgentRuns(task.id).filter((entry) => (entry.roleName === reviewer.name && entry.status === "active"));
2713
3033
  const activePointer = tx.getActiveAgentRun(task.id, reviewer.name);
2714
- // An exact already-created retry is the idempotent result. The identity
2715
- // fences are intentionally as strict as the failed-Run retry path: pending
2716
- // is reusable only while every Reviewer lane is idle; running is reusable
2717
- // only with its exact active Run/mailbox; completed is a no-write result.
2718
- if (existingRetry !== undefined) {
2719
- if (existingRetry.status === "pending") {
2720
- if (activePointer !== null || activeReviewerRuns.length > 0
2721
- || hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
2722
- throw usageError(`Reviewer has unrelated active execution: ${reviewer.name}.`);
2723
- }
2724
- return { round: existingRetry, created: false };
2725
- }
2726
- if (existingRetry.status === "running") {
2727
- const reviewerRunId = existingRetry.reviewerRunId;
2728
- const activeMatches = reviewerRunId !== undefined
2729
- && activePointer !== null
2730
- && activePointer.id === reviewerRunId
2731
- && activePointer.status === "active"
2732
- && activeReviewerRuns.length === 1
2733
- && activeReviewerRuns[0].id === reviewerRunId;
2734
- const processingMatches = reviewerRunId !== undefined
2735
- && reviewerMailbox?.processing?.executionRef !== undefined
2736
- && isDeepStrictEqual(reviewerMailbox.processing.executionRef, runRef(task.id, reviewerRunId))
2737
- && reviewerMailbox.pending === null;
2738
- const pendingMatches = reviewerRunId !== undefined
2739
- && reviewerMailbox?.pending?.requestCount === 1
2740
- && reviewerMailbox.pending.refs.some((ref) => (isDeepStrictEqual(ref, runRef(task.id, reviewerRunId))));
2741
- if (!activeMatches || (!processingMatches && !pendingMatches) || hasMailboxWork(runtimeMailbox)) {
2742
- throw usageError(`Existing retry ${existingRetry.id} is running without its exact active Reviewer execution.`);
2743
- }
2744
- return { round: existingRetry, created: false };
2745
- }
2746
- if (existingRetry.status === "completed") {
2747
- if (activePointer !== null || activeReviewerRuns.length > 0
2748
- || hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
2749
- throw usageError(`Reviewer has unrelated active execution: ${reviewer.name}.`);
2750
- }
2751
- return { round: existingRetry, created: false };
2752
- }
2753
- throw usageError(`Existing retry ${existingRetry.id} is not reusable from ${existingRetry.status}.`);
2754
- }
2755
3034
  if (activePointer !== null || activeReviewerRuns.length > 0) {
2756
3035
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
2757
3036
  }
2758
3037
  if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
2759
3038
  throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
2760
3039
  }
2761
- const nextRound = createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, round.workItemId, round.candidateId, round.reviewerRoleName, "leader", round.taskCandidate, now, taskFinalContract);
2762
- tx.saveReviewRound(task.id, nextRound);
3040
+ // Issue 06: an already-pending Round is the idempotent retry result.
3041
+ if (round.status === "pending") {
3042
+ return { round, created: false };
3043
+ }
3044
+ // Issue 06: infra retry resets the same semantic Round to pending instead
3045
+ // of manufacturing a new Round, so Round count and finding identity stay
3046
+ // stable across execution-attempt failures.
3047
+ const resetRound = retryTaskReviewRound(round);
3048
+ tx.saveReviewRound(task.id, resetRound);
2763
3049
  recordTaskEvent(tx, task.id, "review.task-final-retried", {
2764
- previousReviewRoundId: round.id,
2765
- nextReviewRoundId: nextRound.id,
3050
+ reviewRoundId: round.id,
2766
3051
  workItemId: round.workItemId,
2767
3052
  candidateId: round.candidateId
2768
3053
  }, now);
2769
- return { round: nextRound, created: true };
3054
+ return { round: resetRound, created: true };
2770
3055
  });
2771
3056
  return output(result.created
2772
3057
  ? `Task-final Review retry requested as ${result.round.id}\n`
@@ -2784,6 +3069,8 @@ function taskRunCommand(args, store, options) {
2784
3069
  return output(recoverRun(rest, store, options));
2785
3070
  if (command === "yield")
2786
3071
  return yieldRun(rest, store, options);
3072
+ if (command === "yield-status")
3073
+ return yieldRunStatus(rest, store, options);
2787
3074
  if (command === "checkpoint")
2788
3075
  return output(checkpointRun(rest, store, options));
2789
3076
  throw usageError(command === undefined
@@ -3436,11 +3723,9 @@ function latestTaskReviewContractAnchor(store, task, taskFinalContract) {
3436
3723
  }
3437
3724
  /**
3438
3725
  * Leader-only retry of an exact failed Task-final review Run. The old failed
3439
- * Run/Round/workspace/evidence are preserved verbatim: the old Round is
3440
- * terminalized as failed (idempotent if already failed) and a new independent
3441
- * ReviewRound bound to the same frozen Candidate is created (or reused if a
3442
- * prior retry already produced one). Every identity and frozen-head fence is
3443
- * checked inside one transaction so a partial fail-old-without-new state can
3726
+ * Run remains the attempt trail, while the semantic ReviewRound is reset to
3727
+ * pending under its existing identity. Every identity and frozen-head fence is
3728
+ * checked inside one transaction so a partial fail-old-without-reset state can
3444
3729
  * never be committed.
3445
3730
  */
3446
3731
  function retryFailedReviewRun(previous, store, options, now) {
@@ -3470,13 +3755,12 @@ function retryFailedReviewRun(previous, store, options, now) {
3470
3755
  if (!sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalContract)) {
3471
3756
  throw usageError(`Task final-review contract does not match ReviewRound ${round.id}.`);
3472
3757
  }
3473
- if (round.status !== "failed" && round.status !== "running") {
3758
+ if (round.status !== "failed"
3759
+ && round.status !== "running"
3760
+ && round.status !== "pending"
3761
+ && round.status !== "completed") {
3474
3762
  throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
3475
3763
  }
3476
- const validation = validateExactRunReviewRound(tx, run, { allowTerminal: true });
3477
- if (validation.disposition !== "applied" || validation.round === null) {
3478
- throw usageError(`Review Run ${run.id} identity does not match its ReviewRound or frozen Task state changed: ${validation.reason ?? "mismatch"}.`);
3479
- }
3480
3764
  const currentTaskCandidate = actualTaskReviewCandidateForMutation(tx, task, options);
3481
3765
  if (!isSameTaskReviewCandidate(currentTaskCandidate, round.taskCandidate)) {
3482
3766
  throw usageError(`Task-final ReviewRound ${round.id} no longer matches the latest committed Integration heads.`);
@@ -3499,27 +3783,9 @@ function retryFailedReviewRun(previous, store, options, now) {
3499
3783
  if (laterRound !== undefined && !isSameTaskReviewCandidate(laterRound.taskCandidate, round.taskCandidate)) {
3500
3784
  throw usageError(`A newer final Task candidate already has ReviewRound ${laterRound.id}.`);
3501
3785
  }
3502
- const reviewerRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id).filter((entry) => (entry.workItemId === item.id
3503
- && entry.candidateId === candidate.id
3504
- && entry.reviewerRoleName === round.reviewerRoleName)));
3505
3786
  const allReviewerRounds = reviewRoundsByIdentity(tx.listReviewRounds(task.id).filter((entry) => (entry.reviewerRoleName === round.reviewerRoleName)));
3506
- const existingRetry = reviewerRounds.filter((entry) => (entry.id !== round.id
3507
- && entry.status !== "failed"
3508
- && entry.requestedBy === "leader"
3509
- && (entry.scope ?? "work-item") === "task"
3510
- && entry.candidateId === candidate.id
3511
- && entry.workItemId === item.id
3512
- && entry.reviewerRoleName === round.reviewerRoleName
3513
- && entry.reviewBaseCommit === round.reviewBaseCommit
3514
- && sameTaskFinalReviewContract(entry.taskFinalReviewContract, taskFinalContract)
3515
- && isSameTaskReviewCandidate(entry.taskCandidate, round.taskCandidate))).at(-1);
3516
- if (round.status === "running"
3517
- && tx.getActiveAgentRun(task.id, round.reviewerRoleName) !== null) {
3518
- throw usageError(`${task.id}/${round.reviewerRoleName} already has an active run.`);
3519
- }
3520
- assertNoConflictingTaskReviewRound(tx.listReviewRounds(task.id), [round.id, ...(existingRetry === undefined ? [] : [existingRetry.id])]);
3787
+ assertNoConflictingTaskReviewRound(tx.listReviewRounds(task.id), round.id);
3521
3788
  const activeRound = allReviewerRounds.find((entry) => (entry.id !== round.id
3522
- && entry.id !== existingRetry?.id
3523
3789
  && (entry.status === "pending" || entry.status === "running")));
3524
3790
  if (activeRound !== undefined) {
3525
3791
  throw usageError(`Reviewer already has an active review round for this candidate: ${activeRound.id}.`);
@@ -3536,39 +3802,21 @@ function retryFailedReviewRun(previous, store, options, now) {
3536
3802
  const reviewer = requireRole(tx, task.id, round.reviewerRoleName);
3537
3803
  const activePointer = tx.getActiveAgentRun(task.id, reviewer.name);
3538
3804
  const activeReviewerRuns = tx.listAgentRuns(task.id).filter((entry) => (entry.roleName === reviewer.name && entry.status === "active"));
3539
- if (round.status === "running"
3540
- && (activePointer !== null || activeReviewerRuns.length > 0)) {
3541
- throw usageError(`${task.id}/${reviewer.name} already has an active run.`);
3542
- }
3543
- // A completed identical retry is terminal evidence and is a no-write
3544
- // idempotent read only when no unrelated Reviewer state is live. Keep the
3545
- // same fail-closed fences as pending/running retries.
3546
- if (existingRetry?.status === "completed") {
3547
- if (activePointer !== null || activeReviewerRuns.length > 0) {
3548
- throw usageError(`Reviewer Role already has an active run: ${round.reviewerRoleName}.`);
3549
- }
3550
- if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
3551
- throw usageError(`Reviewer has unrelated mailbox work: ${round.reviewerRoleName}.`);
3552
- }
3553
- return { round: existingRetry, previousRun: run };
3554
- }
3555
- if (existingRetry?.status === "pending") {
3556
- // A pending retry is reusable only while every Reviewer lane is idle.
3557
- // In particular, never return it to the CLI while another Round/Run or
3558
- // mailbox batch could cause the CLI to dispatch or fail it.
3805
+ // Issue 06: a completed same-Round retry is a no-write idempotent result.
3806
+ if (round.status === "completed") {
3559
3807
  if (activePointer !== null || activeReviewerRuns.length > 0) {
3560
3808
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
3561
3809
  }
3562
- if (hasMailboxWork(reviewerMailbox)) {
3563
- throw usageError(`Reviewer mailbox has unrelated work: ${reviewer.name}.`);
3564
- }
3565
- if (hasMailboxWork(runtimeMailbox)) {
3566
- throw usageError(`Reviewer runtime lifecycle is pending: ${reviewer.name}.`);
3810
+ if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
3811
+ throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
3567
3812
  }
3568
- return { round: existingRetry, previousRun: run };
3813
+ return { round, previousRun: run, created: false };
3569
3814
  }
3570
- if (existingRetry?.status === "running") {
3571
- const reviewerRunId = existingRetry.reviewerRunId;
3815
+ // Issue 06: a running same Round is reusable only with its exact active
3816
+ // Run and mailbox execution. A stranded Run (no active pointer) falls
3817
+ // through and resets the Round after the identity fences below.
3818
+ if (round.status === "running") {
3819
+ const reviewerRunId = round.reviewerRunId;
3572
3820
  const activeMatches = reviewerRunId !== undefined
3573
3821
  && activePointer !== null
3574
3822
  && activePointer.id === reviewerRunId
@@ -3583,12 +3831,24 @@ function retryFailedReviewRun(previous, store, options, now) {
3583
3831
  && reviewerMailbox?.processing === null
3584
3832
  && reviewerMailbox.pending?.requestCount === 1
3585
3833
  && reviewerMailbox.pending.refs.some((ref) => (isDeepStrictEqual(ref, runRef(task.id, reviewerRunId))));
3586
- if (!activeMatches
3587
- || (!processingMatches && !pendingMatches)
3588
- || hasMailboxWork(runtimeMailbox)) {
3589
- throw usageError(`Existing retry ${existingRetry.id} is running without its exact active Reviewer execution.`);
3834
+ if (activePointer !== null
3835
+ && (!activeMatches
3836
+ || (!processingMatches && !pendingMatches)
3837
+ || hasMailboxWork(runtimeMailbox))) {
3838
+ throw usageError(`Existing running ReviewRound ${round.id} lacks its exact active Reviewer execution.`);
3590
3839
  }
3591
- return { round: existingRetry, previousRun: run };
3840
+ if (activeMatches)
3841
+ return { round, previousRun: run, created: false };
3842
+ }
3843
+ // Issue 06: an already-pending Round is the idempotent retry result.
3844
+ if (round.status === "pending") {
3845
+ if (activePointer !== null || activeReviewerRuns.length > 0) {
3846
+ throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
3847
+ }
3848
+ if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
3849
+ throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
3850
+ }
3851
+ return { round, previousRun: run, created: false };
3592
3852
  }
3593
3853
  if (activePointer !== null || activeReviewerRuns.length > 0) {
3594
3854
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
@@ -3599,6 +3859,10 @@ function retryFailedReviewRun(previous, store, options, now) {
3599
3859
  if (runtimeMailbox?.pending !== null && runtimeMailbox?.pending !== undefined) {
3600
3860
  throw usageError(`Reviewer runtime lifecycle has pending work: ${reviewer.name}.`);
3601
3861
  }
3862
+ const validation = validateExactRunReviewRound(tx, run, { allowTerminal: true });
3863
+ if (validation.disposition !== "applied" || validation.round === null) {
3864
+ throw usageError(`Review Run ${run.id} identity does not match its ReviewRound or frozen Task state changed: ${validation.reason ?? "mismatch"}.`);
3865
+ }
3602
3866
  // A stranded pre-delivery Run can leave its exact pending or processing
3603
3867
  // dispatch behind; settle only that exact reference while holding the
3604
3868
  // same aggregate lock. Any merged or unrelated batch fails closed.
@@ -3622,27 +3886,31 @@ function retryFailedReviewRun(previous, store, options, now) {
3622
3886
  }
3623
3887
  // Terminalize the old stranded Round only after every identity and mailbox
3624
3888
  // fence has passed. The outer transaction rolls back if Round creation fails.
3889
+ let roundToReset = round;
3625
3890
  if (round.status !== "failed") {
3626
3891
  const summary = round.summary
3627
3892
  ?? run.summary
3628
3893
  ?? `Review Run ${run.id} failed before delivery.`;
3629
- tx.saveReviewRound(task.id, finishReviewRound(round, "failed", summary, now, {
3894
+ roundToReset = finishReviewRound(round, "failed", summary, now, {
3630
3895
  report: round.report ?? summary,
3631
3896
  checks: round.checks ?? [],
3632
3897
  ...(round.evidenceCommit === undefined ? {} : { evidenceCommit: round.evidenceCommit })
3633
- }));
3898
+ });
3899
+ tx.saveReviewRound(task.id, roundToReset);
3634
3900
  }
3635
- const nextRound = createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, item.id, candidate.id, round.reviewerRoleName, "leader", round.taskCandidate, now, taskFinalContract);
3636
- tx.saveReviewRound(task.id, nextRound);
3901
+ // Issue 06: infra retry resets the same semantic Round to pending instead
3902
+ // of manufacturing a new Round, so Round count and finding identity stay
3903
+ // stable across execution-attempt failures.
3904
+ const resetRound = retryTaskReviewRound(roundToReset);
3905
+ tx.saveReviewRound(task.id, resetRound);
3637
3906
  recordTaskEvent(tx, task.id, "run.review-retried", {
3638
3907
  runId: run.id,
3639
3908
  reviewRoundId: round.id,
3640
- nextReviewRoundId: nextRound.id,
3641
3909
  candidateId: candidate.id
3642
3910
  }, now);
3643
- return { round: nextRound, previousRun: run };
3911
+ return { round: resetRound, previousRun: run, created: true };
3644
3912
  });
3645
- return output(result.round.status === "pending"
3913
+ return output(result.created
3646
3914
  ? `Review retry requested as ${result.round.id}\n`
3647
3915
  : `Review retry already requested as ${result.round.id} (${result.round.status})\n`, { reviewRound: result.round });
3648
3916
  }
@@ -3722,12 +3990,111 @@ function recoverRun(args, store, options) {
3722
3990
  : "";
3723
3991
  return `Recorded exact ${result.action} recovery for ${result.run?.id ?? "unknown Run"}.${followup}\n`;
3724
3992
  }
3993
+ /**
3994
+ * Issue 04: builds the terminal yield outcome from the command inputs. The
3995
+ * same construction feeds both the first commit and the idempotent replay, so
3996
+ * a resend always hashes to the same digest.
3997
+ */
3998
+ function buildYieldOutcome(run, inputSummary, options) {
3999
+ let yieldedReport;
4000
+ if (run.purpose === "review"
4001
+ || (run.purpose === "execution" && run.executionGroupId !== undefined)) {
4002
+ yieldedReport = parseReviewYieldReport(inputSummary);
4003
+ }
4004
+ const summary = yieldedReport?.summary ?? inputSummary;
4005
+ if (yieldedReport === undefined)
4006
+ return { summary };
4007
+ return {
4008
+ summary,
4009
+ reviewResult: {
4010
+ report: yieldedReport.report,
4011
+ checks: yieldedReport.checks,
4012
+ ...(yieldedReport.findings === undefined ? {} : { findings: yieldedReport.findings }),
4013
+ ...(yieldedReport.evidence === undefined ? {} : { evidence: yieldedReport.evidence }),
4014
+ ...(run.purpose === "review"
4015
+ && options.reviewWorkspaceResult?.evidenceCommit === undefined
4016
+ ? {}
4017
+ : run.purpose === "review"
4018
+ ? { evidenceCommit: options.reviewWorkspaceResult.evidenceCommit }
4019
+ : yieldedReport.evidenceCommit === undefined
4020
+ ? {}
4021
+ : { evidenceCommit: yieldedReport.evidenceCommit }),
4022
+ ...(options.executionLaneGitSnapshot === undefined
4023
+ || options.executionLaneGitSnapshot === null
4024
+ ? {}
4025
+ : { gitSnapshot: options.executionLaneGitSnapshot })
4026
+ }
4027
+ };
4028
+ }
4029
+ /**
4030
+ * Issue 04: replays an already-committed yield. Returns the committed receipt
4031
+ * for the same outcome, fails closed for a different outcome, or returns
4032
+ * `null` to keep the legacy "already terminal" behavior.
4033
+ */
4034
+ function replayYieldReceipt(run, inputSummary, options) {
4035
+ const config = providerRetryConfig(options.environment ?? process.env);
4036
+ if (!config.yieldReceiptReplay)
4037
+ return null;
4038
+ if (run.yieldReceipt === undefined)
4039
+ return null;
4040
+ const outcome = buildYieldOutcome(run, inputSummary, options);
4041
+ const match = matchYieldReceipt(run.yieldReceipt, {
4042
+ status: "yielded",
4043
+ summary: outcome.summary,
4044
+ ...(outcome.reviewResult === undefined ? {} : { reviewResult: outcome.reviewResult })
4045
+ });
4046
+ if (match === null)
4047
+ return null;
4048
+ if (match.kind === "digest-mismatch") {
4049
+ throw usageError(`Run ${run.id} is already terminal with a different yield outcome. `
4050
+ + `Existing receipt: ${match.existing.receiptId} (request ${match.existing.requestId}).`);
4051
+ }
4052
+ return output(`Run ${run.id} yield already committed.\n`
4053
+ + `Receipt: ${match.receipt.receiptId}\n`
4054
+ + `Request: ${match.receipt.requestId}\n`, { receipt: match.receipt });
4055
+ }
4056
+ /**
4057
+ * Issue 04: `yui task run yield-status <task>/<run>` — returns the committed
4058
+ * yield receipt for a terminal Run, or the current status for an active Run.
4059
+ */
4060
+ function yieldRunStatus(args, store, options) {
4061
+ const usage = "Task run yield-status usage: yui task run yield-status <task>/<run>.";
4062
+ const parsed = parseTail(args, new Set(), usage);
4063
+ exactPositionals(parsed.positionals, 1, usage);
4064
+ const run = requireRun(store, parsed.positionals[0], options);
4065
+ if (run.status === "active") {
4066
+ return output(`Run ${run.id} is active; no yield receipt yet.\n`, {
4067
+ runId: run.id,
4068
+ status: run.status
4069
+ });
4070
+ }
4071
+ if (run.yieldReceipt === undefined) {
4072
+ return output(`Run ${run.id} is ${run.status}; no yield receipt recorded.\n`, {
4073
+ runId: run.id,
4074
+ status: run.status
4075
+ });
4076
+ }
4077
+ return output(`Run ${run.id} yield receipt:\n`
4078
+ + `Receipt: ${run.yieldReceipt.receiptId}\n`
4079
+ + `Request: ${run.yieldReceipt.requestId}\n`
4080
+ + `Committed: ${run.yieldReceipt.committedAt}\n`, { receipt: run.yieldReceipt });
4081
+ }
3725
4082
  function yieldRun(args, store, options) {
3726
4083
  const usage = "Task run yield usage: yui task run yield <task>/<run> (--summary <text>|--summary-file <path|->).";
3727
4084
  const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage);
3728
4085
  exactPositionals(parsed.positionals, 1, usage);
3729
4086
  const inputSummary = readCommandText(parsed.options.get("--summary"), parsed.options.get("--summary-file"), "--summary", usage);
3730
4087
  const now = clock(options);
4088
+ // Issue 04: an already-terminal Run may be a lost-response resend. Match
4089
+ // the presented outcome against the committed receipt before opening the
4090
+ // transaction; the receipt is immutable once committed.
4091
+ const existing = requireRun(store, parsed.positionals[0], options);
4092
+ if (existing.status !== "active") {
4093
+ const replayed = replayYieldReceipt(existing, inputSummary, options);
4094
+ if (replayed !== null)
4095
+ return replayed;
4096
+ throw usageError(`Run ${existing.id} is already terminal: ${existing.status}.`);
4097
+ }
3731
4098
  const yielded = store.transaction((tx) => {
3732
4099
  const active = requireRun(tx, parsed.positionals[0], options);
3733
4100
  if (active.status !== "active") {
@@ -3775,7 +4142,8 @@ function yieldRun(args, store, options) {
3775
4142
  throw usageError(`Reported Review evidence commit does not match the managed workspace: ${active.id}.`);
3776
4143
  }
3777
4144
  }
3778
- const summary = yieldedReport?.summary ?? inputSummary;
4145
+ const yieldOutcome = buildYieldOutcome(active, inputSummary, options);
4146
+ const summary = yieldOutcome.summary;
3779
4147
  const terminalization = terminalizeExactTaskRun(tx, {
3780
4148
  taskId: task.id,
3781
4149
  roleName: role.name,
@@ -3789,28 +4157,9 @@ function yieldRun(args, store, options) {
3789
4157
  ? {}
3790
4158
  : { launchId: options.environment.YUI_LAUNCH_ID }),
3791
4159
  outcome: { status: "yielded", summary },
3792
- ...(yieldedReport === undefined
4160
+ ...(yieldOutcome.reviewResult === undefined
3793
4161
  ? {}
3794
- : {
3795
- reviewResult: {
3796
- report: yieldedReport.report,
3797
- checks: yieldedReport.checks,
3798
- ...(yieldedReport.findings === undefined ? {} : { findings: yieldedReport.findings }),
3799
- ...(yieldedReport.evidence === undefined ? {} : { evidence: yieldedReport.evidence }),
3800
- ...(active.purpose === "review"
3801
- && options.reviewWorkspaceResult?.evidenceCommit === undefined
3802
- ? {}
3803
- : active.purpose === "review"
3804
- ? { evidenceCommit: options.reviewWorkspaceResult.evidenceCommit }
3805
- : yieldedReport.evidenceCommit === undefined
3806
- ? {}
3807
- : { evidenceCommit: yieldedReport.evidenceCommit }),
3808
- ...(options.executionLaneGitSnapshot === undefined
3809
- || options.executionLaneGitSnapshot === null
3810
- ? {}
3811
- : { gitSnapshot: options.executionLaneGitSnapshot })
3812
- }
3813
- })
4162
+ : { reviewResult: yieldOutcome.reviewResult })
3814
4163
  }, now);
3815
4164
  if (terminalization.disposition !== "applied" || terminalization.run === null) {
3816
4165
  throw usageError(`Run ${active.id} no longer matches its exact execution fence: `
@@ -3978,6 +4327,21 @@ function yieldRun(args, store, options) {
3978
4327
  ...(terminal.workItemId === undefined ? [] : [workItemRef(task.id, terminal.workItemId)])
3979
4328
  ]);
3980
4329
  }
4330
+ // Issue 05: record the Leader's terminal receipt so the Scheduler can
4331
+ // suppress no-change `task-orphaned` wakes. The disposition and digest are
4332
+ // machine-derived from the post-yield projection; the Leader does not
4333
+ // need to cooperate for the admission check to work.
4334
+ if (role.name === LEADER_ROLE) {
4335
+ const receipt = leaderYieldReceipt(tx, task, terminal, now);
4336
+ if (receipt !== null) {
4337
+ tx.saveAgentRun(receipt);
4338
+ return {
4339
+ run: receipt,
4340
+ reviewDispatch,
4341
+ notifyLeader: leaderHandoff !== null
4342
+ };
4343
+ }
4344
+ }
3981
4345
  return {
3982
4346
  run: terminal,
3983
4347
  reviewDispatch,
@@ -3998,6 +4362,47 @@ function yieldRun(args, store, options) {
3998
4362
  : { reviewRound: yielded.reviewDispatch.round })
3999
4363
  });
4000
4364
  }
4365
+ /**
4366
+ * Issue 05: compute the Leader Run terminal receipt (disposition + observed
4367
+ * actionability digest) from the post-yield projection. Returns the updated
4368
+ * Run, or null when the receipt cannot be computed (the caller keeps the
4369
+ * unmodified terminal Run in that case; the Scheduler fails open).
4370
+ */
4371
+ function leaderYieldReceipt(tx, task, terminal, now) {
4372
+ try {
4373
+ const projection = buildTaskExecutionProjection(tx, task.id, task);
4374
+ if (projection === null)
4375
+ return null;
4376
+ const disposition = deriveLeaderRunDisposition(projection.status, task.status);
4377
+ const digest = computeActionabilityDigest(collectTaskActionability(tx, task.id));
4378
+ const waitReason = disposition === "waiting" || disposition === "blocked"
4379
+ ? leaderWaitReason(projection)
4380
+ : undefined;
4381
+ return {
4382
+ ...terminal,
4383
+ disposition,
4384
+ observedActionabilityDigest: digest,
4385
+ ...(waitReason === undefined ? {} : { waitReason }),
4386
+ updatedAt: now.toISOString()
4387
+ };
4388
+ }
4389
+ catch {
4390
+ return null;
4391
+ }
4392
+ }
4393
+ function leaderWaitReason(projection) {
4394
+ const blocker = projection.blockers[0];
4395
+ if (blocker !== undefined) {
4396
+ return { kind: blocker.kind, ref: blocker.id };
4397
+ }
4398
+ if (projection.status === "waiting-on-agents") {
4399
+ return { kind: "delegated-work" };
4400
+ }
4401
+ if (projection.status === "waiting-user") {
4402
+ return { kind: "input" };
4403
+ }
4404
+ return undefined;
4405
+ }
4001
4406
  /**
4002
4407
  * Records a structured progress checkpoint for an active Run. This is a durable
4003
4408
  * run fact, not a Task Message: it advances the Run's durable-progress clock so
@@ -4222,6 +4627,9 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4222
4627
  .map(({ projectId, commit }) => `${projectId}@${commit}`)
4223
4628
  .join(", ")
4224
4629
  : `candidate@${round.reviewBaseCommit}`;
4630
+ const findingContext = taskScope
4631
+ ? buildTaskFinalReviewFindingContext(tx, taskId, round.taskCandidate).context
4632
+ : "";
4225
4633
  const scopeLabel = taskScope ? "Task-final" : "WorkItem";
4226
4634
  const projectPolicyPointers = task.projectBindings
4227
4635
  .map(({ projectId }) => (`yui project show ${projectId}; yui project knowledge list ${projectId}`))
@@ -4238,6 +4646,7 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4238
4646
  `Review workspace source: exact workspace attached to this Reviewer Lane`,
4239
4647
  `Candidate summary: ${candidate.summary}`,
4240
4648
  `Acceptance criteria: ${item.acceptance.length === 0 ? "none" : item.acceptance.join("; ")}`,
4649
+ ...(taskScope ? [findingContext] : []),
4241
4650
  "Start from the user's core outcome and the WorkItem intent. The candidate summary is a pointer, not proof: inspect the complete relevant change, callers, and proportionate checks.",
4242
4651
  "Keep Yui Core lifecycle safety, generic Reviewer behavior, Project Policy/Knowledge, and the Task Contract separate. Follow Project Policy pointers from the dispatch context for project-specific checks.",
4243
4652
  ...(round.scope === "task"