@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
@@ -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,
@@ -2285,13 +2442,20 @@ function reviewWork(args, store, options) {
2285
2442
  && round.candidateId === candidate.id
2286
2443
  && (round.status === "pending" || round.status === "running")))).at(-1);
2287
2444
  if (activeRound !== undefined) {
2445
+ if (activeRound.status === "pending" && activeRound.reviewerRunId === undefined) {
2446
+ return { round: activeRound, run: null, resumed: true };
2447
+ }
2288
2448
  throw usageError(`ReviewRound is already active: ${activeRound.id}/${activeRound.status}.`);
2289
2449
  }
2290
- return queueReviewRound(tx, item, config, "leader", now);
2450
+ const queued = queueReviewRound(tx, item, config, "leader", now);
2451
+ return { ...queued, resumed: false };
2291
2452
  });
2292
2453
  if (result.run !== null) {
2293
2454
  notifyReviewMailbox(options, options.runtime, roleMailbox(result.run.taskId, result.run.roleName), result.run.taskId);
2294
2455
  }
2456
+ if (result.resumed) {
2457
+ return output(`Review request ${result.round.id} is pending; resuming dispatch.\n`, { reviewRound: result.round });
2458
+ }
2295
2459
  return result.round.status === "failed"
2296
2460
  ? output(`Review could not start for ${result.round.workItemId}: ${result.round.summary}\n`, { reviewRound: result.round })
2297
2461
  : output(`Review requested as ${result.round.id}\n`, { reviewRound: result.round });
@@ -2313,6 +2477,8 @@ function taskReviewCommand(args, store, options) {
2313
2477
  return retryFailedTaskReviewRound(rest, store, options);
2314
2478
  if (command === "group")
2315
2479
  return resolveReviewExecutionGroup(rest, store, options);
2480
+ if (command === "finding")
2481
+ return reviewFindingCommand(rest, store, options);
2316
2482
  throw usageError(command === undefined
2317
2483
  ? "Task review command is required."
2318
2484
  : `Unknown command: task review ${command}`);
@@ -2355,29 +2521,32 @@ function resolveReviewExecutionGroup(args, store, options) {
2355
2521
  : selectedLaneIds === undefined ? {} : { selectedLaneIds })
2356
2522
  }, now);
2357
2523
  const withGroup = updateReviewExecutionGroup(round, resolved);
2358
- const laneReports = resolved.lanes
2359
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2524
+ const selectedLanes = resolved.lanes
2525
+ .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false);
2526
+ const laneReports = selectedLanes
2360
2527
  .map((lane) => lane.result?.report ?? lane.result?.summary ?? "")
2361
2528
  .filter((report) => report.length > 0);
2362
- const checks = resolved.lanes
2363
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2529
+ const checks = selectedLanes
2364
2530
  .flatMap((lane) => lane.result?.checks ?? [])
2365
2531
  .map(({ name, outcome, details }) => ({
2366
2532
  name,
2367
2533
  outcome,
2368
2534
  ...(details === undefined ? {} : { details })
2369
2535
  }));
2370
- const findings = resolved.lanes
2371
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2536
+ const findings = selectedLanes
2372
2537
  .flatMap((lane) => lane.result?.findings ?? []);
2373
- const evidence = resolved.lanes
2374
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2538
+ const evidence = selectedLanes
2375
2539
  .flatMap((lane) => lane.result?.evidence ?? []);
2376
- const evidenceCommits = [...new Set(resolved.lanes
2377
- .filter((lane) => resolved.resolution?.selectedLaneIds.includes(lane.id) ?? false)
2540
+ const evidenceCommits = [...new Set(selectedLanes
2378
2541
  .map((lane) => lane.result?.evidenceCommit)
2379
2542
  .filter((commit) => commit !== undefined))];
2380
- const evidenceCommit = evidenceCommits.length === 1 ? evidenceCommits[0] : undefined;
2543
+ // A Round attests a single tree only when EVERY selected Lane attests it.
2544
+ // A dirty Lane (no evidenceCommit) ran checks on an uncommitted tree, so its
2545
+ // checks cannot be covered by another Lane's base attestation.
2546
+ const allLanesAttest = selectedLanes.every((lane) => lane.result?.evidenceCommit !== undefined);
2547
+ const evidenceCommit = allLanesAttest && evidenceCommits.length === 1
2548
+ ? evidenceCommits[0]
2549
+ : undefined;
2381
2550
  const terminal = finishReviewRound(withGroup, decision === "accept" ? "completed" : "failed", summary, now, {
2382
2551
  report: [
2383
2552
  laneReports.join("\n\n") || summary,
@@ -2393,6 +2562,11 @@ function resolveReviewExecutionGroup(args, store, options) {
2393
2562
  ...(evidenceCommit === undefined ? {} : { evidenceCommit })
2394
2563
  });
2395
2564
  tx.saveReviewRound(task.id, terminal);
2565
+ // Issue 06: a panel-resolved completed Round feeds the finding ledger;
2566
+ // a rejected Round is an execution-attempt failure and is skipped.
2567
+ if (terminal.status === "completed") {
2568
+ reconcileReviewFindingsAfterReview(tx, task.id, terminal.id, now);
2569
+ }
2396
2570
  enqueueWork(tx, leaderMailbox(task.id), "review-group-resolved", now, [
2397
2571
  workItemRef(task.id, round.workItemId)
2398
2572
  ]);
@@ -2400,6 +2574,166 @@ function resolveReviewExecutionGroup(args, store, options) {
2400
2574
  });
2401
2575
  return output(`Resolved Review ExecutionGroup ${result.executionGroup?.id ?? "unknown"} as ${decision}; ReviewRound ${result.id} is ${result.status}.\n`, { reviewRound: result });
2402
2576
  }
2577
+ /**
2578
+ * Issue 06: `yui task review finding` — the cross-Round finding ledger CLI.
2579
+ * Findings are extracted automatically from completed Rounds; these commands
2580
+ * let the Leader inspect the ledger, disposition each finding, and plan the
2581
+ * parallel repair wave.
2582
+ */
2583
+ function reviewFindingCommand(args, store, options) {
2584
+ const [command, ...rest] = args;
2585
+ if (command === "list")
2586
+ return listReviewFindings(rest, store, options);
2587
+ if (command === "dispose")
2588
+ return disposeReviewFindingCommand(rest, store, options);
2589
+ if (command === "repair-wave")
2590
+ return planReviewRepairWave(rest, store, options);
2591
+ if (command === "extract")
2592
+ return extractReviewFindingsCommand(rest, store, options);
2593
+ throw usageError(command === undefined
2594
+ ? "Task review finding command is required."
2595
+ : `Unknown command: task review finding ${command}`);
2596
+ }
2597
+ function listReviewFindings(args, store, options) {
2598
+ const usage = "Task review finding list usage: yui task review finding list <task>.";
2599
+ exactPositionals(args, 1, usage);
2600
+ const task = requireTask(store, args[0]);
2601
+ const findings = store.listReviewFindings(task.id);
2602
+ if (findings.length === 0) {
2603
+ return output(`No review findings recorded for ${task.id}.\n`);
2604
+ }
2605
+ const lines = findings.map((finding) => {
2606
+ const repair = finding.repair === undefined
2607
+ ? ""
2608
+ : `; repair: ${finding.repair.workItemId ?? "?"}${finding.repair.commit === undefined ? "" : `@${finding.repair.commit.slice(0, 12)}`}`;
2609
+ const merge = finding.mergeRequired === true ? " [merge-required]" : "";
2610
+ return `${finding.id} [${finding.severity}/${finding.disposition}] ${finding.title}`
2611
+ + ` (invariant: ${finding.invariant}; first: ${finding.firstReviewRoundId}; last: ${finding.lastReviewRoundId})${repair}${merge}`;
2612
+ });
2613
+ return output(`Review findings for ${task.id}:\n${lines.join("\n")}\n`);
2614
+ }
2615
+ function disposeReviewFindingCommand(args, store, options) {
2616
+ const usage = "Task review finding dispose usage: yui task review finding dispose <task>/<finding> "
2617
+ + "--disposition <fixed-pending-review|verified-fixed|accepted-risk|not-actionable|superseded> "
2618
+ + "[--work-item <id>] [--commit <sha>] [--verification <text>] [--note <text>] [--superseded-by <stable-key>].";
2619
+ const parsed = parseTail(args, new Set(["--disposition", "--work-item", "--commit", "--verification", "--note", "--superseded-by"]), usage);
2620
+ exactPositionals(parsed.positionals, 1, usage);
2621
+ const disposition = requiredOption(parsed.options, "--disposition");
2622
+ if (!LEADER_FINDING_DISPOSITIONS.includes(disposition)) {
2623
+ throw usageError(`Review finding disposition is invalid: ${disposition}.`);
2624
+ }
2625
+ const now = clock(options);
2626
+ const reference = resolveTaskRecordReference(parsed.positionals[0], {
2627
+ kind: "reviewFinding",
2628
+ label: "Review finding"
2629
+ });
2630
+ const result = store.transaction((tx) => {
2631
+ const task = requireTask(tx, reference.taskId);
2632
+ if (task.status !== "active")
2633
+ throw usageError(inactiveTaskMessage(task, "dispositioning a review finding"));
2634
+ if (taskActor(options, task.id) !== "leader") {
2635
+ throw usageError("Only the Task Leader may disposition a review finding.");
2636
+ }
2637
+ const command = {
2638
+ disposition,
2639
+ by: taskLeaderActionRunId(tx, task.id, options.environment, options.yuiHome) ?? "leader",
2640
+ ...(parsed.options.get("--note") === undefined ? {} : { note: parsed.options.get("--note") }),
2641
+ ...(parsed.options.get("--work-item") === undefined ? {} : { workItemId: parsed.options.get("--work-item") }),
2642
+ ...(parsed.options.get("--commit") === undefined ? {} : { commit: parsed.options.get("--commit") }),
2643
+ ...(parsed.options.get("--verification") === undefined ? {} : { verification: parsed.options.get("--verification") }),
2644
+ ...(parsed.options.get("--superseded-by") === undefined ? {} : { supersededBy: parsed.options.get("--superseded-by") }),
2645
+ now
2646
+ };
2647
+ return dispositionReviewFinding(tx, task.id, reference.localId, command);
2648
+ });
2649
+ return output(`Dispositioned ${result.id} as ${result.disposition}.\n`);
2650
+ }
2651
+ function planReviewRepairWave(args, store, options) {
2652
+ const usage = "Task review finding repair-wave usage: yui task review finding repair-wave <task> [--create].";
2653
+ const parsed = parseTail(args, new Set(), usage, new Set(["--create"]));
2654
+ exactPositionals(parsed.positionals, 1, usage);
2655
+ const task = requireTask(store, parsed.positionals[0]);
2656
+ const groups = planRepairGroups(store, task.id);
2657
+ if (groups.length === 0) {
2658
+ return output(`No open P1/P2 findings need repair for ${task.id}.\n`);
2659
+ }
2660
+ if (parsed.options.has("--create")) {
2661
+ const now = clock(options);
2662
+ const created = store.transaction((tx) => {
2663
+ const currentTask = requireTask(tx, task.id);
2664
+ if (currentTask.status !== "active") {
2665
+ throw usageError(inactiveTaskMessage(currentTask, "creating a review repair wave"));
2666
+ }
2667
+ if (taskActor(options, currentTask.id) !== "leader") {
2668
+ throw usageError("Only the Task Leader may create a review repair wave.");
2669
+ }
2670
+ const openItems = tx.listWorkItems(currentTask.id)
2671
+ .filter((item) => item.status === "pending" || item.status === "running");
2672
+ return groups.map((group) => {
2673
+ const findingMarkers = group.findingIds.map((id) => `review-finding:${id}`);
2674
+ const existing = openItems.find((item) => isDeepStrictEqual([...item.acceptance].sort(), [...findingMarkers].sort()));
2675
+ if (existing !== undefined)
2676
+ return { group, item: existing, changed: false };
2677
+ const item = createWorkItem(tx.nextWorkItemId(currentTask.id), currentTask.id, {
2678
+ title: `Repair review findings ${group.findingIds.join(", ")}`,
2679
+ objective: [
2680
+ "Repair the following Task-final Review findings as one overlapping group:",
2681
+ ...group.findings.map((finding) => (`- ${finding.id} [${finding.severity}] ${finding.title} `
2682
+ + `(invariant: ${finding.invariant}; evidence: ${finding.evidence.join(" | ") || "see ReviewRound"})`)),
2683
+ "Integrate this repair with the rest of the Task before requesting another Task-final Review."
2684
+ ].join("\n"),
2685
+ acceptance: findingMarkers,
2686
+ writeProjectIds: reviewRepairProjectIds(currentTask, group.affectedPaths)
2687
+ }, now);
2688
+ tx.saveWorkItem(currentTask.id, item);
2689
+ enqueueWork(tx, taskMailbox(currentTask.id), "work-created", now, [
2690
+ workItemRef(currentTask.id, item.id)
2691
+ ]);
2692
+ return { group, item, changed: true };
2693
+ });
2694
+ });
2695
+ const lines = created.map(({ group, item, changed }) => (`wave ${group.groupKey}: ${item.id} ${changed ? "created" : "already open"} `
2696
+ + `(${group.findingIds.join(", ")})`));
2697
+ return output(`Review repair wave for ${task.id} (${groups.length} group(s)):\n${lines.join("\n")}\n`, { groups: created });
2698
+ }
2699
+ const lines = groups.map((group, index) => {
2700
+ const findings = group.findings
2701
+ .map((finding) => `${finding.id} [${finding.severity}] ${finding.title}`)
2702
+ .join("; ");
2703
+ return `wave ${index + 1}: ${findings}`
2704
+ + ` (paths: ${group.affectedPaths.join(", ") || "none"}; invariants: ${group.invariants.join(", ")})`;
2705
+ });
2706
+ return output(`Repair wave for ${task.id} (${groups.length} group(s), run disjoint groups in parallel):\n${lines.join("\n")}\n`);
2707
+ }
2708
+ function reviewRepairProjectIds(task, affectedPaths) {
2709
+ const bindings = task.projectBindings;
2710
+ const matched = bindings.filter((binding) => affectedPaths.some((path) => pathWithinProject(path, binding.directory)));
2711
+ const projectIds = (matched.length > 0 ? matched : bindings).map(({ projectId }) => projectId);
2712
+ return [...new Set(projectIds)];
2713
+ }
2714
+ function pathWithinProject(path, directory) {
2715
+ const normalizedPath = path.replace(/^\.\//u, "").replace(/^\/+/u, "");
2716
+ const normalizedDirectory = directory.replace(/^\.\//u, "").replace(/^\/+|\/+$/gu, "");
2717
+ if (normalizedDirectory.length === 0 || normalizedDirectory === ".")
2718
+ return true;
2719
+ return normalizedPath === normalizedDirectory
2720
+ || normalizedPath.startsWith(`${normalizedDirectory}/`);
2721
+ }
2722
+ function extractReviewFindingsCommand(args, store, options) {
2723
+ const usage = "Task review finding extract usage: yui task review finding extract <task>/<review-round>.";
2724
+ exactPositionals(args, 1, usage);
2725
+ const reference = resolveTaskRecordReference(args[0], {
2726
+ kind: "reviewRound",
2727
+ label: "ReviewRound"
2728
+ });
2729
+ const now = clock(options);
2730
+ const result = store.transaction((tx) => reconcileReviewFindings(tx, reference.taskId, reference.localId, now));
2731
+ if (result.skipped) {
2732
+ return output(`ReviewRound ${result.roundId} produced no findings: ${result.reason ?? "skipped"}\n`);
2733
+ }
2734
+ return output(`Reconciled ${result.created.length + result.updated.length + result.conflicts.length} finding(s) from ${result.roundId}: `
2735
+ + `${result.created.length} created, ${result.updated.length} updated, ${result.conflicts.length} conflict(s).\n`);
2736
+ }
2403
2737
  function requestTaskReviewRound(args, store, options) {
2404
2738
  const usage = "Task review request usage: yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...].";
2405
2739
  const parsed = parseMultiValueTail(args, new Set(["--role", "--strategy"]), new Set(["--lane-role"]), usage);
@@ -2633,9 +2967,12 @@ function retryFailedTaskReviewRound(args, store, options) {
2633
2967
  throw usageError(`ReviewRound ${round.id} is not a failed Task-final ReviewRound.`);
2634
2968
  }
2635
2969
  if (round.reviewerRunId !== undefined) {
2970
+ if (round.status === "completed") {
2971
+ throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
2972
+ }
2636
2973
  throw usageError(`ReviewRound ${round.id} has Reviewer Run ${round.reviewerRunId}; use task run retry instead.`);
2637
2974
  }
2638
- if (round.status !== "failed") {
2975
+ if (round.status !== "failed" && round.status !== "pending") {
2639
2976
  throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
2640
2977
  }
2641
2978
  if (round.taskCandidate === undefined) {
@@ -2667,25 +3004,15 @@ function retryFailedTaskReviewRound(args, store, options) {
2667
3004
  if (roundIndex < 0) {
2668
3005
  throw dataError(`Final ReviewRound is not in Task history: ${round.id}.`);
2669
3006
  }
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);
3007
+ // Issue 06: infra retries reuse the same semantic Round ID. Any later
3008
+ // Round supersedes this one; an active Round for the same Reviewer blocks.
3009
+ const conflictingLater = reviewerRounds.slice(roundIndex + 1).at(-1);
2682
3010
  if (conflictingLater !== undefined) {
2683
3011
  throw usageError(`A newer conflicting final ReviewRound already exists after ${round.id}: `
2684
3012
  + `${conflictingLater.id}/${conflictingLater.status}.`);
2685
3013
  }
2686
- assertNoConflictingTaskReviewRound(reviewerRounds, existingRetry?.id);
3014
+ assertNoConflictingTaskReviewRound(reviewerRounds, round.id);
2687
3015
  const activeRound = reviewerRounds.find((entry) => (entry.id !== round.id
2688
- && entry.id !== existingRetry?.id
2689
3016
  && entry.reviewerRoleName === round.reviewerRoleName
2690
3017
  && (entry.status === "pending" || entry.status === "running")));
2691
3018
  if (activeRound !== undefined) {
@@ -2711,62 +3038,27 @@ function retryFailedTaskReviewRound(args, store, options) {
2711
3038
  || mailbox?.pending !== null && mailbox?.pending !== undefined);
2712
3039
  const activeReviewerRuns = tx.listAgentRuns(task.id).filter((entry) => (entry.roleName === reviewer.name && entry.status === "active"));
2713
3040
  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
3041
  if (activePointer !== null || activeReviewerRuns.length > 0) {
2756
3042
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
2757
3043
  }
2758
3044
  if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
2759
3045
  throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
2760
3046
  }
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);
3047
+ // Issue 06: an already-pending Round is the idempotent retry result.
3048
+ if (round.status === "pending") {
3049
+ return { round, created: false };
3050
+ }
3051
+ // Issue 06: infra retry resets the same semantic Round to pending instead
3052
+ // of manufacturing a new Round, so Round count and finding identity stay
3053
+ // stable across execution-attempt failures.
3054
+ const resetRound = retryTaskReviewRound(round);
3055
+ tx.saveReviewRound(task.id, resetRound);
2763
3056
  recordTaskEvent(tx, task.id, "review.task-final-retried", {
2764
- previousReviewRoundId: round.id,
2765
- nextReviewRoundId: nextRound.id,
3057
+ reviewRoundId: round.id,
2766
3058
  workItemId: round.workItemId,
2767
3059
  candidateId: round.candidateId
2768
3060
  }, now);
2769
- return { round: nextRound, created: true };
3061
+ return { round: resetRound, created: true };
2770
3062
  });
2771
3063
  return output(result.created
2772
3064
  ? `Task-final Review retry requested as ${result.round.id}\n`
@@ -2784,6 +3076,8 @@ function taskRunCommand(args, store, options) {
2784
3076
  return output(recoverRun(rest, store, options));
2785
3077
  if (command === "yield")
2786
3078
  return yieldRun(rest, store, options);
3079
+ if (command === "yield-status")
3080
+ return yieldRunStatus(rest, store, options);
2787
3081
  if (command === "checkpoint")
2788
3082
  return output(checkpointRun(rest, store, options));
2789
3083
  throw usageError(command === undefined
@@ -3436,11 +3730,9 @@ function latestTaskReviewContractAnchor(store, task, taskFinalContract) {
3436
3730
  }
3437
3731
  /**
3438
3732
  * 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
3733
+ * Run remains the attempt trail, while the semantic ReviewRound is reset to
3734
+ * pending under its existing identity. Every identity and frozen-head fence is
3735
+ * checked inside one transaction so a partial fail-old-without-reset state can
3444
3736
  * never be committed.
3445
3737
  */
3446
3738
  function retryFailedReviewRun(previous, store, options, now) {
@@ -3470,13 +3762,12 @@ function retryFailedReviewRun(previous, store, options, now) {
3470
3762
  if (!sameTaskFinalReviewContract(round.taskFinalReviewContract, taskFinalContract)) {
3471
3763
  throw usageError(`Task final-review contract does not match ReviewRound ${round.id}.`);
3472
3764
  }
3473
- if (round.status !== "failed" && round.status !== "running") {
3765
+ if (round.status !== "failed"
3766
+ && round.status !== "running"
3767
+ && round.status !== "pending"
3768
+ && round.status !== "completed") {
3474
3769
  throw usageError(`ReviewRound ${round.id} is not retryable from ${round.status}.`);
3475
3770
  }
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
3771
  const currentTaskCandidate = actualTaskReviewCandidateForMutation(tx, task, options);
3481
3772
  if (!isSameTaskReviewCandidate(currentTaskCandidate, round.taskCandidate)) {
3482
3773
  throw usageError(`Task-final ReviewRound ${round.id} no longer matches the latest committed Integration heads.`);
@@ -3499,27 +3790,9 @@ function retryFailedReviewRun(previous, store, options, now) {
3499
3790
  if (laterRound !== undefined && !isSameTaskReviewCandidate(laterRound.taskCandidate, round.taskCandidate)) {
3500
3791
  throw usageError(`A newer final Task candidate already has ReviewRound ${laterRound.id}.`);
3501
3792
  }
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
3793
  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])]);
3794
+ assertNoConflictingTaskReviewRound(tx.listReviewRounds(task.id), round.id);
3521
3795
  const activeRound = allReviewerRounds.find((entry) => (entry.id !== round.id
3522
- && entry.id !== existingRetry?.id
3523
3796
  && (entry.status === "pending" || entry.status === "running")));
3524
3797
  if (activeRound !== undefined) {
3525
3798
  throw usageError(`Reviewer already has an active review round for this candidate: ${activeRound.id}.`);
@@ -3536,39 +3809,21 @@ function retryFailedReviewRun(previous, store, options, now) {
3536
3809
  const reviewer = requireRole(tx, task.id, round.reviewerRoleName);
3537
3810
  const activePointer = tx.getActiveAgentRun(task.id, reviewer.name);
3538
3811
  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.
3812
+ // Issue 06: a completed same-Round retry is a no-write idempotent result.
3813
+ if (round.status === "completed") {
3559
3814
  if (activePointer !== null || activeReviewerRuns.length > 0) {
3560
3815
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
3561
3816
  }
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}.`);
3817
+ if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
3818
+ throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
3567
3819
  }
3568
- return { round: existingRetry, previousRun: run };
3820
+ return { round, previousRun: run, created: false };
3569
3821
  }
3570
- if (existingRetry?.status === "running") {
3571
- const reviewerRunId = existingRetry.reviewerRunId;
3822
+ // Issue 06: a running same Round is reusable only with its exact active
3823
+ // Run and mailbox execution. A stranded Run (no active pointer) falls
3824
+ // through and resets the Round after the identity fences below.
3825
+ if (round.status === "running") {
3826
+ const reviewerRunId = round.reviewerRunId;
3572
3827
  const activeMatches = reviewerRunId !== undefined
3573
3828
  && activePointer !== null
3574
3829
  && activePointer.id === reviewerRunId
@@ -3583,12 +3838,24 @@ function retryFailedReviewRun(previous, store, options, now) {
3583
3838
  && reviewerMailbox?.processing === null
3584
3839
  && reviewerMailbox.pending?.requestCount === 1
3585
3840
  && 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.`);
3841
+ if (activePointer !== null
3842
+ && (!activeMatches
3843
+ || (!processingMatches && !pendingMatches)
3844
+ || hasMailboxWork(runtimeMailbox))) {
3845
+ throw usageError(`Existing running ReviewRound ${round.id} lacks its exact active Reviewer execution.`);
3846
+ }
3847
+ if (activeMatches)
3848
+ return { round, previousRun: run, created: false };
3849
+ }
3850
+ // Issue 06: an already-pending Round is the idempotent retry result.
3851
+ if (round.status === "pending") {
3852
+ if (activePointer !== null || activeReviewerRuns.length > 0) {
3853
+ throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
3590
3854
  }
3591
- return { round: existingRetry, previousRun: run };
3855
+ if (hasMailboxWork(reviewerMailbox) || hasMailboxWork(runtimeMailbox)) {
3856
+ throw usageError(`Reviewer has unrelated mailbox work: ${reviewer.name}.`);
3857
+ }
3858
+ return { round, previousRun: run, created: false };
3592
3859
  }
3593
3860
  if (activePointer !== null || activeReviewerRuns.length > 0) {
3594
3861
  throw usageError(`Reviewer Role already has an active run: ${reviewer.name}.`);
@@ -3599,6 +3866,10 @@ function retryFailedReviewRun(previous, store, options, now) {
3599
3866
  if (runtimeMailbox?.pending !== null && runtimeMailbox?.pending !== undefined) {
3600
3867
  throw usageError(`Reviewer runtime lifecycle has pending work: ${reviewer.name}.`);
3601
3868
  }
3869
+ const validation = validateExactRunReviewRound(tx, run, { allowTerminal: true });
3870
+ if (validation.disposition !== "applied" || validation.round === null) {
3871
+ throw usageError(`Review Run ${run.id} identity does not match its ReviewRound or frozen Task state changed: ${validation.reason ?? "mismatch"}.`);
3872
+ }
3602
3873
  // A stranded pre-delivery Run can leave its exact pending or processing
3603
3874
  // dispatch behind; settle only that exact reference while holding the
3604
3875
  // same aggregate lock. Any merged or unrelated batch fails closed.
@@ -3622,27 +3893,31 @@ function retryFailedReviewRun(previous, store, options, now) {
3622
3893
  }
3623
3894
  // Terminalize the old stranded Round only after every identity and mailbox
3624
3895
  // fence has passed. The outer transaction rolls back if Round creation fails.
3896
+ let roundToReset = round;
3625
3897
  if (round.status !== "failed") {
3626
3898
  const summary = round.summary
3627
3899
  ?? run.summary
3628
3900
  ?? `Review Run ${run.id} failed before delivery.`;
3629
- tx.saveReviewRound(task.id, finishReviewRound(round, "failed", summary, now, {
3901
+ roundToReset = finishReviewRound(round, "failed", summary, now, {
3630
3902
  report: round.report ?? summary,
3631
3903
  checks: round.checks ?? [],
3632
3904
  ...(round.evidenceCommit === undefined ? {} : { evidenceCommit: round.evidenceCommit })
3633
- }));
3905
+ });
3906
+ tx.saveReviewRound(task.id, roundToReset);
3634
3907
  }
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);
3908
+ // Issue 06: infra retry resets the same semantic Round to pending instead
3909
+ // of manufacturing a new Round, so Round count and finding identity stay
3910
+ // stable across execution-attempt failures.
3911
+ const resetRound = retryTaskReviewRound(roundToReset);
3912
+ tx.saveReviewRound(task.id, resetRound);
3637
3913
  recordTaskEvent(tx, task.id, "run.review-retried", {
3638
3914
  runId: run.id,
3639
3915
  reviewRoundId: round.id,
3640
- nextReviewRoundId: nextRound.id,
3641
3916
  candidateId: candidate.id
3642
3917
  }, now);
3643
- return { round: nextRound, previousRun: run };
3918
+ return { round: resetRound, previousRun: run, created: true };
3644
3919
  });
3645
- return output(result.round.status === "pending"
3920
+ return output(result.created
3646
3921
  ? `Review retry requested as ${result.round.id}\n`
3647
3922
  : `Review retry already requested as ${result.round.id} (${result.round.status})\n`, { reviewRound: result.round });
3648
3923
  }
@@ -3722,12 +3997,111 @@ function recoverRun(args, store, options) {
3722
3997
  : "";
3723
3998
  return `Recorded exact ${result.action} recovery for ${result.run?.id ?? "unknown Run"}.${followup}\n`;
3724
3999
  }
4000
+ /**
4001
+ * Issue 04: builds the terminal yield outcome from the command inputs. The
4002
+ * same construction feeds both the first commit and the idempotent replay, so
4003
+ * a resend always hashes to the same digest.
4004
+ */
4005
+ function buildYieldOutcome(run, inputSummary, options) {
4006
+ let yieldedReport;
4007
+ if (run.purpose === "review"
4008
+ || (run.purpose === "execution" && run.executionGroupId !== undefined)) {
4009
+ yieldedReport = parseReviewYieldReport(inputSummary);
4010
+ }
4011
+ const summary = yieldedReport?.summary ?? inputSummary;
4012
+ if (yieldedReport === undefined)
4013
+ return { summary };
4014
+ return {
4015
+ summary,
4016
+ reviewResult: {
4017
+ report: yieldedReport.report,
4018
+ checks: yieldedReport.checks,
4019
+ ...(yieldedReport.findings === undefined ? {} : { findings: yieldedReport.findings }),
4020
+ ...(yieldedReport.evidence === undefined ? {} : { evidence: yieldedReport.evidence }),
4021
+ ...(run.purpose === "review"
4022
+ && options.reviewWorkspaceResult?.evidenceCommit === undefined
4023
+ ? {}
4024
+ : run.purpose === "review"
4025
+ ? { evidenceCommit: options.reviewWorkspaceResult.evidenceCommit }
4026
+ : yieldedReport.evidenceCommit === undefined
4027
+ ? {}
4028
+ : { evidenceCommit: yieldedReport.evidenceCommit }),
4029
+ ...(options.executionLaneGitSnapshot === undefined
4030
+ || options.executionLaneGitSnapshot === null
4031
+ ? {}
4032
+ : { gitSnapshot: options.executionLaneGitSnapshot })
4033
+ }
4034
+ };
4035
+ }
4036
+ /**
4037
+ * Issue 04: replays an already-committed yield. Returns the committed receipt
4038
+ * for the same outcome, fails closed for a different outcome, or returns
4039
+ * `null` to keep the legacy "already terminal" behavior.
4040
+ */
4041
+ function replayYieldReceipt(run, inputSummary, options) {
4042
+ const config = providerRetryConfig(options.environment ?? process.env);
4043
+ if (!config.yieldReceiptReplay)
4044
+ return null;
4045
+ if (run.yieldReceipt === undefined)
4046
+ return null;
4047
+ const outcome = buildYieldOutcome(run, inputSummary, options);
4048
+ const match = matchYieldReceipt(run.yieldReceipt, {
4049
+ status: "yielded",
4050
+ summary: outcome.summary,
4051
+ ...(outcome.reviewResult === undefined ? {} : { reviewResult: outcome.reviewResult })
4052
+ });
4053
+ if (match === null)
4054
+ return null;
4055
+ if (match.kind === "digest-mismatch") {
4056
+ throw usageError(`Run ${run.id} is already terminal with a different yield outcome. `
4057
+ + `Existing receipt: ${match.existing.receiptId} (request ${match.existing.requestId}).`);
4058
+ }
4059
+ return output(`Run ${run.id} yield already committed.\n`
4060
+ + `Receipt: ${match.receipt.receiptId}\n`
4061
+ + `Request: ${match.receipt.requestId}\n`, { receipt: match.receipt });
4062
+ }
4063
+ /**
4064
+ * Issue 04: `yui task run yield-status <task>/<run>` — returns the committed
4065
+ * yield receipt for a terminal Run, or the current status for an active Run.
4066
+ */
4067
+ function yieldRunStatus(args, store, options) {
4068
+ const usage = "Task run yield-status usage: yui task run yield-status <task>/<run>.";
4069
+ const parsed = parseTail(args, new Set(), usage);
4070
+ exactPositionals(parsed.positionals, 1, usage);
4071
+ const run = requireRun(store, parsed.positionals[0], options);
4072
+ if (run.status === "active") {
4073
+ return output(`Run ${run.id} is active; no yield receipt yet.\n`, {
4074
+ runId: run.id,
4075
+ status: run.status
4076
+ });
4077
+ }
4078
+ if (run.yieldReceipt === undefined) {
4079
+ return output(`Run ${run.id} is ${run.status}; no yield receipt recorded.\n`, {
4080
+ runId: run.id,
4081
+ status: run.status
4082
+ });
4083
+ }
4084
+ return output(`Run ${run.id} yield receipt:\n`
4085
+ + `Receipt: ${run.yieldReceipt.receiptId}\n`
4086
+ + `Request: ${run.yieldReceipt.requestId}\n`
4087
+ + `Committed: ${run.yieldReceipt.committedAt}\n`, { receipt: run.yieldReceipt });
4088
+ }
3725
4089
  function yieldRun(args, store, options) {
3726
4090
  const usage = "Task run yield usage: yui task run yield <task>/<run> (--summary <text>|--summary-file <path|->).";
3727
4091
  const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage);
3728
4092
  exactPositionals(parsed.positionals, 1, usage);
3729
4093
  const inputSummary = readCommandText(parsed.options.get("--summary"), parsed.options.get("--summary-file"), "--summary", usage);
3730
4094
  const now = clock(options);
4095
+ // Issue 04: an already-terminal Run may be a lost-response resend. Match
4096
+ // the presented outcome against the committed receipt before opening the
4097
+ // transaction; the receipt is immutable once committed.
4098
+ const existing = requireRun(store, parsed.positionals[0], options);
4099
+ if (existing.status !== "active") {
4100
+ const replayed = replayYieldReceipt(existing, inputSummary, options);
4101
+ if (replayed !== null)
4102
+ return replayed;
4103
+ throw usageError(`Run ${existing.id} is already terminal: ${existing.status}.`);
4104
+ }
3731
4105
  const yielded = store.transaction((tx) => {
3732
4106
  const active = requireRun(tx, parsed.positionals[0], options);
3733
4107
  if (active.status !== "active") {
@@ -3775,7 +4149,8 @@ function yieldRun(args, store, options) {
3775
4149
  throw usageError(`Reported Review evidence commit does not match the managed workspace: ${active.id}.`);
3776
4150
  }
3777
4151
  }
3778
- const summary = yieldedReport?.summary ?? inputSummary;
4152
+ const yieldOutcome = buildYieldOutcome(active, inputSummary, options);
4153
+ const summary = yieldOutcome.summary;
3779
4154
  const terminalization = terminalizeExactTaskRun(tx, {
3780
4155
  taskId: task.id,
3781
4156
  roleName: role.name,
@@ -3789,28 +4164,9 @@ function yieldRun(args, store, options) {
3789
4164
  ? {}
3790
4165
  : { launchId: options.environment.YUI_LAUNCH_ID }),
3791
4166
  outcome: { status: "yielded", summary },
3792
- ...(yieldedReport === undefined
4167
+ ...(yieldOutcome.reviewResult === undefined
3793
4168
  ? {}
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
- })
4169
+ : { reviewResult: yieldOutcome.reviewResult })
3814
4170
  }, now);
3815
4171
  if (terminalization.disposition !== "applied" || terminalization.run === null) {
3816
4172
  throw usageError(`Run ${active.id} no longer matches its exact execution fence: `
@@ -3978,6 +4334,21 @@ function yieldRun(args, store, options) {
3978
4334
  ...(terminal.workItemId === undefined ? [] : [workItemRef(task.id, terminal.workItemId)])
3979
4335
  ]);
3980
4336
  }
4337
+ // Issue 05: record the Leader's terminal receipt so the Scheduler can
4338
+ // suppress no-change `task-orphaned` wakes. The disposition and digest are
4339
+ // machine-derived from the post-yield projection; the Leader does not
4340
+ // need to cooperate for the admission check to work.
4341
+ if (role.name === LEADER_ROLE) {
4342
+ const receipt = leaderYieldReceipt(tx, task, terminal, now);
4343
+ if (receipt !== null) {
4344
+ tx.saveAgentRun(receipt);
4345
+ return {
4346
+ run: receipt,
4347
+ reviewDispatch,
4348
+ notifyLeader: leaderHandoff !== null
4349
+ };
4350
+ }
4351
+ }
3981
4352
  return {
3982
4353
  run: terminal,
3983
4354
  reviewDispatch,
@@ -3998,6 +4369,47 @@ function yieldRun(args, store, options) {
3998
4369
  : { reviewRound: yielded.reviewDispatch.round })
3999
4370
  });
4000
4371
  }
4372
+ /**
4373
+ * Issue 05: compute the Leader Run terminal receipt (disposition + observed
4374
+ * actionability digest) from the post-yield projection. Returns the updated
4375
+ * Run, or null when the receipt cannot be computed (the caller keeps the
4376
+ * unmodified terminal Run in that case; the Scheduler fails open).
4377
+ */
4378
+ function leaderYieldReceipt(tx, task, terminal, now) {
4379
+ try {
4380
+ const projection = buildTaskExecutionProjection(tx, task.id, task);
4381
+ if (projection === null)
4382
+ return null;
4383
+ const disposition = deriveLeaderRunDisposition(projection.status, task.status);
4384
+ const digest = computeActionabilityDigest(collectTaskActionability(tx, task.id));
4385
+ const waitReason = disposition === "waiting" || disposition === "blocked"
4386
+ ? leaderWaitReason(projection)
4387
+ : undefined;
4388
+ return {
4389
+ ...terminal,
4390
+ disposition,
4391
+ observedActionabilityDigest: digest,
4392
+ ...(waitReason === undefined ? {} : { waitReason }),
4393
+ updatedAt: now.toISOString()
4394
+ };
4395
+ }
4396
+ catch {
4397
+ return null;
4398
+ }
4399
+ }
4400
+ function leaderWaitReason(projection) {
4401
+ const blocker = projection.blockers[0];
4402
+ if (blocker !== undefined) {
4403
+ return { kind: blocker.kind, ref: blocker.id };
4404
+ }
4405
+ if (projection.status === "waiting-on-agents") {
4406
+ return { kind: "delegated-work" };
4407
+ }
4408
+ if (projection.status === "waiting-user") {
4409
+ return { kind: "input" };
4410
+ }
4411
+ return undefined;
4412
+ }
4001
4413
  /**
4002
4414
  * Records a structured progress checkpoint for an active Run. This is a durable
4003
4415
  * run fact, not a Task Message: it advances the Run's durable-progress clock so
@@ -4222,6 +4634,9 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4222
4634
  .map(({ projectId, commit }) => `${projectId}@${commit}`)
4223
4635
  .join(", ")
4224
4636
  : `candidate@${round.reviewBaseCommit}`;
4637
+ const findingContext = taskScope
4638
+ ? buildTaskFinalReviewFindingContext(tx, taskId, round.taskCandidate).context
4639
+ : "";
4225
4640
  const scopeLabel = taskScope ? "Task-final" : "WorkItem";
4226
4641
  const projectPolicyPointers = task.projectBindings
4227
4642
  .map(({ projectId }) => (`yui project show ${projectId}; yui project knowledge list ${projectId}`))
@@ -4238,6 +4653,7 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4238
4653
  `Review workspace source: exact workspace attached to this Reviewer Lane`,
4239
4654
  `Candidate summary: ${candidate.summary}`,
4240
4655
  `Acceptance criteria: ${item.acceptance.length === 0 ? "none" : item.acceptance.join("; ")}`,
4656
+ ...(taskScope ? [findingContext] : []),
4241
4657
  "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
4658
  "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
4659
  ...(round.scope === "task"