@zq-silk/yui 0.6.13 → 0.6.14

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 (90) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  77. package/dist/task/completionReadiness.js +282 -0
  78. package/dist/task/publicationReference.js +123 -0
  79. package/dist/task/taskRecordReference.js +3 -1
  80. package/dist/telemetry/telemetryConfig.js +23 -18
  81. package/dist/telemetry/telemetryWiring.js +8 -8
  82. package/dist/tmux/tmuxManager.js +50 -9
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/view.js +18 -0
  85. package/dist/web/webSnapshot.js +100 -10
  86. package/i18n/README.zh-CN.md +5 -5
  87. package/package.json +1 -1
  88. package/skills/yui-leader/SKILL.md +49 -10
  89. package/skills/yui-operator/SKILL.md +13 -3
  90. package/skills/yui-worker/SKILL.md +8 -0
@@ -15,22 +15,27 @@ import { recoverExactAgentRun, terminalizeExactTaskRun, validateExactRunReviewRo
15
15
  import { resetTaskRoleSessionGeneration } from "../lifecycle/taskRoleSessionReset.js";
16
16
  import { activeRoleAgentBinding, copyGlobalRoleToTaskRole, createRole, createRoleAgentBinding, switchActiveRoleAgent, unbindRoleAgent, updateRole, updateRoleStatus } from "../role/role.js";
17
17
  import { createAgentRun } from "../run/agentRun.js";
18
+ import { projectRunRecovery, readRunRecoveryFacts } from "../run/recoveryProjection.js";
18
19
  import { matchYieldReceipt } from "../run/yieldReceipt.js";
19
20
  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";
21
+ import { createReviewRound, createTaskReviewRound, createTaskDeltaReviewRound, attachReviewExecutionGroup, deltaRecheckBlocksAcceptance, finishReviewRound, parseReviewYieldReport, recordReviewWorkspaceDisposition, retryTaskReviewRound, startReviewRound, updateReviewExecutionGroup, validateTaskReviewCandidate } from "../review/reviewRound.js";
22
+ import { buildDeltaRecheckDispatchContext, verifyDeltaRecheckDiff } from "../review/deltaRecheck.js";
23
+ import { buildTaskFinalReviewFindingContext, dispositionReviewFinding, planRepairGroups, reconcileReviewFindings, reconcileReviewFindingsAfterReview } from "../review/reviewFindingLedger.js";
22
24
  import { LEADER_FINDING_DISPOSITIONS } from "../review/reviewFinding.js";
23
25
  import { markYuiRunInput, retagYuiRunInput } from "../run/runIdentity.js";
24
26
  import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
25
27
  import { createTaskBrief, updateTaskBrief } from "../brief/taskBrief.js";
26
28
  import { createDecision, supersedeDecision } from "../decision/decision.js";
27
29
  import { createMilestone } from "../milestone/milestone.js";
30
+ import { runPublicationCommand } from "./taskPublicationCommands.js";
28
31
  import { enqueueWork, settleExactWorkExecution } from "../coordination/workMailboxQueue.js";
29
32
  import { mailboxHasWork as workMailboxHasWork, nextPendingBatch } from "../coordination/workMailbox.js";
30
33
  import { RUNTIME_CLEANUP_REQUIRED_REASON, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
31
- import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
34
+ import { blockingProviderContinuations, projectProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
35
+ import { runtimeObservationFromTaskEvent } from "../runtime/runtimeObservation.js";
32
36
  import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createTask, retireTask, reopenTask, updateTaskMetadata } from "../task/task.js";
33
37
  import { formatAgentRunReceiptId, resolveTaskRecordReference } from "../task/taskRecordReference.js";
38
+ import { projectCompletionReadiness } from "../task/completionReadiness.js";
34
39
  import { resolveProject } from "../repository/project.js";
35
40
  import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
36
41
  import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
@@ -43,13 +48,14 @@ import { assertRoleRuntimeMutationAllowed } from "./roleRuntimeGuard.js";
43
48
  import { runTaskContextCommand } from "./taskContextCommand.js";
44
49
  import { runTaskNextActionCommand } from "./taskNextActionCommand.js";
45
50
  import { runDeliveryGuardPreflight, withGuardWarnings } from "./deliveryGuardPreflight.js";
46
- import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel } from "./taskRoleRuntimeStatus.js";
51
+ import { inspectTaskRoleRuntimeStatuses, renderTaskRoleRuntimeStatus, taskRoleActiveWorkLabel, taskRoleLastRunLabel, taskRoleNativeSessionLabel, taskRoleOpenInputLabel, taskRoleTmuxLabel } from "./taskRoleRuntimeStatus.js";
47
52
  import { assertNoOpenInputRequests, openInputRequestCount, runTaskInputCommand } from "./taskInputCommands.js";
48
53
  import { runGrantCommand } from "./grantCommands.js";
49
54
  import { runWorkflowCommand } from "./workflowCommands.js";
50
55
  import { taskActor as resolveTaskActor, taskLeaderActionRunId } from "./taskActor.js";
51
56
  import { createTaskTerminalNotification } from "../scheduler/operatorNotification.js";
52
57
  import { queueLeaderWakeup } from "../scheduler/wakeupQueue.js";
58
+ import { renderWakeReason, wakeReason } from "../scheduler/wakeReason.js";
53
59
  import { collectTaskActionability, computeActionabilityDigest, deriveLeaderRunDisposition } from "../scheduler/actionability.js";
54
60
  import { buildTaskExecutionProjection } from "../scheduler/taskExecutionProjection.js";
55
61
  import { buildTaskOverview, parseTaskListOptions, renderTaskOverview } from "./taskOverviewCommand.js";
@@ -114,8 +120,8 @@ function taskFinalReviewContractForMutation(store, taskId, options) {
114
120
  return stored;
115
121
  }
116
122
  export function parseTaskCompletionRequest(args, summaryOverride) {
117
- const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->).";
118
- const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage);
123
+ const usage = "Task complete usage: yui task complete <id> (--summary <text>|--summary-file <path|->) [--refresh-remote].";
124
+ const parsed = parseTail(args, new Set(["--summary", "--summary-file"]), usage, new Set(["--refresh-remote"]));
119
125
  exactPositionals(parsed.positionals, 1, usage);
120
126
  const inlineSummary = parsed.options.get("--summary");
121
127
  const summaryFile = parsed.options.get("--summary-file");
@@ -131,6 +137,11 @@ export function parseTaskCompletionRequest(args, summaryOverride) {
131
137
  * path invokes this same preflight and then repeats its checks while holding
132
138
  * the store write fence, so remote reconciliation can never get ahead of the
133
139
  * local lifecycle/readiness gate.
140
+ *
141
+ * Issue 06: the blocker enumeration is the pure `projectCompletionReadiness`
142
+ * projection, shared with `task next-action` so the Leader sees every
143
+ * terminalization precondition before attempting completion. All blockers
144
+ * are reported in one error instead of one per attempt.
134
145
  */
135
146
  export function preflightTaskCompletion(taskId, store, options = {}) {
136
147
  const task = requireTask(store, taskId);
@@ -148,64 +159,27 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
148
159
  const taskFinalReviewContract = taskFinalReviewContractForMutation(store, task.id, options);
149
160
  const activeTaskReview = store.listReviewRounds(task.id).some((round) => ((round.scope ?? "work-item") === "task"
150
161
  && (round.status === "pending" || round.status === "running")));
151
- assertNoOpenInputRequests(store, task.id, "completing the Task");
152
- const incompleteWork = store.listWorkItems(task.id).find((item) => (item.status !== "completed"
153
- && item.status !== "retired"));
154
- if (incompleteWork !== undefined) {
155
- throw usageError(`Task ${task.id} has unaccepted work: ${incompleteWork.id}/${incompleteWork.status}.`);
156
- }
157
- const activeJob = store.listDurableJobs(task.id).find((job) => (job.status === "queued"
158
- || job.status === "running"
159
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined)));
160
- if (activeJob !== undefined) {
161
- throw usageError(`Task ${task.id} has an active DurableJob: ${activeJob.id}/${activeJob.status}.`);
162
- }
163
- if (task.requireIntegration) {
164
- if (store.listWorkItems(task.id).length === 0) {
165
- throw usageError(`Task ${task.id} requires at least one WorkItem before completion.`);
166
- }
167
- if (store.listChangeSets(task.id).length === 0) {
168
- throw usageError(`Task ${task.id} requires at least one ChangeSet before completion.`);
169
- }
170
- if (!store.listIntegrationAttempts(task.id).some(({ status }) => status === "committed")) {
171
- throw usageError(`Task ${task.id} requires a committed Integration Attempt before completion.`);
172
- }
173
- }
174
- const unresolvedIntegration = store.listIntegrationAttempts(task.id).find((integration) => (integration.status === "running"
175
- || integration.status === "blocked"
176
- || integration.status === "validating"));
177
- if (unresolvedIntegration !== undefined) {
178
- throw usageError(`Task ${task.id} has an unresolved Integration Attempt: ${unresolvedIntegration.id}.`);
179
- }
180
- const unsettledQueueEntry = store.listIntegrationQueueEntries(task.id).find((entry) => (entry.status !== "committed" && entry.status !== "superseded"));
181
- if (unsettledQueueEntry !== undefined) {
182
- throw usageError(`Task ${task.id} has an unsettled integration queue entry: `
183
- + `${unsettledQueueEntry.id}/${unsettledQueueEntry.status}.`);
184
- }
185
- const continuationBlockers = blockingProviderContinuations(store.listEvents(task.id));
186
- if (continuationBlockers.length > 0) {
187
- throw usageError(`Task ${task.id} has ${continuationBlockers.length} Provider continuation(s) `
188
- + "that may still write the Workspace or have an identity conflict.");
189
- }
190
- const isolatedWorkspace = store.listManagedWorkspaces(task.id)
191
- .find(({ owner }) => owner.type === "work-item");
192
- if (isolatedWorkspace?.owner.type === "work-item") {
193
- throw usageError(`Task ${task.id} has an isolated WorkItem workspace: `
194
- + `${isolatedWorkspace.owner.workItemId}. Capture, integrate or abandon it, then clean it up.`);
162
+ // Issue 06: one shared readiness projection enumerates every blocker.
163
+ const readinessFacts = store.readCompletionReadinessFacts(task.id);
164
+ if (readinessFacts === null)
165
+ throw taskNotFound(task.id);
166
+ // The finding ledger gate is intentionally deferred: the transactional
167
+ // completion path runs it after `prepareFinalTaskReview`, which may create
168
+ // the Task-final Review that resolves `fixed-pending-review` findings.
169
+ const readiness = projectCompletionReadiness(readinessFacts, { findingsGate: false });
170
+ // An active Task-final Review is not a preflight failure: the transactional
171
+ // path resumes a pending Round (or reports the running one) via
172
+ // `prepareFinalTaskReview`, and the CLI skips remote reconciliation while
173
+ // `activeTaskReview` is true. The blocker stays in the shared projection
174
+ // so other surfaces (next-action, future readers) see the full rule set.
175
+ const blockers = readiness.blockers.filter((blocker) => blocker.code !== "active-task-review");
176
+ if (blockers.length > 0) {
177
+ throw usageError(formatCompletionBlockers(task.id, blockers));
195
178
  }
196
179
  const roles = store.listRoles(task.id);
197
180
  const activeRuns = roles
198
181
  .map((role) => ({ role, run: store.getActiveAgentRun(task.id, role.name) }))
199
182
  .filter((entry) => entry.run !== null);
200
- const workerRun = activeRuns.find(({ role }) => role.name !== LEADER_ROLE);
201
- if (workerRun !== undefined) {
202
- throw usageError(`Task ${task.id} has an active run for Role ${workerRun.role.name}.`);
203
- }
204
- const unsettledWork = store.listWorkItems(task.id)
205
- .find((item) => item.status === "pending" || item.status === "running");
206
- if (unsettledWork !== undefined) {
207
- throw usageError(`Task ${task.id} has unsettled work: ${unsettledWork.id}.`);
208
- }
209
183
  const leaderEntry = activeRuns.find(({ role }) => role.name === LEADER_ROLE);
210
184
  if (leaderEntry !== undefined) {
211
185
  if (actor !== "leader") {
@@ -226,6 +200,15 @@ export function preflightTaskCompletion(taskId, store, options = {}) {
226
200
  ...(taskFinalReviewContract === undefined ? {} : { taskFinalReviewContract })
227
201
  };
228
202
  }
203
+ /**
204
+ * Issue 06: format every completion blocker into one fail-closed error so the
205
+ * Leader sees the full remaining work instead of one blocker per attempt.
206
+ */
207
+ function formatCompletionBlockers(taskId, blockers) {
208
+ const lines = blockers.map((blocker) => ` ${blocker.code} (${blocker.ref.kind} ${blocker.ref.id}): ${blocker.reason}`
209
+ + ` — fix: ${blocker.fix}`);
210
+ return `Task ${taskId} cannot complete: ${blockers.length} blocker(s) remain.\n${lines.join("\n")}`;
211
+ }
229
212
  export function runTaskCommand(args, store, options = {}) {
230
213
  const [command, ...rest] = args;
231
214
  switch (command) {
@@ -242,11 +225,12 @@ export function runTaskCommand(args, store, options = {}) {
242
225
  case "retire": return retireTaskCommand(rest, store, options);
243
226
  case "reconcile": return output(reconcileTaskCommand(rest, store, options));
244
227
  case "message": return output(taskMessageCommand(rest, store, options));
245
- case "wake": return output(taskWakeCommand(rest, store, options));
228
+ case "wake": return taskWakeDispatch(rest, store, options);
246
229
  case "project": return taskProjectCommand(rest, store, options);
247
230
  case "input": return runTaskInputCommand(rest, store, options);
248
231
  case "grant": return runGrantCommand(rest, store, options);
249
232
  case "workflow": return runWorkflowCommand(rest, store, options);
233
+ case "publication": return runPublicationCommand(rest, store, options);
250
234
  case "role": return taskRoleCommand(rest, store, options);
251
235
  case "work": return taskWorkCommand(rest, store, options);
252
236
  case "review": return taskReviewCommand(rest, store, options);
@@ -255,6 +239,7 @@ export function runTaskCommand(args, store, options = {}) {
255
239
  case "decision": return taskDecisionCommand(rest, store, options);
256
240
  case "milestone": return taskMilestoneCommand(rest, store, options);
257
241
  case "event": return taskEventCommand(rest, store);
242
+ case "continuation": return taskContinuationCommand(rest, store);
258
243
  case "enter": return enterTaskRoleAlias(rest, store, options);
259
244
  default:
260
245
  throw usageError(command === undefined
@@ -571,6 +556,8 @@ function showTaskCommand(args, store) {
571
556
  const work = store.listWorkItems(task.id);
572
557
  const changeSets = store.listChangeSets(task.id);
573
558
  const integrations = store.listIntegrationAttempts(task.id);
559
+ const publications = store.listPublicationReferences(task.id);
560
+ const verifiedMergedPublications = publications.filter((reference) => (reference.state === "merged" && reference.verification === "verified")).length;
574
561
  const counts = {
575
562
  messages: messages.length,
576
563
  decisions: decisions.length,
@@ -580,6 +567,7 @@ function showTaskCommand(args, store) {
580
567
  agentRuns: store.listAgentRuns(task.id).length,
581
568
  changeSets: changeSets.length,
582
569
  integrations: integrations.length,
570
+ publications: publications.length,
583
571
  openInputs
584
572
  };
585
573
  const timeZone = store.getConfig().timeZone;
@@ -615,6 +603,7 @@ function showTaskCommand(args, store) {
615
603
  `Agent Runs: ${counts.agentRuns}`,
616
604
  `ChangeSets: ${counts.changeSets}`,
617
605
  `Integration Attempts: ${counts.integrations}`,
606
+ `Publication references: ${counts.publications} (${verifiedMergedPublications} verified merged)`,
618
607
  `Open inputs: ${counts.openInputs}`,
619
608
  `Created: ${presentTime(task.createdAt, timeZone)}`,
620
609
  `Updated: ${presentTime(task.updatedAt, timeZone)}`
@@ -673,20 +662,10 @@ function completeTaskCommand(args, store, options) {
673
662
  const activeRuns = roles
674
663
  .map((role) => ({ role, run: tx.getActiveAgentRun(task.id, role.name) }))
675
664
  .filter((entry) => entry.run !== null);
676
- const workerRun = activeRuns.find(({ role }) => role.name !== LEADER_ROLE);
677
- if (workerRun !== undefined) {
678
- throw usageError(`Task ${task.id} has an active run for Role ${workerRun.role.name}.`);
679
- }
680
- const unsettledWork = tx.listWorkItems(task.id)
681
- .find((item) => item.status === "pending" || item.status === "running");
682
- if (unsettledWork !== undefined) {
683
- throw usageError(`Task ${task.id} has unsettled work: ${unsettledWork.id}.`);
684
- }
685
- const continuationBlockers = blockingProviderContinuations(tx.listEvents(task.id));
686
- if (continuationBlockers.length > 0) {
687
- throw usageError(`Task ${task.id} has ${continuationBlockers.length} Provider continuation(s) `
688
- + "that may still write the Workspace or have an identity conflict.");
689
- }
665
+ // The preflight (above, same transaction) already projected the full
666
+ // readiness via projectCompletionReadiness. The leader-Run check is
667
+ // actor-dependent and stays here; every other blocker is re-validated by
668
+ // the post-Review readiness fence below.
690
669
  let terminalizedLeaderRun = false;
691
670
  const leaderEntry = activeRuns.find(({ role }) => role.name === LEADER_ROLE);
692
671
  if (leaderEntry !== undefined) {
@@ -730,21 +709,18 @@ function completeTaskCommand(args, store, options) {
730
709
  terminalizedLeaderRun
731
710
  };
732
711
  }
733
- // Issue 06: under `review.findingLedger=enforce`, Task completion fails
734
- // closed on undispositioned open P1/P2 findings. Run this after final-review
735
- // preparation so `fixed-pending-review` findings can still request the one
736
- // re-review for a changed frozen head; once no Review is needed, the gate
737
- // blocks final completion.
738
- if (completionGateBlocked(tx, task.id)) {
739
- const blocking = blockingOpenFindings(tx, task.id);
740
- if (reviewFindingLedgerWriteFailed(tx, task.id)) {
741
- throw usageError(`Task ${task.id} cannot complete: the Review finding ledger was unavailable `
742
- + "while reconciling a semantic Review. Recover the ledger and reconcile the Round "
743
- + "before completing the Task.");
744
- }
745
- throw usageError(`Task ${task.id} has ${blocking.length} undispositioned open P1/P2 finding(s): `
746
- + `${blocking.map(({ id }) => id).join(", ")}. `
747
- + "Disposition each finding (yui task review finding dispose) before completing the Task.");
712
+ // Issue 06: re-validate the full completion readiness inside the
713
+ // transaction (the CAS fence) after final-review preparation. This is the
714
+ // same pure projection `task next-action` displays, now with the finding
715
+ // ledger gate enabled: `fixed-pending-review` findings that the prepared
716
+ // Review would resolve are no longer blocked, but once no Review is needed
717
+ // every remaining blocker fails closed with the fresh list.
718
+ const readinessFacts = tx.readCompletionReadinessFacts(task.id);
719
+ if (readinessFacts === null)
720
+ throw taskNotFound(task.id);
721
+ const readiness = projectCompletionReadiness(readinessFacts);
722
+ if (!readiness.ready) {
723
+ throw usageError(formatCompletionBlockers(task.id, readiness.blockers));
748
724
  }
749
725
  const completed = completeTask(task, now, { by: actor, summary });
750
726
  tx.saveTask(completed);
@@ -823,8 +799,9 @@ function reopenTaskCommand(args, store, options) {
823
799
  const active = reopenTask(task, now);
824
800
  tx.saveTask(active);
825
801
  tx.clearOperatorNotification(task.id);
826
- enqueueWork(tx, leaderMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
827
- enqueueWork(tx, taskMailbox(task.id), "task-reopened", now, [taskRef(task.id)]);
802
+ const reopenedReason = wakeReason("task-reopened");
803
+ enqueueWork(tx, leaderMailbox(task.id), reopenedReason, now, [taskRef(task.id)]);
804
+ enqueueWork(tx, taskMailbox(task.id), reopenedReason, now, [taskRef(task.id)]);
828
805
  recordTaskEvent(tx, task.id, "task.reopened", { status: active.status }, now);
829
806
  return { task: active, changed: true };
830
807
  });
@@ -1133,9 +1110,25 @@ function taskMessageCommand(args, store, options) {
1133
1110
  return `Sent message ${result.message.id} to ${result.task.id}\n`;
1134
1111
  }
1135
1112
  if (command === "list") {
1136
- exactPositionals(rest, 1, "Task message list usage: yui task message list <id>.");
1137
- const task = requireTask(store, rest[0]);
1138
- const messages = store.listMessages(task.id);
1113
+ const messageListUsage = "Task message list usage: yui task message list <id> [--after <timestamp>] [--limit <n>].";
1114
+ const parsed = parseTail(rest, new Set(["--after", "--limit"]), messageListUsage);
1115
+ exactPositionals(parsed.positionals, 1, messageListUsage);
1116
+ const task = requireTask(store, parsed.positionals[0]);
1117
+ let messages = store.listMessages(task.id);
1118
+ const after = optionalNonEmptyOption(parsed.options, "--after");
1119
+ if (after !== undefined) {
1120
+ const afterMs = Date.parse(after);
1121
+ if (!Number.isFinite(afterMs))
1122
+ throw usageError("--after must be a valid timestamp.", messageListUsage);
1123
+ messages = messages.filter((m) => Date.parse(m.createdAt) > afterMs);
1124
+ }
1125
+ const limit = optionalNonEmptyOption(parsed.options, "--limit");
1126
+ if (limit !== undefined) {
1127
+ const n = Number(limit);
1128
+ if (!Number.isSafeInteger(n) || n <= 0)
1129
+ throw usageError("--limit must be a positive integer.", messageListUsage);
1130
+ messages = messages.slice(-n);
1131
+ }
1139
1132
  if (messages.length === 0)
1140
1133
  return "No messages found.\n";
1141
1134
  const timeZone = store.getConfig().timeZone;
@@ -1160,7 +1153,7 @@ function taskMessageCommand(args, store, options) {
1160
1153
  * enqueues exactly one Leader wakeup with an auditable reason. The reason is
1161
1154
  * truncated to keep the event payload compact.
1162
1155
  */
1163
- function taskWakeCommand(args, store, options) {
1156
+ function taskWakeForceCommand(args, store, options) {
1164
1157
  const usage = "Task wake usage: yui task wake <id> --force --reason <text>.";
1165
1158
  const parsed = parseTail(args, new Set(["--reason"]), usage, new Set(["--force"]));
1166
1159
  exactPositionals(parsed.positionals, 1, usage);
@@ -1171,13 +1164,13 @@ function taskWakeCommand(args, store, options) {
1171
1164
  const now = clock(options);
1172
1165
  const task = requireTask(store, parsed.positionals[0]);
1173
1166
  assertTaskOpen(task);
1174
- const wakeReason = `force-wake:${truncateEventNote(reason)}`;
1167
+ const wakeReasonTag = wakeReason("force-wake", truncateEventNote(reason));
1175
1168
  store.transaction((tx) => {
1176
- queueLeaderWakeup(tx, task.id, wakeReason, now);
1177
- recordTaskEvent(tx, task.id, "task.wake-forced", { reason: wakeReason }, now);
1169
+ queueLeaderWakeup(tx, task.id, wakeReasonTag, now);
1170
+ recordTaskEvent(tx, task.id, "task.wake-forced", { reason: wakeReasonTag }, now);
1178
1171
  });
1179
1172
  notifyMailbox(options.runtime, leaderMailbox(task.id), task.id);
1180
- return `Woke ${task.id} (${wakeReason})\n`;
1173
+ return `Woke ${task.id} (${wakeReasonTag})\n`;
1181
1174
  }
1182
1175
  function taskRoleCommand(args, store, options) {
1183
1176
  const [command, ...rest] = args;
@@ -1313,6 +1306,7 @@ function listTaskRoles(args, store, options) {
1313
1306
  { header: "Health", minWidth: 6, maxWidth: 15 },
1314
1307
  { header: "Open input", minWidth: 5, maxWidth: 10 },
1315
1308
  { header: "Active work", minWidth: 10, maxWidth: 34 },
1309
+ { header: "Last run", minWidth: 10, maxWidth: 28 },
1316
1310
  { header: "Native session", minWidth: 10, maxWidth: 28 },
1317
1311
  { header: "tmux", minWidth: 6, maxWidth: 22 }
1318
1312
  ], statuses.map((status) => [
@@ -1321,6 +1315,7 @@ function listTaskRoles(args, store, options) {
1321
1315
  status.health,
1322
1316
  taskRoleOpenInputLabel(status),
1323
1317
  taskRoleActiveWorkLabel(status),
1318
+ taskRoleLastRunLabel(status),
1324
1319
  taskRoleNativeSessionLabel(status),
1325
1320
  taskRoleTmuxLabel(status)
1326
1321
  ]), defaultTableWidth())}\n`, { roles: statuses });
@@ -2806,12 +2801,14 @@ function extractReviewFindingsCommand(args, store, options) {
2806
2801
  + `${result.created.length} created, ${result.updated.length} updated, ${result.conflicts.length} conflict(s).\n`);
2807
2802
  }
2808
2803
  function requestTaskReviewRound(args, store, options) {
2809
- const usage = "Task review request usage: yui task review request <task> --role <global-role> [--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...].";
2810
- const parsed = parseMultiValueTail(args, new Set(["--role", "--strategy"]), new Set(["--lane-role"]), usage);
2804
+ const usage = "Task review request usage: yui task review request <task> --role <global-role> "
2805
+ + "[--strategy fixed:<count>|adaptive:<max>] [--lane-role <role> ...] [--delta-recheck].";
2806
+ const parsed = parseMultiValueTail(args, new Set(["--role", "--strategy"]), new Set(["--lane-role"]), usage, new Set(["--delta-recheck"]));
2811
2807
  exactPositionals(parsed.positionals, 1, usage);
2812
2808
  const reviewerRoleName = requiredOption(parsed.options, "--role");
2813
2809
  const requestedStrategy = parseExecutionStrategy(parsed.options.get("--strategy"), usage);
2814
2810
  const requestedLaneRoles = parsed.multiOptions.get("--lane-role") ?? [];
2811
+ const deltaRecheckRequested = parsed.options.has("--delta-recheck");
2815
2812
  const now = clock(options);
2816
2813
  const round = store.transaction((tx) => {
2817
2814
  const task = requireTask(tx, parsed.positionals[0]);
@@ -2824,6 +2821,17 @@ function requestTaskReviewRound(args, store, options) {
2824
2821
  throw usageError(`Task ${task.id} has no bound Projects for a Task-final Review.`);
2825
2822
  }
2826
2823
  const taskFinalContract = taskFinalReviewContractForMutation(tx, task.id, options);
2824
+ if (deltaRecheckRequested) {
2825
+ const reviewConfig = tx.getReviewConfig();
2826
+ if (reviewConfig === null || reviewConfig.deltaRecheck !== "enabled") {
2827
+ throw usageError("Delta-recheck is not enabled for this Project's review policy. "
2828
+ + "Set `yui config set review --role <role> --trigger final --delta-recheck enabled` "
2829
+ + "or request a full Review.");
2830
+ }
2831
+ if (taskFinalContract !== undefined) {
2832
+ throw usageError("Delta-recheck is not supported with a Task-final review contract.");
2833
+ }
2834
+ }
2827
2835
  if (tx.getGlobalRole(reviewerRoleName) === null) {
2828
2836
  throw usageError(`Global Role not found: ${reviewerRoleName}.`);
2829
2837
  }
@@ -2838,7 +2846,8 @@ function requestTaskReviewRound(args, store, options) {
2838
2846
  const exact = taskRounds.filter((entry) => (entry.reviewerRoleName === reviewerRoleName
2839
2847
  && (taskFinalContract === undefined || sameTaskFinalReviewContract(entry.taskFinalReviewContract, taskFinalContract))
2840
2848
  && isSameTaskReviewCandidate(entry.taskCandidate, provenance.candidate))).at(-1);
2841
- if (exact !== undefined) {
2849
+ if (exact !== undefined
2850
+ && (deltaRecheckRequested || !deltaRecheckBlocksAcceptance(exact))) {
2842
2851
  if (exact.status === "failed") {
2843
2852
  throw usageError(`Explicit Task-final ReviewRound ${exact.id} is failed for this exact candidate; resolve it before requesting again.`);
2844
2853
  }
@@ -2941,7 +2950,16 @@ function requestTaskReviewRound(args, store, options) {
2941
2950
  }
2942
2951
  assertTaskReviewRequestLane(tx, task.id, laneRole.name);
2943
2952
  }
2944
- let created = createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, anchor.item.id, anchor.candidate.id, reviewerRoleName, "leader", provenance.candidate, now, taskFinalContract);
2953
+ let deltaRecord;
2954
+ if (deltaRecheckRequested) {
2955
+ if (requestedLaneRoles.length > 0 || requestedStrategy !== undefined) {
2956
+ throw usageError("Delta-recheck supports only the default single Reviewer Lane.");
2957
+ }
2958
+ deltaRecord = validateDeltaRecheckRequest(tx, task.id, reviewerRoleName, provenance.candidate, options.deltaRecheckPreflight);
2959
+ }
2960
+ let created = deltaRecord === undefined
2961
+ ? createTaskReviewRound(tx.nextReviewRoundId(task.id), task.id, anchor.item.id, anchor.candidate.id, reviewerRoleName, "leader", provenance.candidate, now, taskFinalContract)
2962
+ : createTaskDeltaReviewRound(tx.nextReviewRoundId(task.id), task.id, anchor.item.id, anchor.candidate.id, reviewerRoleName, "leader", provenance.candidate, deltaRecord, now, taskFinalContract);
2945
2963
  let group = createExecutionGroup(`execution-group-${created.id}`, task.id, {
2946
2964
  purpose: "review",
2947
2965
  target: executionTargetForReviewRound(task, created, anchor.item, anchor.candidate),
@@ -2953,20 +2971,80 @@ function requestTaskReviewRound(args, store, options) {
2953
2971
  executionGroup: group
2954
2972
  };
2955
2973
  tx.saveReviewRound(task.id, created);
2974
+ // Issue 07: when a full Review is created after a non-accepting delta
2975
+ // disposition, record the escalation lineage on the delta Round.
2976
+ if (exact !== undefined
2977
+ && deltaRecheckBlocksAcceptance(exact)
2978
+ && created.deltaRecheck === undefined
2979
+ && exact.deltaRecheck !== undefined
2980
+ && exact.deltaRecheck.escalatedToReviewRoundId === undefined) {
2981
+ tx.saveReviewRound(task.id, {
2982
+ ...exact,
2983
+ deltaRecheck: {
2984
+ ...exact.deltaRecheck,
2985
+ escalatedToReviewRoundId: created.id
2986
+ }
2987
+ });
2988
+ }
2956
2989
  recordTaskEvent(tx, task.id, "review.task-final-requested", {
2957
2990
  reviewRoundId: created.id,
2958
2991
  workItemId: created.workItemId,
2959
2992
  candidateId: created.candidateId,
2960
2993
  reviewerRoleName: created.reviewerRoleName,
2961
2994
  requestedBy: created.requestedBy,
2962
- taskCandidate: JSON.stringify(created.taskCandidate)
2995
+ taskCandidate: JSON.stringify(created.taskCandidate),
2996
+ ...(created.deltaRecheck === undefined
2997
+ ? {}
2998
+ : {
2999
+ deltaRecheck: "true",
3000
+ previousReviewRoundId: created.deltaRecheck.previousReviewRoundId,
3001
+ diffDigest: created.deltaRecheck.diffDigest
3002
+ })
2963
3003
  }, now);
2964
3004
  return created;
2965
3005
  });
2966
3006
  return output(round.status === "pending"
2967
- ? `Task-final Review requested as ${round.id}\n`
3007
+ ? round.deltaRecheck === undefined
3008
+ ? `Task-final Review requested as ${round.id}\n`
3009
+ : `Task-final delta-recheck requested as ${round.id} (rechecks ${round.deltaRecheck.previousReviewRoundId})\n`
2968
3010
  : `Task-final Review is already ${round.status}: ${round.id}\n`, { reviewRound: round });
2969
3011
  }
3012
+ /**
3013
+ * Issue 07: re-validates the CLI-computed delta preflight inside the store
3014
+ * transaction. The previous Round must be a completed acceptance (a full
3015
+ * Review or an equivalent-and-accepted delta) so a delta never extends a
3016
+ * non-accepting disposition.
3017
+ */
3018
+ function validateDeltaRecheckRequest(store, taskId, reviewerRoleName, candidate, preflight) {
3019
+ if (preflight === undefined) {
3020
+ throw usageError("Delta-recheck assessment is missing; the CLI preflight did not run. "
3021
+ + "Request a full Review or retry with a current CLI.");
3022
+ }
3023
+ const previous = store.getReviewRound(taskId, preflight.record.previousReviewRoundId);
3024
+ if (previous === null
3025
+ || (previous.scope ?? "work-item") !== "task"
3026
+ || previous.status !== "completed") {
3027
+ throw usageError(`Delta-recheck previous ReviewRound is not a completed Task-final Review: `
3028
+ + `${preflight.record.previousReviewRoundId}.`);
3029
+ }
3030
+ if (previous.reviewerRoleName !== reviewerRoleName) {
3031
+ throw usageError(`Delta-recheck Reviewer Role must match the previous acceptance: `
3032
+ + `${previous.reviewerRoleName}.`);
3033
+ }
3034
+ if (previous.reviewBaseCommit !== preflight.record.previousBaseCommit) {
3035
+ throw usageError("Delta-recheck previous base commit does not match the recorded acceptance.");
3036
+ }
3037
+ // A delta may only extend an acceptance, never a finding or an escalation.
3038
+ if (previous.deltaRecheck !== undefined
3039
+ && previous.deltaRecheck.disposition !== "equivalent-and-accepted") {
3040
+ throw usageError(`Delta-recheck cannot extend ${previous.id}: its disposition is `
3041
+ + `${previous.deltaRecheck.disposition}. Resolve it with a full Review first.`);
3042
+ }
3043
+ if (candidate.projects[0].commit === preflight.record.previousBaseCommit) {
3044
+ throw usageError("Delta-recheck candidate head is unchanged; the previous acceptance already covers it.");
3045
+ }
3046
+ return preflight.record;
3047
+ }
2970
3048
  function assertTaskReviewRequestLane(store, taskId, reviewerRoleName, reusableRound) {
2971
3049
  const reviewerMailbox = store.getWorkMailbox(roleMailbox(taskId, reviewerRoleName));
2972
3050
  const runtimeMailbox = store.getWorkMailbox(runtimeLifecycleTarget({
@@ -3137,6 +3215,8 @@ function taskRunCommand(args, store, options) {
3137
3215
  const [command, ...rest] = args;
3138
3216
  if (command === "list")
3139
3217
  return output(listRuns(rest, store, options));
3218
+ if (command === "show")
3219
+ return showRun(rest, store, options);
3140
3220
  if (command === "retry")
3141
3221
  return retryRun(rest, store, options);
3142
3222
  if (command === "settle")
@@ -3717,7 +3797,33 @@ function prepareFinalTaskReview(store, task, now, taskFinalContract, options) {
3717
3797
  // review evidence. Do not create duplicate rounds on repeated completion
3718
3798
  // attempts. A failed round remains a blocker until the Leader changes the
3719
3799
  // candidate or otherwise resolves the failed evidence explicitly.
3720
- return latest.status === "completed" ? null : latest;
3800
+ // Issue 07: a completed delta-recheck that did not accept the head is not
3801
+ // final evidence. A `requires-full-review` disposition escalates to a new
3802
+ // full Review; a `finding` disposition stays a blocker for the Leader.
3803
+ if (latest.status === "completed"
3804
+ && latest.deltaRecheck !== undefined
3805
+ && latest.deltaRecheck.disposition === "requires-full-review") {
3806
+ // Fall through to queue a full Review for the same candidate.
3807
+ const anchor = taskFinalContract === undefined
3808
+ ? latestTaskReviewAnchor(store, task)
3809
+ : latestTaskReviewContractAnchor(store, task, taskFinalContract);
3810
+ const escalated = queueTaskReviewRound(store, task, anchor.item, anchor.candidate.id, config, taskCandidate, options, now, establishedRound?.requestedBy ?? "policy", taskFinalContract);
3811
+ // Record the escalation lineage on the delta Round so the full Review
3812
+ // is traceable from the non-accepting delta disposition.
3813
+ if (latest.deltaRecheck.escalatedToReviewRoundId === undefined) {
3814
+ store.saveReviewRound(task.id, {
3815
+ ...latest,
3816
+ deltaRecheck: {
3817
+ ...latest.deltaRecheck,
3818
+ escalatedToReviewRoundId: escalated.id
3819
+ }
3820
+ });
3821
+ }
3822
+ return escalated;
3823
+ }
3824
+ else {
3825
+ return latest.status === "completed" ? null : latest;
3826
+ }
3721
3827
  }
3722
3828
  const anchor = taskFinalContract === undefined
3723
3829
  ? latestTaskReviewAnchor(store, task)
@@ -3997,11 +4103,12 @@ function retryFailedReviewRun(previous, store, options, now) {
3997
4103
  * another input or changes a native generation from this command.
3998
4104
  */
3999
4105
  function recoverRun(args, store, options) {
4000
- const usage = "Task run recover usage: yui task run recover <task>/<run> --action <diagnose|retry|replace-session|terminate> --expected-progress-at <timestamp> --provider-acceptance <accepted|rejected|ambiguous> --reason <text> [--agent-id <id>] [--adapter-id <id>] [--native-session-id <id>] [--launch-id <id>].";
4106
+ const usage = "Task run recover usage: yui task run recover <task>/<run> --action <diagnose|retry|replace-session|terminate> (--expected-progress-at <timestamp>|--from-next-action <fingerprint>) --provider-acceptance <accepted|rejected|ambiguous> --reason <text> [--agent-id <id>] [--adapter-id <id>] [--native-session-id <id>] [--launch-id <id>].";
4001
4107
  const parsed = parseTail(args, new Set([
4002
4108
  "--action",
4003
4109
  "--expected-progress-at",
4004
4110
  "--progress-at",
4111
+ "--from-next-action",
4005
4112
  "--provider-acceptance",
4006
4113
  "--reason",
4007
4114
  "--role",
@@ -4012,6 +4119,12 @@ function recoverRun(args, store, options) {
4012
4119
  ]), usage);
4013
4120
  exactPositionals(parsed.positionals, 1, usage);
4014
4121
  const now = clock(options);
4122
+ const fingerprint = parsed.options.get("--from-next-action");
4123
+ const explicitFence = parsed.options.get("--expected-progress-at")
4124
+ ?? parsed.options.get("--progress-at");
4125
+ if (fingerprint !== undefined && explicitFence !== undefined) {
4126
+ throw usageError("--from-next-action and --expected-progress-at/--progress-at are mutually exclusive.", usage);
4127
+ }
4015
4128
  const input = store.transaction((tx) => {
4016
4129
  const active = requireRun(tx, parsed.positionals[0], options);
4017
4130
  const task = requireTask(tx, active.taskId);
@@ -4027,17 +4140,41 @@ function recoverRun(args, store, options) {
4027
4140
  const role = requireRole(tx, task.id, roleName);
4028
4141
  const sessions = tx.getTaskRoleSessionSet(task.id, role.name);
4029
4142
  const session = sessions?.sessions[sessions.activeAgentId];
4030
- const action = parseRecoveryAction(parsed.options.get("--action"), usage);
4031
- const expectedProgressAt = parsed.options.get("--expected-progress-at")
4032
- ?? parsed.options.get("--progress-at");
4033
- if (expectedProgressAt === undefined)
4143
+ // Issue 08: a fingerprint copied from `task run show` resolves the
4144
+ // canonical fence server-side. The projection is recomputed here, so a
4145
+ // fingerprint from stale observations matches nothing and fails closed.
4146
+ let plan = null;
4147
+ if (fingerprint !== undefined) {
4148
+ const facts = readRunRecoveryFacts(tx, task.id, active.id);
4149
+ if (facts === null)
4150
+ throw usageError(`Agent Run not found: ${task.id}/${active.id}.`, usage);
4151
+ const projection = projectRunRecovery(facts);
4152
+ plan = projection.actions.find((entry) => entry.fingerprint === fingerprint) ?? null;
4153
+ if (plan === null) {
4154
+ throw usageError(runRecoveryStaleDiagnosis(task.id, active.id, projection.canonicalProgressAt ?? null, "recovery action fingerprint is stale or unknown"), usage);
4155
+ }
4156
+ }
4157
+ const action = parseRecoveryAction(parsed.options.get("--action") ?? plan?.action, usage);
4158
+ if (plan !== null && plan.action !== action) {
4159
+ throw usageError(`--action ${action} does not match recovery fingerprint action ${plan.action}.`, usage);
4160
+ }
4161
+ const expectedProgressAt = explicitFence ?? plan?.expectedProgressAt;
4162
+ if (expectedProgressAt === undefined) {
4034
4163
  throw usageError("--expected-progress-at is required.", usage);
4164
+ }
4035
4165
  const providerAcceptance = parseProviderAcceptance(parsed.options.get("--provider-acceptance"), usage);
4036
- const agentId = parsed.options.get("--agent-id") ?? active.effective.agentId;
4037
- const adapterId = parsed.options.get("--adapter-id") ?? active.effective.adapterId;
4166
+ const agentId = parsed.options.get("--agent-id")
4167
+ ?? plan?.agentId
4168
+ ?? active.effective.agentId;
4169
+ const adapterId = parsed.options.get("--adapter-id")
4170
+ ?? plan?.adapterId
4171
+ ?? active.effective.adapterId;
4038
4172
  const nativeSessionId = parsed.options.get("--native-session-id")
4173
+ ?? plan?.nativeSessionId
4039
4174
  ?? session?.nativeSessionId;
4040
- const launchId = parsed.options.get("--launch-id") ?? session?.launchId;
4175
+ const launchId = parsed.options.get("--launch-id")
4176
+ ?? plan?.launchId
4177
+ ?? session?.launchId;
4041
4178
  return {
4042
4179
  taskId: task.id,
4043
4180
  roleName: role.name,
@@ -4055,7 +4192,7 @@ function recoverRun(args, store, options) {
4055
4192
  });
4056
4193
  const result = recoverExactAgentRun(store, input);
4057
4194
  if (result.disposition !== "applied") {
4058
- throw usageError(`Exact Run recovery ${result.disposition}: ${result.reason ?? "state changed"}.`, usage);
4195
+ throw usageError(runRecoveryStaleDiagnosis(input.taskId, input.runId, result.progressAt ?? null, `exact Run recovery ${result.disposition}: ${result.reason ?? "state changed"}`), usage);
4059
4196
  }
4060
4197
  // The durable recovery request is committed before asking the Controller to
4061
4198
  // wake the owning Leader; a failed transaction must not leak a signal.
@@ -4067,6 +4204,86 @@ function recoverRun(args, store, options) {
4067
4204
  : "";
4068
4205
  return `Recorded exact ${result.action} recovery for ${result.run?.id ?? "unknown Run"}.${followup}\n`;
4069
4206
  }
4207
+ /**
4208
+ * Issue 08: every recovery rejection carries the current canonical fence and
4209
+ * points at the single read-only command that projects it. The caller's
4210
+ * side-effecting action is never retried automatically.
4211
+ */
4212
+ function runRecoveryStaleDiagnosis(taskId, runId, canonicalProgressAt, detail) {
4213
+ const fence = canonicalProgressAt === null
4214
+ ? "no durable progress timestamp is available for this Run"
4215
+ : `the canonical durable fence is now ${canonicalProgressAt}`;
4216
+ return `${detail}; ${fence}. Re-read the recovery plan: yui task run show ${taskId}/${runId}.`;
4217
+ }
4218
+ /**
4219
+ * Issue 08: read-only Run detail with the canonical recovery fence, Provider
4220
+ * evidence, and every exact recovery action. Never mutates state and never
4221
+ * selects an action or Provider acceptance for the Leader.
4222
+ */
4223
+ function showRun(args, store, options) {
4224
+ const usage = "Task run show usage: yui task run show <task>/<run> [--json].";
4225
+ const asJson = args.includes("--json");
4226
+ const positionals = args.filter((arg) => arg !== "--json");
4227
+ exactPositionals(positionals, 1, usage);
4228
+ const data = store.transaction((tx) => {
4229
+ const run = requireRun(tx, positionals[0], options);
4230
+ const facts = readRunRecoveryFacts(tx, run.taskId, run.id);
4231
+ if (facts === null)
4232
+ throw usageError(`Agent Run not found: ${run.taskId}/${run.id}.`, usage);
4233
+ return { run, recovery: projectRunRecovery(facts) };
4234
+ });
4235
+ if (asJson) {
4236
+ return { kind: "output", output: `${JSON.stringify(data, null, 2)}\n`, data };
4237
+ }
4238
+ return { kind: "output", output: renderRunShow(data.run, data.recovery), data };
4239
+ }
4240
+ function renderRunShow(run, recovery) {
4241
+ const lines = [
4242
+ `Run: ${run.id}`,
4243
+ `Task: ${run.taskId}`,
4244
+ `Role: ${run.roleName}`,
4245
+ `Purpose: ${run.purpose}`,
4246
+ `Mode: ${run.mode}`,
4247
+ `Status: ${run.status}`,
4248
+ `Effective: ${run.effective.agentId}/${run.effective.adapterId} r${run.effective.sourceDesiredRevision}`,
4249
+ `Created: ${run.createdAt}`,
4250
+ ...(run.pushedAt === undefined ? [] : [`Pushed: ${run.pushedAt}`]),
4251
+ ...(run.deliveredAt === undefined
4252
+ ? []
4253
+ : [`Provider accepted (durable): ${run.deliveredAt}`]),
4254
+ ...(run.summary === undefined || run.summary.trim().length === 0
4255
+ ? []
4256
+ : [`Summary: ${run.summary}`])
4257
+ ];
4258
+ if (recovery.canonicalProgressAt !== null) {
4259
+ lines.push(`Canonical recovery fence (Yui durable CAS): ${recovery.canonicalProgressAt}`, ...(recovery.canonicalProgressEvidence === undefined
4260
+ ? []
4261
+ : [`Fence evidence: ${recovery.canonicalProgressEvidence}`]));
4262
+ }
4263
+ if (recovery.provider.observedAt !== null) {
4264
+ lines.push(`Provider observation (evidence only, not a fence): `
4265
+ + `${recovery.provider.observationKind} at ${recovery.provider.observedAt}`);
4266
+ }
4267
+ if (recovery.session !== null) {
4268
+ const session = recovery.session;
4269
+ lines.push(`Session: ${session.status}`
4270
+ + `${session.nativeSessionId === undefined ? "" : ` ${session.nativeSessionId}`}`
4271
+ + `${session.launchId === undefined ? "" : ` launch ${session.launchId}`}`);
4272
+ }
4273
+ if (recovery.recoverable) {
4274
+ lines.push(`Provider acceptance options: ${recovery.providerAcceptance.options.join(", ")}`, "Recovery actions (copy one; the fence is already canonical):");
4275
+ for (const plan of recovery.actions) {
4276
+ lines.push(` [${plan.action}] ${plan.reason}`, ` ${plan.command}`);
4277
+ }
4278
+ if (recovery.judgmentRequired !== undefined) {
4279
+ lines.push(`Judgment: ${recovery.judgmentRequired}`);
4280
+ }
4281
+ }
4282
+ else {
4283
+ lines.push(`Not recoverable: ${recovery.reason ?? "unknown"}`);
4284
+ }
4285
+ return `${lines.join("\n")}\n`;
4286
+ }
4070
4287
  /**
4071
4288
  * Issue 04: builds the terminal yield outcome from the command inputs. The
4072
4289
  * same construction feeds both the first commit and the idempotent replay, so
@@ -4099,7 +4316,13 @@ function buildYieldOutcome(run, inputSummary, options) {
4099
4316
  ...(options.executionLaneGitSnapshot === undefined
4100
4317
  || options.executionLaneGitSnapshot === null
4101
4318
  ? {}
4102
- : { gitSnapshot: options.executionLaneGitSnapshot })
4319
+ : { gitSnapshot: options.executionLaneGitSnapshot }),
4320
+ ...(yieldedReport.deltaDisposition === undefined
4321
+ ? {}
4322
+ : { deltaDisposition: yieldedReport.deltaDisposition }),
4323
+ ...(yieldedReport.deltaReasoning === undefined
4324
+ ? {}
4325
+ : { deltaReasoning: yieldedReport.deltaReasoning })
4103
4326
  }
4104
4327
  };
4105
4328
  }
@@ -4108,9 +4331,8 @@ function buildYieldOutcome(run, inputSummary, options) {
4108
4331
  * for the same outcome, fails closed for a different outcome, or returns
4109
4332
  * `null` to keep the legacy "already terminal" behavior.
4110
4333
  */
4111
- function replayYieldReceipt(run, inputSummary, options) {
4112
- const config = providerRetryConfig(options.environment ?? process.env);
4113
- if (!config.yieldReceiptReplay)
4334
+ function replayYieldReceipt(run, inputSummary, options, retryConfig) {
4335
+ if (!retryConfig.yieldReceiptReplay)
4114
4336
  return null;
4115
4337
  if (run.yieldReceipt === undefined)
4116
4338
  return null;
@@ -4167,7 +4389,7 @@ function yieldRun(args, store, options) {
4167
4389
  // transaction; the receipt is immutable once committed.
4168
4390
  const existing = requireRun(store, parsed.positionals[0], options);
4169
4391
  if (existing.status !== "active") {
4170
- const replayed = replayYieldReceipt(existing, inputSummary, options);
4392
+ const replayed = replayYieldReceipt(existing, inputSummary, options, providerRetryConfig(store.getConfig()));
4171
4393
  if (replayed !== null)
4172
4394
  return replayed;
4173
4395
  throw usageError(`Run ${existing.id} is already terminal: ${existing.status}.`);
@@ -4292,7 +4514,15 @@ function yieldRun(args, store, options) {
4292
4514
  reviewBaseCommit: round.reviewBaseCommit,
4293
4515
  evidenceCommit: round.evidenceCommit ?? "none",
4294
4516
  checks: round.checks?.map(({ name, outcome }) => `${name}:${outcome}`)
4295
- .join(",") || "none"
4517
+ .join(",") || "none",
4518
+ ...(round.deltaRecheck === undefined
4519
+ ? {}
4520
+ : {
4521
+ reviewMode: "delta-recheck",
4522
+ deltaDisposition: round.deltaRecheck.disposition ?? "requires-full-review",
4523
+ previousReviewRoundId: round.deltaRecheck.previousReviewRoundId,
4524
+ diffDigest: round.deltaRecheck.diffDigest
4525
+ })
4296
4526
  }, now);
4297
4527
  }
4298
4528
  }
@@ -4707,6 +4937,30 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4707
4937
  const findingContext = taskScope
4708
4938
  ? buildTaskFinalReviewFindingContext(tx, taskId, round.taskCandidate).context
4709
4939
  : "";
4940
+ let deltaContext = "";
4941
+ if (taskScope && round.deltaRecheck !== undefined) {
4942
+ const previousRound = tx.getReviewRound(taskId, round.deltaRecheck.previousReviewRoundId);
4943
+ if (previousRound === null || previousRound.status !== "completed") {
4944
+ throw new TaskFinalReviewDispatchDriftError(`Delta-recheck previous ReviewRound is unavailable: ${round.deltaRecheck.previousReviewRoundId}.`);
4945
+ }
4946
+ const diffByProject = options.deltaRecheckDiff;
4947
+ if (diffByProject === undefined) {
4948
+ throw new TaskFinalReviewDispatchDriftError(`Delta-recheck diff is missing for ${round.id}; the CLI preflight did not run.`);
4949
+ }
4950
+ try {
4951
+ verifyDeltaRecheckDiff(round.deltaRecheck, diffByProject);
4952
+ }
4953
+ catch (error) {
4954
+ throw new TaskFinalReviewDispatchDriftError(`Delta-recheck diff verification failed for ${round.id}: `
4955
+ + `${error instanceof Error ? error.message : String(error)}`);
4956
+ }
4957
+ deltaContext = buildDeltaRecheckDispatchContext({
4958
+ round,
4959
+ previousRound,
4960
+ diffByProject,
4961
+ ledgerContext: findingContext
4962
+ });
4963
+ }
4710
4964
  const scopeLabel = taskScope ? "Task-final" : "WorkItem";
4711
4965
  const projectPolicyPointers = task.projectBindings
4712
4966
  .map(({ projectId }) => (`yui project show ${projectId}; yui project knowledge list ${projectId}`))
@@ -4723,10 +4977,10 @@ export function dispatchPreparedReviewRound(taskId, reviewRoundId, store, option
4723
4977
  `Review workspace source: exact workspace attached to this Reviewer Lane`,
4724
4978
  `Candidate summary: ${candidate.summary}`,
4725
4979
  `Acceptance criteria: ${item.acceptance.length === 0 ? "none" : item.acceptance.join("; ")}`,
4726
- ...(taskScope ? [findingContext] : []),
4980
+ ...(taskScope ? [deltaContext !== "" ? deltaContext : findingContext] : []),
4727
4981
  "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.",
4728
4982
  "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.",
4729
- ...(round.scope === "task"
4983
+ ...(round.scope === "task" && round.deltaRecheck === undefined
4730
4984
  ? ["This is the one final Task Review: inspect every bound Project at the frozen integrated heads, and report only reachable, material, actionable P1/P2 findings or bounded verification gaps."]
4731
4985
  : []),
4732
4986
  "You may freely edit source/tests, run local build or test commands, and optionally commit diagnostic evidence only inside this ReviewRound-owned workspace.",
@@ -5482,9 +5736,25 @@ function taskMilestoneCommand(args, store, options) {
5482
5736
  function taskEventCommand(args, store) {
5483
5737
  const [command, ...rest] = args;
5484
5738
  if (command === "list") {
5485
- exactPositionals(rest, 1, "Task event list usage: yui task event list <task>.");
5486
- const task = requireTask(store, rest[0]);
5487
- const events = store.listEvents(task.id);
5739
+ const eventListUsage = "Task event list usage: yui task event list <task> [--after <timestamp>] [--limit <n>].";
5740
+ const parsed = parseTail(rest, new Set(["--after", "--limit"]), eventListUsage);
5741
+ exactPositionals(parsed.positionals, 1, eventListUsage);
5742
+ const task = requireTask(store, parsed.positionals[0]);
5743
+ let events = store.listEvents(task.id);
5744
+ const after = optionalNonEmptyOption(parsed.options, "--after");
5745
+ if (after !== undefined) {
5746
+ const afterMs = Date.parse(after);
5747
+ if (!Number.isFinite(afterMs))
5748
+ throw usageError("--after must be a valid timestamp.", eventListUsage);
5749
+ events = events.filter((e) => Date.parse(e.createdAt) > afterMs);
5750
+ }
5751
+ const limit = optionalNonEmptyOption(parsed.options, "--limit");
5752
+ if (limit !== undefined) {
5753
+ const n = Number(limit);
5754
+ if (!Number.isSafeInteger(n) || n <= 0)
5755
+ throw usageError("--limit must be a positive integer.", eventListUsage);
5756
+ events = events.slice(-n);
5757
+ }
5488
5758
  if (events.length === 0) {
5489
5759
  return output(`No events found for ${task.id}.\n`, { taskId: task.id, events: [] });
5490
5760
  }
@@ -5518,6 +5788,187 @@ function taskEventCommand(args, store) {
5518
5788
  ? "Task event command is required."
5519
5789
  : `Unknown command: task event ${command}`);
5520
5790
  }
5791
+ /**
5792
+ * Issue 13: native child durability visibility. A native Provider subagent is
5793
+ * best-effort until Yui persists its result content; once a continuation
5794
+ * report carries a result digest receipt the child is durable-result and its
5795
+ * full content stays readable through the referenced Task event.
5796
+ */
5797
+ function taskContinuationCommand(args, store) {
5798
+ const [command, ...rest] = args;
5799
+ if (command !== "list") {
5800
+ throw usageError(command === undefined
5801
+ ? "Task continuation command is required."
5802
+ : `Unknown command: task continuation ${command}`);
5803
+ }
5804
+ const usage = "Task continuation list usage: yui task continuation list <task> [--json].";
5805
+ const asJson = rest.includes("--json");
5806
+ const positionals = rest.filter((arg) => arg !== "--json");
5807
+ exactPositionals(positionals, 1, usage);
5808
+ const task = requireTask(store, positionals[0]);
5809
+ const events = store.listEvents(task.id);
5810
+ const continuations = projectProviderContinuations(events);
5811
+ const reportEvents = continuationReportEvents(events);
5812
+ const rows = continuations.map((continuation) => {
5813
+ const identity = continuation.identity;
5814
+ const report = [...continuation.reports].reverse()[0];
5815
+ const reportEvent = report === undefined
5816
+ ? undefined
5817
+ : reportEvents.find((entry) => (entry.continuationId === identity.continuationId
5818
+ && entry.continuationGeneration === identity.generation
5819
+ && entry.reportId === report.reportId));
5820
+ return Object.freeze({
5821
+ continuationId: identity.continuationId,
5822
+ generation: identity.generation,
5823
+ driver: identity.providerNamespace,
5824
+ runId: continuation.runId,
5825
+ execution: continuation.execution,
5826
+ outcome: continuation.outcome,
5827
+ attachment: continuation.attachment,
5828
+ durability: continuation.durability,
5829
+ ...(report?.resultDigest === undefined
5830
+ ? {}
5831
+ : { resultDigest: report.resultDigest }),
5832
+ ...(report?.resultSize === undefined
5833
+ ? {}
5834
+ : { resultSize: report.resultSize }),
5835
+ ...(reportEvent === undefined ? {} : { resultEvent: reportEvent.event.id }),
5836
+ ...(continuation.settledAt === undefined ? {} : { settledAt: continuation.settledAt })
5837
+ });
5838
+ });
5839
+ if (asJson) {
5840
+ return output(`${JSON.stringify({ taskId: task.id, continuations: rows }, null, 2)}\n`, { taskId: task.id, continuations: rows });
5841
+ }
5842
+ if (rows.length === 0) {
5843
+ return output(`No native child continuations found for ${task.id}.\n`, { taskId: task.id, continuations: rows });
5844
+ }
5845
+ const timeZone = store.getConfig().timeZone;
5846
+ return output(`${renderTable(`Native child continuations: ${task.id}`, [
5847
+ { header: "Child", minWidth: 8, maxWidth: 24 },
5848
+ { header: "Driver", minWidth: 8, maxWidth: 24 },
5849
+ { header: "Execution", minWidth: 8, maxWidth: 12 },
5850
+ { header: "Outcome", minWidth: 8, maxWidth: 12 },
5851
+ { header: "Durability", minWidth: 10, maxWidth: 16 },
5852
+ { header: "Result", minWidth: 8, maxWidth: 24 },
5853
+ { header: "Settled", minWidth: 10, maxWidth: 28 }
5854
+ ], rows.map((row) => [
5855
+ row.continuationId,
5856
+ row.driver,
5857
+ row.execution,
5858
+ row.outcome,
5859
+ row.durability,
5860
+ row.resultEvent ?? (row.resultDigest === undefined ? "-" : `digest:${row.resultDigest.slice(0, 12)}`),
5861
+ ...(row.settledAt === undefined ? ["-"] : [presentTime(row.settledAt, timeZone)])
5862
+ ]), defaultTableWidth())}\n`, { taskId: task.id, continuations: rows });
5863
+ }
5864
+ function continuationReportEvents(events) {
5865
+ const result = [];
5866
+ for (const event of events) {
5867
+ const observation = runtimeObservationFromTaskEvent(event);
5868
+ if (observation !== null && observation.kind === "continuation.reported") {
5869
+ const continuationId = observation.fence.continuationId;
5870
+ const continuationGeneration = observation.fence.continuationGeneration;
5871
+ const reportId = observation.payload?.reportId;
5872
+ if (continuationId !== undefined
5873
+ && continuationGeneration !== undefined
5874
+ && reportId !== undefined) {
5875
+ result.push({ event, continuationId, continuationGeneration, reportId });
5876
+ }
5877
+ }
5878
+ }
5879
+ return result;
5880
+ }
5881
+ /**
5882
+ * Issue 04 (long-term): the durable wake ledger. `wake list` shows the
5883
+ * dispatch history; `wake show` returns the structured delta content for one
5884
+ * wake — the on-demand read the Agent uses instead of a context dump in the
5885
+ * wake envelope.
5886
+ */
5887
+ function taskWakeDispatch(args, store, options) {
5888
+ const [subcommand] = args;
5889
+ if (subcommand === "list" || subcommand === "show") {
5890
+ return taskWakeInspectionCommand(args, store);
5891
+ }
5892
+ return output(taskWakeForceCommand(args, store, options));
5893
+ }
5894
+ /**
5895
+ * Issue 04 (long-term): the durable wake ledger. `wake list` shows the
5896
+ * dispatch history; `wake show` returns the structured delta content for one
5897
+ * wake — the on-demand read the Agent uses instead of a context dump in the
5898
+ * wake envelope.
5899
+ */
5900
+ function taskWakeInspectionCommand(args, store) {
5901
+ const [command, ...rest] = args;
5902
+ if (command === "list") {
5903
+ const usage = "Task wake list usage: yui task wake list <task>.";
5904
+ exactPositionals(rest, 1, usage);
5905
+ const task = requireTask(store, rest[0]);
5906
+ const wakes = store.listTaskWakes(task.id);
5907
+ if (wakes.length === 0) {
5908
+ return output(`No wakes recorded for ${task.id}.\n`, { taskId: task.id, wakes: [] });
5909
+ }
5910
+ const timeZone = store.getConfig().timeZone;
5911
+ return output(`${renderTable(`Wakes: ${task.id}`, [
5912
+ { header: "Wake", minWidth: 8, maxWidth: 18 },
5913
+ { header: "Status", minWidth: 8, maxWidth: 12 },
5914
+ { header: "Reasons", minWidth: 10, maxWidth: 40 },
5915
+ { header: "Run", minWidth: 10, maxWidth: 20 },
5916
+ { header: "Dispatched", minWidth: 10, maxWidth: 28 }
5917
+ ], wakes.map((wake) => [
5918
+ wake.id,
5919
+ wake.status,
5920
+ wake.reasons.map(renderWakeReason).join(", "),
5921
+ wake.runId ?? "-",
5922
+ presentTime(wake.createdAt, timeZone)
5923
+ ]), defaultTableWidth())}\n`, { taskId: task.id, wakes });
5924
+ }
5925
+ if (command === "show") {
5926
+ const usage = "Task wake show usage: yui task wake show <task> <wake>.";
5927
+ exactPositionals(rest, 2, usage);
5928
+ const task = requireTask(store, rest[0]);
5929
+ const wake = store.getTaskWake(task.id, rest[1]);
5930
+ if (wake === null)
5931
+ throw dataError(`Wake not found: ${rest[1]}.`);
5932
+ const timeZone = store.getConfig().timeZone;
5933
+ const fromMs = Date.parse(wake.fromCursor);
5934
+ const toMs = Date.parse(wake.toCursor);
5935
+ const inWindow = (createdAt) => {
5936
+ const ms = Date.parse(createdAt);
5937
+ return ms > fromMs && ms <= toMs;
5938
+ };
5939
+ const events = store.listEvents(task.id).filter((e) => inWindow(e.createdAt));
5940
+ const messages = store.listMessages(task.id).filter((m) => inWindow(m.createdAt));
5941
+ const runs = store.listAgentRuns(task.id).filter((r) => inWindow(r.createdAt));
5942
+ const lines = [
5943
+ `Wake: ${wake.id}`,
5944
+ `Task: ${task.id}`,
5945
+ `Status: ${wake.status}`,
5946
+ `Reasons: ${wake.reasons.map(renderWakeReason).join(", ")}`,
5947
+ `Delta window: ${wake.fromCursor} → ${wake.toCursor}`,
5948
+ ...(wake.runId === undefined ? [] : [`Run: ${wake.runId}`]),
5949
+ `Dispatched: ${presentTime(wake.createdAt, timeZone)}`,
5950
+ ...(wake.consumedAt === undefined
5951
+ ? []
5952
+ : [`Consumed: ${presentTime(wake.consumedAt, timeZone)}`]),
5953
+ `Events (${events.length}):`,
5954
+ ...events.map((e) => ` ${e.id} ${e.type} ${presentTime(e.createdAt, timeZone)}`),
5955
+ `Messages (${messages.length}):`,
5956
+ ...messages.map((m) => ` ${m.id} [${taskMessageAuthorLabel(m.author)}] ${presentTime(m.createdAt, timeZone)}`),
5957
+ `Runs (${runs.length}):`,
5958
+ ...runs.map((r) => ` ${r.id} [${r.status}/${r.purpose}] ${r.roleName} ${presentTime(r.createdAt, timeZone)}`)
5959
+ ];
5960
+ return output(lines.join("\n").concat("\n"), {
5961
+ taskId: task.id,
5962
+ wake,
5963
+ events,
5964
+ messages,
5965
+ runs
5966
+ });
5967
+ }
5968
+ throw usageError(command === undefined
5969
+ ? "Task wake command is required."
5970
+ : `Unknown command: task wake ${command}`);
5971
+ }
5521
5972
  function parseMultiValueTail(args, valueOptions, repeatOptions, usage, flagOptions = new Set()) {
5522
5973
  const positionals = [];
5523
5974
  const options = new Map();