@osovv/vv-opencode 1.4.2 → 1.4.3-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +20 -6
  3. package/dist/lib/orchestration.js +12 -3
  4. package/dist/lib/orchestration.js.map +1 -1
  5. package/dist/lib/spec-lint.d.ts +2 -64
  6. package/dist/lib/spec-lint.js +47 -45
  7. package/dist/lib/spec-lint.js.map +1 -1
  8. package/dist/lib/workflow-contract.d.ts +277 -0
  9. package/dist/lib/workflow-contract.js +636 -0
  10. package/dist/lib/workflow-contract.js.map +1 -0
  11. package/dist/plugins/system-context-injection/index.js +15 -7
  12. package/dist/plugins/system-context-injection/index.js.map +1 -1
  13. package/dist/plugins/workflow/authority.d.ts +99 -0
  14. package/dist/plugins/workflow/authority.js +435 -0
  15. package/dist/plugins/workflow/authority.js.map +1 -0
  16. package/dist/plugins/workflow/checkpoint-io.d.ts +10 -1
  17. package/dist/plugins/workflow/checkpoint-io.js +22 -3
  18. package/dist/plugins/workflow/checkpoint-io.js.map +1 -1
  19. package/dist/plugins/workflow/checkpoints.d.ts +58 -1
  20. package/dist/plugins/workflow/checkpoints.js +362 -34
  21. package/dist/plugins/workflow/checkpoints.js.map +1 -1
  22. package/dist/plugins/workflow/delegated.d.ts +150 -2
  23. package/dist/plugins/workflow/delegated.js +551 -15
  24. package/dist/plugins/workflow/delegated.js.map +1 -1
  25. package/dist/plugins/workflow/execution.d.ts +361 -0
  26. package/dist/plugins/workflow/execution.js +1807 -0
  27. package/dist/plugins/workflow/execution.js.map +1 -0
  28. package/dist/plugins/workflow/index.js +539 -80
  29. package/dist/plugins/workflow/index.js.map +1 -1
  30. package/dist/plugins/workflow/persistence.d.ts +18 -4
  31. package/dist/plugins/workflow/persistence.js +439 -42
  32. package/dist/plugins/workflow/persistence.js.map +1 -1
  33. package/dist/plugins/workflow/repair.d.ts +2 -0
  34. package/dist/plugins/workflow/repair.js +16 -8
  35. package/dist/plugins/workflow/repair.js.map +1 -1
  36. package/dist/plugins/workflow/snapshots.js +3 -3
  37. package/dist/plugins/workflow/snapshots.js.map +1 -1
  38. package/dist/plugins/workflow/state.d.ts +13 -0
  39. package/dist/plugins/workflow/state.js +37 -7
  40. package/dist/plugins/workflow/state.js.map +1 -1
  41. package/dist/plugins/workflow/system-instruction.md +15 -1
  42. package/dist/plugins/workflow/tooling.d.ts +81 -6
  43. package/dist/plugins/workflow/tooling.js +956 -23
  44. package/dist/plugins/workflow/tooling.js.map +1 -1
  45. package/dist/plugins/workflow/transactions.d.ts +54 -0
  46. package/dist/plugins/workflow/transactions.js +147 -0
  47. package/dist/plugins/workflow/transactions.js.map +1 -0
  48. package/package.json +1 -1
  49. package/schemas/vvoc/v3.json +1 -1
  50. package/templates/agents/vv-code-reviewer.md +1 -0
  51. package/templates/agents/vv-controller.md +26 -13
  52. package/templates/agents/vv-implementer.md +1 -0
  53. package/templates/agents/vv-spec-reviewer.md +1 -0
  54. package/templates/skills/vv-execute/SKILL.md +6 -5
  55. package/templates/skills/vv-plan/SKILL.md +1 -1
  56. package/templates/skills/vv-review/SKILL.md +1 -0
  57. package/templates/skills/vv-spec/SKILL.md +2 -2
@@ -1,8 +1,8 @@
1
1
  // FILE: src/plugins/workflow/checkpoints.ts
2
- // VERSION: 1.0.0
2
+ // VERSION: 2.0.0
3
3
  // START_MODULE_CONTRACT
4
- // PURPOSE: Registered delegated plan runs with declared review checkpoints, generation-bound reviewer linkage, fingerprint-verified outcomes, and rework authorization.
5
- // SCOPE: Atomic plan-run registration binding canonical task ids to stable delegated work items, idempotent re-registration with explicit drift rejection, checkpoint start with prerequisite acceptance and fresh fingerprints through linked review_only work items, reviewer launch/result recording bound to the current generation, verify deriving passed/failed/stale/stopped outcomes from recorded results plus recomputed fingerprints and approval-input hashes, final complete sealing, failed-checkpoint rework authorization, wave-barrier and overlapping-write gates, and read-only run views. No agent dispatch or command execution.
4
+ // PURPOSE: Registered delegated plan runs with declared review checkpoints, generation-bound reviewer linkage, fingerprint-verified outcomes, rework authorization, and bounded checkpoint recovery.
5
+ // SCOPE: Atomic plan-run registration binding canonical task ids to stable delegated work items, idempotent re-registration with explicit drift rejection, checkpoint start with prerequisite acceptance and fresh fingerprints through linked review_only work items, reviewer launch/result recording bound to the current generation, verify deriving passed/failed/stale/stopped outcomes from recorded results plus recomputed fingerprints and approval-input hashes, final complete sealing, failed-checkpoint rework authorization, bounded recovery of stopped or generation-exhausted checkpoints (settling a stopped generation as historical evidence and granting at most one additional generation per autonomous or replay-protected root-user-authorized unit), wave-barrier and overlapping-write gates, and read-only run views exposing generation budgets and supported next actions. No agent dispatch or command execution.
6
6
  // DEPENDS: [node:crypto, src/plugins/workflow/checkpoint-io.ts, src/plugins/workflow/delegated.ts, src/plugins/workflow/snapshots.ts, src/plugins/workflow/state.ts]
7
7
  // LINKS: [M-WORKFLOW-CHECKPOINTS, M-WORKFLOW-DELEGATED, M-WORKFLOW-SNAPSHOTS, M-WORKFLOW-STATE, M-SPEC-LINT, V-M-WORKFLOW-CHECKPOINTS]
8
8
  // ROLE: RUNTIME
@@ -10,11 +10,12 @@
10
10
  // END_MODULE_CONTRACT
11
11
  //
12
12
  // START_MODULE_MAP
13
- // MAX_CHECKPOINT_REVIEW_ATTEMPTS - Maximum review generations per checkpoint (initial plus one correction).
13
+ // MAX_CHECKPOINT_REVIEW_ATTEMPTS - Maximum ordinary review generations per checkpoint (initial plus one correction).
14
14
  // DelegatedReviewerOutcome - One reviewer's recorded outcome inside a generation.
15
15
  // DelegatedCheckpointReview - Current in-flight review generation state.
16
16
  // DelegatedCheckpointHistoryEntry - One settled generation outcome as historical evidence.
17
- // DelegatedRunCheckpoint - Registered checkpoint runtime state.
17
+ // DelegatedCheckpointRecoveryRecord - One bounded checkpoint recovery event bound to its settled generation.
18
+ // DelegatedRunCheckpoint - Registered checkpoint runtime state with recovery history.
18
19
  // DelegatedRunTask - Canonical task to work-item binding.
19
20
  // DelegatedPlanRun - Registered plan-run registry entry.
20
21
  // RegisterDelegatedPlanInput - Registration input carrying the loaded approved plan.
@@ -27,6 +28,12 @@
27
28
  // VerifyDelegatedCheckpointResult - Derived verify outcome and optional sealed completion.
28
29
  // AuthorizeReworkInput - Failed-checkpoint rework authorization request.
29
30
  // AuthorizeReworkResult - Rework authorization outcome delegating to the guarded reducer.
31
+ // RecoverDelegatedCheckpointInput - Bounded checkpoint recovery request with optional user authorization.
32
+ // RecoverDelegatedCheckpointResult - Checkpoint recovery outcome with generation budget, or a coded rejection.
33
+ // checkpointRecoveryGrantCount - Number of budget-granting checkpoint recovery entries.
34
+ // checkpointGenerationBudget - Allowed generation count given consumed generations and recovery grants.
35
+ // checkpointAutonomousGrantConsumed - Whether the single autonomous checkpoint grant is already recorded.
36
+ // cloneDelegatedPlanRun - Deep clone of one registered run for staged persistence commits.
30
37
  // checkpointBarrierUnsatisfied - Unsatisfied barriers blocking a wave's task launches.
31
38
  // findOverlappingInFlightReview - In-flight checkpoint scope overlap detection for declared writes.
32
39
  // getDelegatedRunView - Read-only run serialization for tooling output.
@@ -42,15 +49,18 @@
42
49
  // verifyDelegatedCheckpointInStore - Store-level verify used by the plugin and tests.
43
50
  // authorizeReworkFromFailedCheckpoint - Validate failed-checkpoint rework authorization.
44
51
  // authorizeReworkFromFailedCheckpointInStore - Store-level rework authorization.
52
+ // recoverDelegatedCheckpoint - Resume a stopped generation or grant exactly one further generation after exhaustion.
53
+ // recoverDelegatedCheckpointInStore - Store-level guarded checkpoint recovery reducer.
45
54
  // END_MODULE_MAP
46
55
  //
47
56
  // START_CHANGE_SUMMARY
48
- // LAST_CHANGE: [C-DELEGATED-WORKFLOW-ASTRA-PRESETS - Initial registry: registration, generations, fingerprint verification, final sealing, rework authorization, and barrier gates.]
57
+ // LAST_CHANGE: [C-WORKFLOW-BOUNDED-RECOVERY-R1 - Added bounded checkpoint recovery: stopped generations settle as historical stopped evidence, exhausted checkpoints may grant one further generation per autonomous or root-user-authorized unit, and run views expose generation budgets and next actions.]
49
58
  // END_CHANGE_SUMMARY
50
59
  import { createHash } from "node:crypto";
51
60
  import { readFile } from "node:fs/promises";
52
61
  import { contentSha256 } from "./checkpoint-io.js";
53
- import { currentDelegatedAcceptance, reworkDelegatedWorkItem, validateDelegatedWriteScope, } from "./delegated.js";
62
+ import { ensureNativeExecutions } from "./execution.js";
63
+ import { currentDelegatedAcceptance, reworkDelegatedWorkItem, validateDelegatedRecoveryInput, validateDelegatedWriteScope, validateRecoveryUserAuthorization, } from "./delegated.js";
54
64
  import { captureWorkflowSnapshot } from "./snapshots.js";
55
65
  import { cloneRecord, createRecordLookupKey, } from "./state.js";
56
66
  // START_BLOCK_REGISTRY_TYPES
@@ -82,6 +92,10 @@ function cloneCheckpoint(checkpoint) {
82
92
  scope: [...checkpoint.scope],
83
93
  reviewers: [...checkpoint.reviewers],
84
94
  history: checkpoint.history.map((entry) => ({ ...entry })),
95
+ recoveryHistory: checkpoint.recoveryHistory.map((recovery) => ({
96
+ ...recovery,
97
+ verification: [...recovery.verification],
98
+ })),
85
99
  ...(checkpoint.currentReview
86
100
  ? {
87
101
  currentReview: {
@@ -94,6 +108,10 @@ function cloneCheckpoint(checkpoint) {
94
108
  : {}),
95
109
  };
96
110
  }
111
+ /** Deep clone of one registered run for staged persistence commits. */
112
+ export function cloneDelegatedPlanRun(run) {
113
+ return cloneRun(run);
114
+ }
97
115
  function cloneRun(run) {
98
116
  return {
99
117
  ...run,
@@ -101,6 +119,20 @@ function cloneRun(run) {
101
119
  checkpoints: new Map([...run.checkpoints].map(([id, checkpoint]) => [id, cloneCheckpoint(checkpoint)])),
102
120
  };
103
121
  }
122
+ /** Count of budget-granting checkpoint recovery entries. */
123
+ export function checkpointRecoveryGrantCount(history) {
124
+ return history.filter((entry) => entry.kind === "autonomous_grant" ||
125
+ entry.kind === "user_grant" ||
126
+ entry.kind === "advance_grant").length;
127
+ }
128
+ /** Allowed generation count: the ordinary maximum plus one per recovery grant. */
129
+ export function checkpointGenerationBudget(checkpoint) {
130
+ return MAX_CHECKPOINT_REVIEW_ATTEMPTS + checkpointRecoveryGrantCount(checkpoint.recoveryHistory);
131
+ }
132
+ /** Whether the single autonomous checkpoint grant is already recorded. */
133
+ export function checkpointAutonomousGrantConsumed(history) {
134
+ return history.some((entry) => entry.kind === "autonomous_grant");
135
+ }
104
136
  function waveIndexOf(run, wave) {
105
137
  return run.definition.waves.indexOf(wave);
106
138
  }
@@ -127,7 +159,13 @@ function currentCoveredAttemptIds(data, run, checkpoint) {
127
159
  // LINKS: [M-WORKFLOW-CHECKPOINTS, M-WORKFLOW-DELEGATED, loadApprovedDelegatedPlan]
128
160
  // END_CONTRACT: registerDelegatedPlan
129
161
  export function registerDelegatedPlan(store, input) {
130
- return registerDelegatedPlanInStore(store.getStoreData(), input);
162
+ const result = registerDelegatedPlanInStore(store.getStoreData(), input);
163
+ if (result.ok) {
164
+ // Materialize the common execution registry view for existing native
165
+ // consumers without altering native counters, hashes, or obligations.
166
+ ensureNativeExecutions(store.getStoreData());
167
+ }
168
+ return result;
131
169
  }
132
170
  export function registerDelegatedPlanInStore(data, input) {
133
171
  if (typeof input.sessionId !== "string" || input.sessionId.trim() === "") {
@@ -220,6 +258,7 @@ export function registerDelegatedPlanInStore(data, input) {
220
258
  status: "pending",
221
259
  attempts: 0,
222
260
  history: [],
261
+ recoveryHistory: [],
223
262
  });
224
263
  }
225
264
  const finalCheckpoint = input.plan.definition.checkpoints.find((c) => c.kind === "final");
@@ -295,6 +334,7 @@ function openInStore(data, input) {
295
334
  decisions: [],
296
335
  acceptances: [],
297
336
  reworkHistory: [],
337
+ recoveryHistory: [],
298
338
  },
299
339
  completedReviewRoundCount: 0,
300
340
  specReviewCount: 0,
@@ -376,11 +416,11 @@ export async function startDelegatedCheckpointInStore(data, input) {
376
416
  message: `ALREADY_IN_REVIEW: ${input.checkpointId} generation ${checkpoint.currentReview?.generation} is in flight`,
377
417
  };
378
418
  }
379
- if (checkpoint.attempts >= MAX_CHECKPOINT_REVIEW_ATTEMPTS) {
419
+ if (checkpoint.attempts >= checkpointGenerationBudget(checkpoint)) {
380
420
  return {
381
421
  ok: false,
382
422
  errorCode: "ATTEMPTS_EXHAUSTED",
383
- message: `ATTEMPTS_EXHAUSTED: ${input.checkpointId} consumed ${checkpoint.attempts} review generations; explicit recovery is required`,
423
+ message: `ATTEMPTS_EXHAUSTED: ${input.checkpointId} consumed ${checkpoint.attempts} of ${checkpointGenerationBudget(checkpoint)} allowed review generations; explicit recovery is required`,
384
424
  };
385
425
  }
386
426
  const covered = currentCoveredAttemptIds(data, run, checkpoint);
@@ -984,6 +1024,258 @@ export function authorizeReworkFromFailedCheckpointInStore(data, input) {
984
1024
  }
985
1025
  return { ok: true, reworkId: reworked.reworkId, grantedAttempts: reworked.grantedAttempts };
986
1026
  }
1027
+ /** Timestamp (epoch ms) of the most recent settled evidence in a checkpoint. */
1028
+ function checkpointStopTimeMs(checkpoint) {
1029
+ if (checkpoint.currentReview) {
1030
+ const recorded = Object.values(checkpoint.currentReview.results)
1031
+ .map((result) => (result ? Date.parse(result.recordedAt) : Number.NaN))
1032
+ .filter((value) => Number.isFinite(value));
1033
+ if (recorded.length > 0)
1034
+ return Math.max(...recorded);
1035
+ }
1036
+ const last = checkpoint.history[checkpoint.history.length - 1];
1037
+ if (last) {
1038
+ const parsed = Date.parse(last.completedAt);
1039
+ if (Number.isFinite(parsed))
1040
+ return parsed;
1041
+ }
1042
+ return undefined;
1043
+ }
1044
+ /**
1045
+ * Synchronous eligibility check for checkpoint recovery: a stopped generation
1046
+ * (NEEDS_CONTEXT settled nothing) or a generation-exhausted checkpoint. Live
1047
+ * reviews, pending checkpoints, and ordinary-budget failures are not
1048
+ * recoverable targets.
1049
+ */
1050
+ function precheckCheckpointRecovery(data, input) {
1051
+ const run = findRun(data, input.runId);
1052
+ if (!run) {
1053
+ return { ok: false, errorCode: "RUN_NOT_FOUND", message: `RUN_NOT_FOUND: ${input.runId}` };
1054
+ }
1055
+ if (run.sessionId !== input.sessionId) {
1056
+ return {
1057
+ ok: false,
1058
+ errorCode: "SESSION_MISMATCH",
1059
+ message: `SESSION_MISMATCH: run ${input.runId} belongs to session ${run.sessionId}`,
1060
+ };
1061
+ }
1062
+ if (run.status === "sealed") {
1063
+ return {
1064
+ ok: false,
1065
+ errorCode: "RUN_SEALED",
1066
+ message: `RUN_SEALED: run ${input.runId} is complete; recovery requires a new change`,
1067
+ };
1068
+ }
1069
+ const checkpoint = run.checkpoints.get(input.checkpointId);
1070
+ if (!checkpoint) {
1071
+ return {
1072
+ ok: false,
1073
+ errorCode: "CHECKPOINT_NOT_FOUND",
1074
+ message: `CHECKPOINT_NOT_FOUND: ${input.checkpointId}`,
1075
+ };
1076
+ }
1077
+ if (checkpoint.status === "passed") {
1078
+ return {
1079
+ ok: false,
1080
+ errorCode: "ALREADY_PASSED",
1081
+ message: `ALREADY_PASSED: ${input.checkpointId} passed at generation ${checkpoint.attempts}`,
1082
+ };
1083
+ }
1084
+ const stopped = checkpoint.status === "in_review" &&
1085
+ checkpoint.lastOutcome === "stopped" &&
1086
+ checkpoint.currentReview !== undefined;
1087
+ const budget = checkpointGenerationBudget(checkpoint);
1088
+ const exhausted = checkpoint.status === "failed" && checkpoint.attempts >= budget;
1089
+ if (!stopped && !exhausted) {
1090
+ if (checkpoint.status === "in_review") {
1091
+ return {
1092
+ ok: false,
1093
+ errorCode: "INVALID_TARGET_STATE",
1094
+ message: `INVALID_TARGET_STATE: ${input.checkpointId} generation ${checkpoint.currentReview?.generation} is live; recovery never interrupts in-flight review work`,
1095
+ };
1096
+ }
1097
+ if (checkpoint.status === "failed") {
1098
+ return {
1099
+ ok: false,
1100
+ errorCode: "INVALID_TARGET_STATE",
1101
+ message: `INVALID_TARGET_STATE: ${input.checkpointId} still has ${budget - checkpoint.attempts} ordinary generation(s) available; start the next generation instead of recovering`,
1102
+ };
1103
+ }
1104
+ return {
1105
+ ok: false,
1106
+ errorCode: "INVALID_TARGET_STATE",
1107
+ message: `INVALID_TARGET_STATE: ${input.checkpointId} is ${checkpoint.status} and has nothing to recover`,
1108
+ };
1109
+ }
1110
+ if (checkpoint.recoveryHistory.some((entry) => entry.recoveryId === input.recoveryId)) {
1111
+ return {
1112
+ ok: false,
1113
+ errorCode: "DUPLICATE_RECOVERY_ID",
1114
+ message: `DUPLICATE_RECOVERY_ID: recoveryId ${input.recoveryId} is already recorded for ${input.checkpointId}`,
1115
+ };
1116
+ }
1117
+ return {
1118
+ ok: true,
1119
+ checkpoint,
1120
+ stopped,
1121
+ needsGrant: exhausted || checkpoint.attempts >= budget,
1122
+ autonomousAvailable: !checkpointAutonomousGrantConsumed(checkpoint.recoveryHistory),
1123
+ stopTimeMs: checkpointStopTimeMs(checkpoint),
1124
+ };
1125
+ }
1126
+ export async function recoverDelegatedCheckpoint(store, input) {
1127
+ return recoverDelegatedCheckpointInStore(store.getStoreData(), input);
1128
+ }
1129
+ export async function recoverDelegatedCheckpointInStore(data, input) {
1130
+ const validation = validateDelegatedRecoveryInput({
1131
+ diagnosis: input.diagnosis,
1132
+ changedCondition: input.changedCondition,
1133
+ verification: input.verification,
1134
+ recoveryId: input.recoveryId,
1135
+ });
1136
+ if (!validation.ok) {
1137
+ return {
1138
+ ok: false,
1139
+ errorCode: "INVALID_INPUT",
1140
+ message: `INVALID_INPUT: ${validation.message}`,
1141
+ };
1142
+ }
1143
+ const precheck = precheckCheckpointRecovery(data, input);
1144
+ if (!precheck.ok) {
1145
+ return { ok: false, errorCode: precheck.errorCode, message: precheck.message };
1146
+ }
1147
+ const commitRecovery = (kind, validatedMessageTimeMs) => {
1148
+ // Re-run the synchronous eligibility check immediately before committing
1149
+ // so an authorization await can never commit against a changed target.
1150
+ const fresh = precheckCheckpointRecovery(data, input);
1151
+ if (!fresh.ok) {
1152
+ return { ok: false, errorCode: fresh.errorCode, message: fresh.message };
1153
+ }
1154
+ // The authorization message was timed against the pre-await stop; if the
1155
+ // target moved to a newer generation during the await, that timing no
1156
+ // longer authorizes this recovery.
1157
+ if (validatedMessageTimeMs !== undefined &&
1158
+ fresh.stopTimeMs !== undefined &&
1159
+ validatedMessageTimeMs < fresh.stopTimeMs) {
1160
+ return {
1161
+ ok: false,
1162
+ errorCode: "AUTHORIZATION_STALE",
1163
+ message: `AUTHORIZATION_STALE: message ${String(input.userMessageId)} predates the stop it must authorize`,
1164
+ };
1165
+ }
1166
+ const now = toIsoNow();
1167
+ const stoppedReview = fresh.stopped ? fresh.checkpoint.currentReview : undefined;
1168
+ const recovery = {
1169
+ recoveryId: input.recoveryId,
1170
+ targetGeneration: stoppedReview?.generation ?? fresh.checkpoint.attempts,
1171
+ kind,
1172
+ diagnosis: input.diagnosis.trim(),
1173
+ changedCondition: input.changedCondition.trim(),
1174
+ verification: input.verification.map((reference) => reference.trim()),
1175
+ recoveredAt: now,
1176
+ ...(kind === "user_grant" && input.userMessageId
1177
+ ? { userMessageId: input.userMessageId }
1178
+ : {}),
1179
+ };
1180
+ let updated;
1181
+ if (stoppedReview) {
1182
+ // Settle the stopped generation as historical stopped evidence. The
1183
+ // FAIL-less stop is not converted into a failure: rework stays locked
1184
+ // until a real failed generation exists.
1185
+ updated = {
1186
+ ...fresh.checkpoint,
1187
+ status: "failed",
1188
+ lastOutcome: "stopped",
1189
+ currentReview: undefined,
1190
+ history: [
1191
+ ...fresh.checkpoint.history,
1192
+ {
1193
+ generation: stoppedReview.generation,
1194
+ outcome: "stopped",
1195
+ fingerprint: stoppedReview.startFingerprint,
1196
+ completedAt: now,
1197
+ },
1198
+ ],
1199
+ recoveryHistory: [...fresh.checkpoint.recoveryHistory, recovery],
1200
+ };
1201
+ }
1202
+ else {
1203
+ updated = {
1204
+ ...fresh.checkpoint,
1205
+ recoveryHistory: [...fresh.checkpoint.recoveryHistory, recovery],
1206
+ };
1207
+ }
1208
+ findRun(data, input.runId).checkpoints.set(input.checkpointId, updated);
1209
+ const generationBudget = checkpointGenerationBudget(updated);
1210
+ return {
1211
+ ok: true,
1212
+ checkpoint: cloneCheckpoint(updated),
1213
+ recoveryId: input.recoveryId,
1214
+ kind,
1215
+ generationBudget,
1216
+ ...(stoppedReview ? { settledStoppedGeneration: stoppedReview.generation } : {}),
1217
+ };
1218
+ };
1219
+ if (!precheck.needsGrant) {
1220
+ return commitRecovery("resume");
1221
+ }
1222
+ if (input.userMessageId !== undefined) {
1223
+ const userMessageId = input.userMessageId.trim();
1224
+ if (!userMessageId) {
1225
+ return {
1226
+ ok: false,
1227
+ errorCode: "INVALID_INPUT",
1228
+ message: "INVALID_INPUT: userMessageId must be a non-empty string when provided",
1229
+ };
1230
+ }
1231
+ if (precheck.checkpoint.recoveryHistory.some((entry) => entry.userMessageId === userMessageId)) {
1232
+ return {
1233
+ ok: false,
1234
+ errorCode: "AUTHORIZATION_REUSED",
1235
+ message: `AUTHORIZATION_REUSED: message ${userMessageId} already authorized recovery of ${input.checkpointId}; each message grants at most one unit per target`,
1236
+ };
1237
+ }
1238
+ if (typeof input.lookupUserMessage !== "function") {
1239
+ return {
1240
+ ok: false,
1241
+ errorCode: "AUTHORIZATION_LOOKUP_FAILED",
1242
+ message: "AUTHORIZATION_LOOKUP_FAILED: user-authorized recovery requires a read-only message lookup bound to the plugin context",
1243
+ };
1244
+ }
1245
+ const authorization = await validateRecoveryUserAuthorization({
1246
+ owningSessionId: input.sessionId,
1247
+ userMessageId,
1248
+ requireAfterMs: precheck.stopTimeMs,
1249
+ lookup: input.lookupUserMessage,
1250
+ });
1251
+ if (!authorization.ok) {
1252
+ return { ok: false, errorCode: authorization.errorCode, message: authorization.message };
1253
+ }
1254
+ // The await above is an async boundary: re-verify non-reuse before commit
1255
+ // (the precheck inside commitRecovery covers the rest of the state).
1256
+ const freshCheckpoint = findRun(data, input.runId)?.checkpoints.get(input.checkpointId);
1257
+ if (freshCheckpoint?.recoveryHistory.some((entry) => entry.userMessageId === userMessageId)) {
1258
+ return {
1259
+ ok: false,
1260
+ errorCode: "AUTHORIZATION_REUSED",
1261
+ message: `AUTHORIZATION_REUSED: message ${userMessageId} already authorized recovery of ${input.checkpointId}`,
1262
+ };
1263
+ }
1264
+ return commitRecovery("user_grant", authorization.timeCreatedMs);
1265
+ }
1266
+ if (precheck.autonomousAvailable) {
1267
+ return commitRecovery("autonomous_grant");
1268
+ }
1269
+ if (input.advanceGrantApproved === true) {
1270
+ return commitRecovery("advance_grant");
1271
+ }
1272
+ return {
1273
+ ok: false,
1274
+ errorCode: "AUTONOMOUS_GRANT_EXHAUSTED",
1275
+ message: `AUTONOMOUS_GRANT_EXHAUSTED: the single autonomous recovery grant for ${input.checkpointId} is consumed; one further generation requires a recorded advance authority or a fresh root-user message referenced by userMessageId`,
1276
+ };
1277
+ }
1278
+ // END_BLOCK_CHECKPOINT_RECOVERY
987
1279
  // START_BLOCK_BARRIER_GATES
988
1280
  /** Unsatisfied checkpoint barriers blocking task launches for the given wave, in declaration order. */
989
1281
  export function checkpointBarrierUnsatisfied(data, runId, wave) {
@@ -1018,8 +1310,37 @@ export function findOverlappingInFlightReview(data, runId, writeScope) {
1018
1310
  }
1019
1311
  return undefined;
1020
1312
  }
1021
- // END_BLOCK_BARRIER_GATES
1022
- // START_BLOCK_RUN_VIEW
1313
+ function checkpointNextAction(checkpoint) {
1314
+ const generationBudget = checkpointGenerationBudget(checkpoint);
1315
+ const remainingGenerations = Math.max(0, generationBudget - checkpoint.attempts);
1316
+ if (checkpoint.status === "passed") {
1317
+ return { generationBudget, remainingGenerations, nextAction: "passed" };
1318
+ }
1319
+ if (checkpoint.status === "in_review") {
1320
+ if (checkpoint.lastOutcome === "stopped") {
1321
+ // The linked review item is hard-stopped: re-running verify is a no-op,
1322
+ // so the supported action is bounded recovery, never collection.
1323
+ return { generationBudget, remainingGenerations, nextAction: "recover" };
1324
+ }
1325
+ return { generationBudget, remainingGenerations, nextAction: "collect_and_verify" };
1326
+ }
1327
+ if (checkpoint.status === "pending") {
1328
+ return { generationBudget, remainingGenerations, nextAction: "start" };
1329
+ }
1330
+ // Failed: suggest an ordinary next generation while budget remains, then
1331
+ // bounded recovery, then user-authorized recovery after the autonomous
1332
+ // grant is consumed. The suggestion never proposes a start the same state
1333
+ // would immediately reject.
1334
+ if (remainingGenerations > 0) {
1335
+ return { generationBudget, remainingGenerations, nextAction: "start_next_generation" };
1336
+ }
1337
+ const autonomousAvailable = !checkpointAutonomousGrantConsumed(checkpoint.recoveryHistory);
1338
+ return {
1339
+ generationBudget,
1340
+ remainingGenerations,
1341
+ nextAction: autonomousAvailable ? "recover" : "recover_with_user_authorization",
1342
+ };
1343
+ }
1023
1344
  /** Read-only run serialization for tooling output and persistence consumers. */
1024
1345
  export function getDelegatedRunView(data, runId) {
1025
1346
  const run = findRun(data, runId);
@@ -1039,28 +1360,35 @@ export function getDelegatedRunView(data, runId) {
1039
1360
  taskId: task.taskId,
1040
1361
  workItemId: task.workItemId,
1041
1362
  })),
1042
- checkpoints: [...run.checkpoints.values()].map((checkpoint) => ({
1043
- checkpointId: checkpoint.checkpointId,
1044
- kind: checkpoint.kind,
1045
- afterWave: checkpoint.afterWave,
1046
- covers: checkpoint.covers,
1047
- scope: checkpoint.scope,
1048
- reviewers: checkpoint.reviewers,
1049
- status: checkpoint.status,
1050
- attempts: checkpoint.attempts,
1051
- ...(checkpoint.lastOutcome ? { lastOutcome: checkpoint.lastOutcome } : {}),
1052
- ...(checkpoint.currentReview
1053
- ? {
1054
- currentReview: {
1055
- reviewWorkItemId: checkpoint.currentReview.reviewWorkItemId,
1056
- generation: checkpoint.currentReview.generation,
1057
- coveredAttemptIds: checkpoint.currentReview.coveredAttemptIds,
1058
- recordedReviewers: Object.keys(checkpoint.currentReview.results),
1059
- },
1060
- }
1061
- : {}),
1062
- history: checkpoint.history,
1063
- })),
1363
+ checkpoints: [...run.checkpoints.values()].map((checkpoint) => {
1364
+ const progress = checkpointNextAction(checkpoint);
1365
+ return {
1366
+ checkpointId: checkpoint.checkpointId,
1367
+ kind: checkpoint.kind,
1368
+ afterWave: checkpoint.afterWave,
1369
+ covers: checkpoint.covers,
1370
+ scope: checkpoint.scope,
1371
+ reviewers: checkpoint.reviewers,
1372
+ status: checkpoint.status,
1373
+ attempts: checkpoint.attempts,
1374
+ ...(checkpoint.lastOutcome ? { lastOutcome: checkpoint.lastOutcome } : {}),
1375
+ generationBudget: progress.generationBudget,
1376
+ remainingGenerations: progress.remainingGenerations,
1377
+ recoveryCount: checkpointRecoveryGrantCount(checkpoint.recoveryHistory),
1378
+ nextAction: progress.nextAction,
1379
+ ...(checkpoint.currentReview
1380
+ ? {
1381
+ currentReview: {
1382
+ reviewWorkItemId: checkpoint.currentReview.reviewWorkItemId,
1383
+ generation: checkpoint.currentReview.generation,
1384
+ coveredAttemptIds: checkpoint.currentReview.coveredAttemptIds,
1385
+ recordedReviewers: Object.keys(checkpoint.currentReview.results),
1386
+ },
1387
+ }
1388
+ : {}),
1389
+ history: checkpoint.history,
1390
+ };
1391
+ }),
1064
1392
  };
1065
1393
  }
1066
1394
  // END_BLOCK_RUN_VIEW