@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,22 +1,29 @@
1
1
  // FILE: src/plugins/workflow/persistence.ts
2
- // VERSION: 0.3.0
2
+ // VERSION: 0.4.0
3
3
  // START_MODULE_CONTRACT
4
4
  // PURPOSE: Hydrate and snapshot work-item workflow state from/to per-session JSON
5
5
  // files under $XDG_DATA_HOME/vvoc/workflow/<sessionId>/workflow-state.json.
6
6
  // SCOPE: Read/write WorkItemStoreData (nextId, records, keyIndexBySession,
7
- // planRuns) as serializable JSON. Version 2 snapshots additionally persist
8
- // delegated attempts, decisions, acceptances, rework history, and registered
9
- // plan runs with checkpoint generations through an atomic temporary-file
10
- // replacement, including failed attempts carrying bounded host failure
11
- // evidence. Version 1 files hydrate conservatively as legacy records with
12
- // an empty plan-run registry and never synthesize acceptance or approval.
7
+ // planRuns, executions, messageClaims) as serializable JSON. Version 4
8
+ // snapshots additionally persist the common execution registry and
9
+ // session-wide root-message claims. Version 3 snapshots persist
10
+ // delegated and checkpoint recovery histories with replay-protected
11
+ // authorization references, recovery-aware attempt and generation budgets,
12
+ // and report_rejected attempts carrying bounded rejected-report evidence
13
+ // through an atomic temporary-file replacement. Version 2 files hydrate
14
+ // conservatively with empty recovery histories and unchanged budgets;
15
+ // version 1 files hydrate as legacy records with an empty plan-run
16
+ // registry. Existing native plan runs materialize a compatibility
17
+ // execution registry entry without inventing authority. Neither ever
18
+ // synthesizes acceptance, approval, or recovery.
13
19
  // Strict validation rejects malformed or contradictory new state instead of
14
20
  // silently restarting a run. A checked loader distinguishes missing, valid,
15
21
  // and invalid state and surfaces I/O failures.
16
22
  // DEPENDS: [node:fs, node:fs/promises, node:path, src/lib/vvoc-paths.ts,
23
+ // src/lib/workflow-contract.ts,
17
24
  // src/plugins/workflow/checkpoints.ts (types), src/plugins/workflow/delegated.ts,
18
- // src/plugins/workflow/state.ts]
19
- // LINKS: M-WORKFLOW-PERSISTENCE, M-CONFIG-LAYERS, M-WORKFLOW-STATE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, V-M-WORKFLOW-PERSISTENCE
25
+ // src/plugins/workflow/execution.ts, src/plugins/workflow/state.ts]
26
+ // LINKS: M-WORKFLOW-PERSISTENCE, M-CONFIG-LAYERS, M-WORKFLOW-STATE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, M-WORKFLOW-EXECUTION, M-WORKFLOW-CONTRACT, V-M-WORKFLOW-PERSISTENCE
20
27
  // ROLE: RUNTIME
21
28
  // MAP_MODE: EXPORTS
22
29
  // END_MODULE_CONTRACT
@@ -25,6 +32,7 @@
25
32
  // PERSISTED_WORKFLOW_STATE_VERSION - Current persisted snapshot version.
26
33
  // PersistedWorkflowState - JSON-serializable shape of a per-session workflow state.
27
34
  // SerializedDelegatedPlanRun - JSON form of one registered plan run.
35
+ // SerializedWorkflowExecution - JSON form of one common execution registry entry.
28
36
  // HydratedWorkflowStateResult - Missing/valid/invalid triage returned by the checked loader.
29
37
  // SnapshotWorkflowStateResult - Write outcome returned by the checked snapshot path.
30
38
  // getWorkflowSessionDir - Resolve per-session directory path.
@@ -36,16 +44,20 @@
36
44
  // END_MODULE_MAP
37
45
  //
38
46
  // START_CHANGE_SUMMARY
39
- // LAST_CHANGE: [direct fix inFlightAttempt after failed worker launch - Version 2 validation accepts failed delegated attempts with bounded failure evidence and rejects contradictory failed/completed/in-flight attempt payloads.]
47
+ // LAST_CHANGE: [C-WORKFLOW-PLAN-INDEPENDENCE - Version 4 persists the common execution registry and session-wide message claims while explicitly migrating v1/v2/v3 without clearing recovery, rejected-report, or stopped-generation history.]
40
48
  // END_CHANGE_SUMMARY
41
49
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
42
50
  import { rm } from "node:fs/promises";
43
51
  import { join } from "node:path";
44
52
  import { getGlobalVvocDataDir } from "../../lib/vvoc-paths.js";
45
- import { normalizeDeclaredScopePath } from "../../lib/spec-lint.js";
46
- import { DELEGATED_BASE_ATTEMPTS, DELEGATED_EVIDENCE_MAX_CHARS, DELEGATED_EVIDENCE_MAX_REFS, DELEGATED_RATIONALE_MAX_CHARS, } from "./delegated.js";
53
+ import { normalizeDeclaredScopePath } from "../../lib/workflow-contract.js";
54
+ import { ensureNativeExecutions } from "./execution.js";
55
+ import { DELEGATED_BASE_ATTEMPTS, DELEGATED_EVIDENCE_MAX_CHARS, DELEGATED_EVIDENCE_MAX_REFS, DELEGATED_RATIONALE_MAX_CHARS, DELEGATED_RECOVERY_CODE_MAX_CHARS, delegatedRecoveryGrantCount, } from "./delegated.js";
56
+ import { MAX_CHECKPOINT_REVIEW_ATTEMPTS, checkpointRecoveryGrantCount } from "./checkpoints.js";
47
57
  // START_BLOCK_SERIALIZATION_TYPES
48
- export const PERSISTED_WORKFLOW_STATE_VERSION = 2;
58
+ export const PERSISTED_WORKFLOW_STATE_VERSION = 4;
59
+ /** Version at which delegated/checkpoint recovery histories and report rejections were introduced. */
60
+ const RECOVERY_STATE_VERSION = 3;
49
61
  // END_BLOCK_SERIALIZATION_TYPES
50
62
  const VALID_STATES = new Set([
51
63
  "open",
@@ -63,7 +75,7 @@ const DELEGATED_RESULT_STATUSES = new Set([
63
75
  "NEEDS_CONTEXT",
64
76
  "BLOCKED",
65
77
  ]);
66
- const CHECKPOINT_OUTCOMES = new Set(["passed", "failed", "stale"]);
78
+ const CHECKPOINT_OUTCOMES = new Set(["passed", "failed", "stale", "stopped"]);
67
79
  const LAST_OUTCOMES = new Set([
68
80
  "passed",
69
81
  "failed",
@@ -71,6 +83,17 @@ const LAST_OUTCOMES = new Set([
71
83
  "stopped",
72
84
  "incomplete",
73
85
  ]);
86
+ const RECOVERY_KINDS = new Set([
87
+ "resume",
88
+ "autonomous_grant",
89
+ "user_grant",
90
+ "advance_grant",
91
+ ]);
92
+ const TERMINAL_ATTEMPT_STATUSES = new Set([
93
+ "completed",
94
+ "failed",
95
+ "report_rejected",
96
+ ]);
74
97
  function isWorkItemMode(value) {
75
98
  return value === "implementation" || value === "review_only" || value === "delegated";
76
99
  }
@@ -167,7 +190,7 @@ function validateBoundedEvidence(value, label, errors) {
167
190
  return true;
168
191
  }
169
192
  /** Validate the delegated-mode record extension; returns every contradiction found. */
170
- function validateDelegatedState(record, sessionId, errors) {
193
+ function validateDelegatedState(record, sessionId, version, errors) {
171
194
  const delegated = record.delegated;
172
195
  if (!delegated || typeof delegated !== "object") {
173
196
  errors.push(`${record.workItemId}: delegated records require a delegated state object`);
@@ -199,7 +222,8 @@ function validateDelegatedState(record, sessionId, errors) {
199
222
  inFlight += 1;
200
223
  if (attempt.resultStatus !== undefined ||
201
224
  attempt.completedAt !== undefined ||
202
- attempt.failureExcerpt !== undefined) {
225
+ attempt.failureExcerpt !== undefined ||
226
+ attempt.reportRejection !== undefined) {
203
227
  errors.push(`${record.workItemId}: in-flight attempt ${attempt.attempt} must not carry a result`);
204
228
  }
205
229
  }
@@ -210,22 +234,43 @@ function validateDelegatedState(record, sessionId, errors) {
210
234
  if (typeof attempt.completedAt !== "string") {
211
235
  errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} requires completedAt`);
212
236
  }
213
- if (attempt.failureExcerpt !== undefined) {
214
- errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} must not carry a failure excerpt`);
237
+ if (attempt.failureExcerpt !== undefined || attempt.reportRejection !== undefined) {
238
+ errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} must not carry failure or rejection evidence`);
215
239
  }
216
240
  }
217
241
  else if (attempt.status === "failed") {
218
242
  if (typeof attempt.completedAt !== "string") {
219
243
  errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} requires completedAt`);
220
244
  }
221
- if (attempt.resultStatus !== undefined) {
222
- errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} must not carry a resultStatus`);
245
+ if (attempt.resultStatus !== undefined || attempt.reportRejection !== undefined) {
246
+ errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} must not carry a result or rejection`);
223
247
  }
224
248
  if (attempt.failureExcerpt === undefined ||
225
249
  !isWorkflowResultExcerpt(attempt.failureExcerpt)) {
226
250
  errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} requires a bounded failure excerpt`);
227
251
  }
228
252
  }
253
+ else if (attempt.status === "report_rejected") {
254
+ if (version < RECOVERY_STATE_VERSION) {
255
+ errors.push(`${record.workItemId}: report_rejected attempts require persisted version ${RECOVERY_STATE_VERSION}`);
256
+ }
257
+ if (typeof attempt.completedAt !== "string") {
258
+ errors.push(`${record.workItemId}: report_rejected attempt ${attempt.attempt} requires completedAt`);
259
+ }
260
+ if (attempt.resultStatus !== undefined || attempt.failureExcerpt !== undefined) {
261
+ errors.push(`${record.workItemId}: report_rejected attempt ${attempt.attempt} must not carry a synthesized result`);
262
+ }
263
+ const rejection = attempt.reportRejection;
264
+ if (!rejection ||
265
+ typeof rejection !== "object" ||
266
+ typeof rejection.protocolErrorCode !== "string" ||
267
+ rejection.protocolErrorCode.trim() === "" ||
268
+ rejection.protocolErrorCode.length > DELEGATED_RECOVERY_CODE_MAX_CHARS ||
269
+ typeof rejection.rejectedAt !== "string" ||
270
+ !isWorkflowResultExcerpt(rejection.excerpt)) {
271
+ errors.push(`${record.workItemId}: report_rejected attempt ${attempt.attempt} requires a bounded rejection record`);
272
+ }
273
+ }
229
274
  else {
230
275
  errors.push(`${record.workItemId}: attempt ${attempt.attempt} has invalid status ${attempt.status}`);
231
276
  }
@@ -240,8 +285,63 @@ function validateDelegatedState(record, sessionId, errors) {
240
285
  if (delegated.reworkHistory.some((rework) => !rework.reworkId || !rework.authorizedByCheckpoint)) {
241
286
  errors.push(`${record.workItemId}: rework history entries require ids and checkpoint authorization`);
242
287
  }
243
- if (attempts.length > DELEGATED_BASE_ATTEMPTS + delegated.reworkHistory.length) {
244
- errors.push(`${record.workItemId}: attempts exceed the base budget plus authorized rework grants`);
288
+ const recoveryHistory = delegated.recoveryHistory;
289
+ if (!Array.isArray(recoveryHistory)) {
290
+ if (version >= RECOVERY_STATE_VERSION) {
291
+ errors.push(`${record.workItemId}: version 3 delegated records require a recovery history`);
292
+ }
293
+ }
294
+ else if (recoveryHistory.length > 0 && version < RECOVERY_STATE_VERSION) {
295
+ errors.push(`${record.workItemId}: recovery history requires persisted version ${RECOVERY_STATE_VERSION}`);
296
+ }
297
+ else {
298
+ const recoveryIds = new Set();
299
+ const userMessageIds = new Set();
300
+ let autonomousGrants = 0;
301
+ for (const recovery of recoveryHistory) {
302
+ if (!recovery.recoveryId || recoveryIds.has(recovery.recoveryId)) {
303
+ errors.push(`${record.workItemId}: recovery entries require unique non-empty recoveryId values`);
304
+ }
305
+ recoveryIds.add(recovery.recoveryId ?? "");
306
+ if (!RECOVERY_KINDS.has(recovery.kind)) {
307
+ errors.push(`${record.workItemId}: recovery ${recovery.recoveryId} has an invalid kind`);
308
+ }
309
+ if (recovery.kind === "autonomous_grant") {
310
+ autonomousGrants += 1;
311
+ }
312
+ if (recovery.kind === "user_grant") {
313
+ if (typeof recovery.userMessageId !== "string" || recovery.userMessageId === "") {
314
+ errors.push(`${record.workItemId}: user-grant recovery ${recovery.recoveryId} requires its authorizing message id`);
315
+ }
316
+ else if (userMessageIds.has(recovery.userMessageId)) {
317
+ errors.push(`${record.workItemId}: message ${recovery.userMessageId} authorized more than one recovery unit`);
318
+ }
319
+ else {
320
+ userMessageIds.add(recovery.userMessageId);
321
+ }
322
+ }
323
+ else if (recovery.userMessageId !== undefined) {
324
+ errors.push(`${record.workItemId}: non-user recovery ${recovery.recoveryId} must not persist an authorization reference`);
325
+ }
326
+ if (!Number.isInteger(recovery.targetAttempt) ||
327
+ recovery.targetAttempt < 1 ||
328
+ recovery.targetAttempt > attempts.length ||
329
+ !TERMINAL_ATTEMPT_STATUSES.has(attempts[recovery.targetAttempt - 1]?.status ?? "")) {
330
+ errors.push(`${record.workItemId}: recovery ${recovery.recoveryId} targets a non-terminal attempt`);
331
+ }
332
+ validateDelegatedText(recovery.diagnosis, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: recovery ${recovery.recoveryId} diagnosis`, errors);
333
+ validateDelegatedText(recovery.changedCondition, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: recovery ${recovery.recoveryId} changedCondition`, errors);
334
+ validateBoundedEvidence(recovery.verification, `${record.workItemId}: recovery ${recovery.recoveryId} verification`, errors);
335
+ }
336
+ if (autonomousGrants > 1) {
337
+ errors.push(`${record.workItemId}: at most one autonomous recovery grant may be recorded per item`);
338
+ }
339
+ }
340
+ if (attempts.length >
341
+ DELEGATED_BASE_ATTEMPTS +
342
+ delegated.reworkHistory.length +
343
+ delegatedRecoveryGrantCount(delegated.recoveryHistory ?? [])) {
344
+ errors.push(`${record.workItemId}: attempts exceed the base budget plus authorized rework and recovery grants`);
245
345
  }
246
346
  const decisions = delegated.decisions;
247
347
  if (!Array.isArray(decisions)) {
@@ -310,7 +410,7 @@ function validateDelegatedState(record, sessionId, errors) {
310
410
  errors.push(`${record.workItemId}: record session mismatch`);
311
411
  }
312
412
  }
313
- function isWorkItemRecord(value, sessionId, errors) {
413
+ function isWorkItemRecord(value, sessionId, version, errors) {
314
414
  if (!value || typeof value !== "object")
315
415
  return false;
316
416
  const record = value;
@@ -333,7 +433,7 @@ function isWorkItemRecord(value, sessionId, errors) {
333
433
  return false;
334
434
  if (record.mode === "delegated") {
335
435
  const recordErrors = [];
336
- validateDelegatedState(record, sessionId, recordErrors);
436
+ validateDelegatedState(record, sessionId, version, recordErrors);
337
437
  errors.push(...recordErrors);
338
438
  return recordErrors.length === 0;
339
439
  }
@@ -347,7 +447,7 @@ function isWorkItemRecord(value, sessionId, errors) {
347
447
  }
348
448
  return true;
349
449
  }
350
- function validatePlanRun(run, recordsById, sessionId, errors) {
450
+ function validatePlanRun(run, recordsById, sessionId, version, errors) {
351
451
  if (!run || typeof run !== "object") {
352
452
  errors.push("plan run entries must be objects");
353
453
  return false;
@@ -429,10 +529,68 @@ function validatePlanRun(run, recordsById, sessionId, errors) {
429
529
  if (!CHECKPOINT_OUTCOMES.has(entry.outcome)) {
430
530
  errors.push(`checkpoint ${checkpoint.checkpointId} history has invalid outcome ${entry.outcome}`);
431
531
  }
532
+ if (entry.outcome === "stopped" && version < RECOVERY_STATE_VERSION) {
533
+ errors.push(`checkpoint ${checkpoint.checkpointId} stopped history requires persisted version ${RECOVERY_STATE_VERSION}`);
534
+ }
432
535
  if (typeof entry.generation !== "number" || typeof entry.fingerprint !== "string") {
433
536
  errors.push(`checkpoint ${checkpoint.checkpointId} history entry is malformed`);
434
537
  }
435
538
  }
539
+ const recoveryHistory = checkpoint.recoveryHistory;
540
+ if (!Array.isArray(recoveryHistory)) {
541
+ if (version >= RECOVERY_STATE_VERSION) {
542
+ errors.push(`checkpoint ${checkpoint.checkpointId} requires a recovery history at persisted version ${RECOVERY_STATE_VERSION}`);
543
+ }
544
+ }
545
+ else if (recoveryHistory.length > 0 && version < RECOVERY_STATE_VERSION) {
546
+ errors.push(`checkpoint ${checkpoint.checkpointId} recovery history requires persisted version ${RECOVERY_STATE_VERSION}`);
547
+ }
548
+ else {
549
+ const recoveryIds = new Set();
550
+ const userMessageIds = new Set();
551
+ let autonomousGrants = 0;
552
+ for (const recovery of recoveryHistory) {
553
+ if (!recovery.recoveryId || recoveryIds.has(recovery.recoveryId)) {
554
+ errors.push(`checkpoint ${checkpoint.checkpointId} recovery entries require unique non-empty recoveryId values`);
555
+ }
556
+ recoveryIds.add(recovery.recoveryId ?? "");
557
+ if (!RECOVERY_KINDS.has(recovery.kind)) {
558
+ errors.push(`checkpoint ${checkpoint.checkpointId} recovery ${recovery.recoveryId} has an invalid kind`);
559
+ }
560
+ if (recovery.kind === "autonomous_grant") {
561
+ autonomousGrants += 1;
562
+ }
563
+ if (recovery.kind === "user_grant") {
564
+ if (typeof recovery.userMessageId !== "string" || recovery.userMessageId === "") {
565
+ errors.push(`checkpoint ${checkpoint.checkpointId} user-grant recovery ${recovery.recoveryId} requires its authorizing message id`);
566
+ }
567
+ else if (userMessageIds.has(recovery.userMessageId)) {
568
+ errors.push(`checkpoint ${checkpoint.checkpointId} message ${recovery.userMessageId} authorized more than one recovery unit`);
569
+ }
570
+ else {
571
+ userMessageIds.add(recovery.userMessageId);
572
+ }
573
+ }
574
+ else if (recovery.userMessageId !== undefined) {
575
+ errors.push(`checkpoint ${checkpoint.checkpointId} non-user recovery ${recovery.recoveryId} must not persist an authorization reference`);
576
+ }
577
+ if (!Number.isInteger(recovery.targetGeneration) ||
578
+ recovery.targetGeneration < 1 ||
579
+ recovery.targetGeneration > checkpoint.attempts) {
580
+ errors.push(`checkpoint ${checkpoint.checkpointId} recovery ${recovery.recoveryId} targets an unknown generation`);
581
+ }
582
+ validateDelegatedText(recovery.diagnosis, DELEGATED_RATIONALE_MAX_CHARS, `checkpoint ${checkpoint.checkpointId} recovery ${recovery.recoveryId} diagnosis`, errors);
583
+ validateDelegatedText(recovery.changedCondition, DELEGATED_RATIONALE_MAX_CHARS, `checkpoint ${checkpoint.checkpointId} recovery ${recovery.recoveryId} changedCondition`, errors);
584
+ validateBoundedEvidence(recovery.verification, `checkpoint ${checkpoint.checkpointId} recovery ${recovery.recoveryId} verification`, errors);
585
+ }
586
+ if (autonomousGrants > 1) {
587
+ errors.push(`checkpoint ${checkpoint.checkpointId} has more than one autonomous recovery grant`);
588
+ }
589
+ if (checkpoint.attempts >
590
+ MAX_CHECKPOINT_REVIEW_ATTEMPTS + checkpointRecoveryGrantCount(recoveryHistory)) {
591
+ errors.push(`checkpoint ${checkpoint.checkpointId} generations exceed the ordinary budget plus recovery grants`);
592
+ }
593
+ }
436
594
  const review = checkpoint.currentReview;
437
595
  if (checkpoint.status === "in_review") {
438
596
  if (!review ||
@@ -458,7 +616,7 @@ function validatePlanRun(run, recordsById, sessionId, errors) {
458
616
  errors.push(`checkpoint ${checkpoint.checkpointId} passed without a passing history entry`);
459
617
  }
460
618
  if (checkpoint.status === "failed" &&
461
- !["failed", "stale"].includes(history[history.length - 1]?.outcome ?? "")) {
619
+ !["failed", "stale", "stopped"].includes(history[history.length - 1]?.outcome ?? "")) {
462
620
  errors.push(`checkpoint ${checkpoint.checkpointId} failed without a failing history entry`);
463
621
  }
464
622
  if (checkpoint.attempts !== history.length + (checkpoint.status === "in_review" ? 1 : 0)) {
@@ -481,6 +639,181 @@ function serializePlanRun(run) {
481
639
  checkpoints: [...run.checkpoints.values()],
482
640
  };
483
641
  }
642
+ // START_BLOCK_EXECUTION_SERIALIZATION
643
+ function serializeWorkflowExecution(execution) {
644
+ return {
645
+ ...execution,
646
+ tasks: [...execution.tasks.entries()],
647
+ checkpoints: [...execution.checkpoints.entries()],
648
+ };
649
+ }
650
+ function isNonEmptyString(value) {
651
+ return typeof value === "string" && value.trim() !== "";
652
+ }
653
+ function validateWorkflowExecution(candidate, sessionId, errors) {
654
+ const before = errors.length;
655
+ if (!isNonEmptyString(candidate.runId))
656
+ errors.push("execution requires a runId");
657
+ if (candidate.sessionId !== sessionId) {
658
+ errors.push(`execution ${candidate.runId} belongs to another session`);
659
+ }
660
+ if (!isNonEmptyString(candidate.workspaceRoot)) {
661
+ errors.push(`execution ${candidate.runId} requires a workspaceRoot`);
662
+ }
663
+ if (!isNonEmptyString(candidate.executionKey)) {
664
+ errors.push(`execution ${candidate.runId} requires an executionKey`);
665
+ }
666
+ if (!isNonEmptyString(candidate.goal)) {
667
+ errors.push(`execution ${candidate.runId} requires a goal`);
668
+ }
669
+ if (candidate.state !== "preparing" &&
670
+ candidate.state !== "active" &&
671
+ candidate.state !== "sealed") {
672
+ errors.push(`execution ${candidate.runId} has invalid state ${String(candidate.state)}`);
673
+ }
674
+ if (!Number.isInteger(candidate.revision) || candidate.revision < 1) {
675
+ errors.push(`execution ${candidate.runId} requires a positive revision`);
676
+ }
677
+ if (!candidate.source || typeof candidate.source !== "object") {
678
+ errors.push(`execution ${candidate.runId} requires a source`);
679
+ }
680
+ else if (candidate.source.kind !== "native-package" &&
681
+ candidate.source.kind !== "provided-plan" &&
682
+ candidate.source.kind !== "conversation-scoped") {
683
+ errors.push(`execution ${candidate.runId} has an invalid source kind`);
684
+ }
685
+ if (!candidate.boundary ||
686
+ !Array.isArray(candidate.boundary.files) ||
687
+ !Array.isArray(candidate.boundary.directories)) {
688
+ errors.push(`execution ${candidate.runId} requires a boundary with files and directories`);
689
+ }
690
+ if (!Array.isArray(candidate.tasks) || candidate.tasks.length === 0) {
691
+ errors.push(`execution ${candidate.runId} requires at least one task binding`);
692
+ }
693
+ else {
694
+ const ids = new Set();
695
+ for (const [taskId, binding] of candidate.tasks) {
696
+ if (ids.has(taskId))
697
+ errors.push(`execution ${candidate.runId} repeats task ${taskId}`);
698
+ ids.add(taskId);
699
+ if (!binding || binding.taskId !== taskId) {
700
+ errors.push(`execution ${candidate.runId} task ${taskId} binding mismatch`);
701
+ }
702
+ }
703
+ }
704
+ if (!Array.isArray(candidate.checkpoints)) {
705
+ errors.push(`execution ${candidate.runId} requires a checkpoints array`);
706
+ }
707
+ if (!Array.isArray(candidate.lineage)) {
708
+ errors.push(`execution ${candidate.runId} requires a lineage array`);
709
+ }
710
+ if (!Array.isArray(candidate.authority) || !Array.isArray(candidate.stageApprovals)) {
711
+ errors.push(`execution ${candidate.runId} requires authority and stageApprovals arrays`);
712
+ }
713
+ if (!Array.isArray(candidate.reserveDebits)) {
714
+ errors.push(`execution ${candidate.runId} requires a reserveDebits array`);
715
+ }
716
+ // Deep authority/debit/approval consistency: cross-record totals, exact
717
+ // bindings, and replay-unique identities within one execution.
718
+ const authorityIds = new Set();
719
+ let grantedUnits = 0;
720
+ for (const authority of candidate.authority) {
721
+ if (!authority || typeof authority !== "object") {
722
+ errors.push(`execution ${candidate.runId} has a malformed authority record`);
723
+ continue;
724
+ }
725
+ const authorityId = authority.authorityId;
726
+ if (!isNonEmptyString(authorityId) || authorityIds.has(authorityId)) {
727
+ errors.push(`execution ${candidate.runId} requires unique authority ids`);
728
+ continue;
729
+ }
730
+ authorityIds.add(authorityId);
731
+ if (authority.runId !== candidate.runId) {
732
+ errors.push(`authority ${authorityId} does not belong to execution ${candidate.runId}`);
733
+ }
734
+ if (!isNonEmptyString(authority.grantedByMessageId)) {
735
+ errors.push(`authority ${authorityId} lacks its granting message identity`);
736
+ }
737
+ if (!Number.isInteger(authority.initialUnits) || authority.initialUnits < 1) {
738
+ errors.push(`authority ${authorityId} requires a positive initial reserve`);
739
+ }
740
+ else {
741
+ grantedUnits += authority.initialUnits;
742
+ }
743
+ if (!Array.isArray(authority.extensions) || !Array.isArray(authority.revocations)) {
744
+ errors.push(`authority ${authorityId} requires extension and revocation arrays`);
745
+ }
746
+ else {
747
+ for (const extension of authority.extensions) {
748
+ if (!Number.isInteger(extension.units) || extension.units < 1) {
749
+ errors.push(`authority ${authorityId} has an invalid extension`);
750
+ }
751
+ else {
752
+ grantedUnits += extension.units;
753
+ }
754
+ }
755
+ }
756
+ }
757
+ const debitIds = new Set();
758
+ let consumedUnits = 0;
759
+ for (const debit of candidate.reserveDebits) {
760
+ if (!debit || typeof debit !== "object") {
761
+ errors.push(`execution ${candidate.runId} has a malformed reserve debit`);
762
+ continue;
763
+ }
764
+ const recoveryId = debit.recoveryId;
765
+ if (!isNonEmptyString(recoveryId) || debitIds.has(recoveryId)) {
766
+ errors.push(`execution ${candidate.runId} requires unique reserve debit ids`);
767
+ continue;
768
+ }
769
+ debitIds.add(recoveryId);
770
+ if (!authorityIds.has(debit.authorityId)) {
771
+ errors.push(`reserve debit ${recoveryId} references an unknown authority`);
772
+ }
773
+ if (!Number.isInteger(debit.units) || debit.units < 1) {
774
+ errors.push(`reserve debit ${recoveryId} requires a positive unit count`);
775
+ }
776
+ else {
777
+ consumedUnits += debit.units;
778
+ }
779
+ }
780
+ if (consumedUnits > grantedUnits) {
781
+ errors.push(`execution ${candidate.runId} consumed more reserve units than were granted`);
782
+ }
783
+ const approvalIds = new Set();
784
+ for (const approval of candidate.stageApprovals) {
785
+ if (!approval || typeof approval !== "object") {
786
+ errors.push(`execution ${candidate.runId} has a malformed stage approval`);
787
+ continue;
788
+ }
789
+ if (!isNonEmptyString(approval.approvalId) || approvalIds.has(approval.approvalId)) {
790
+ errors.push(`execution ${candidate.runId} requires unique stage approval ids`);
791
+ continue;
792
+ }
793
+ approvalIds.add(approval.approvalId);
794
+ if (!authorityIds.has(approval.authorityId)) {
795
+ errors.push(`stage approval ${approval.approvalId} references an unknown authority`);
796
+ }
797
+ }
798
+ if (Array.isArray(candidate.checkpoints)) {
799
+ for (const [checkpointId, binding] of candidate.checkpoints) {
800
+ if (!binding || typeof binding !== "object") {
801
+ errors.push(`execution ${candidate.runId} checkpoint ${checkpointId} is malformed`);
802
+ continue;
803
+ }
804
+ if (binding.status === "passed") {
805
+ if (!Number.isInteger(binding.attempts) || binding.attempts < 1) {
806
+ errors.push(`passed checkpoint ${checkpointId} requires a completed generation`);
807
+ }
808
+ if (!Number.isInteger(binding.passedRevision) || binding.passedRevision < 1) {
809
+ errors.push(`passed checkpoint ${checkpointId} requires a passing revision`);
810
+ }
811
+ }
812
+ }
813
+ }
814
+ return errors.length === before;
815
+ }
816
+ // END_BLOCK_EXECUTION_SERIALIZATION
484
817
  /**
485
818
  * Resolve the per-session workflow data directory.
486
819
  * Path: $XDG_DATA_HOME/vvoc/workflow/<sessionId>/
@@ -526,7 +859,10 @@ export function hydrateWorkflowStateChecked(sessionId) {
526
859
  errors: [`workflow state is not valid JSON: ${error.message}`],
527
860
  };
528
861
  }
529
- if (parsed.version !== 1 && parsed.version !== PERSISTED_WORKFLOW_STATE_VERSION) {
862
+ if (parsed.version !== 1 &&
863
+ parsed.version !== 2 &&
864
+ parsed.version !== RECOVERY_STATE_VERSION &&
865
+ parsed.version !== PERSISTED_WORKFLOW_STATE_VERSION) {
530
866
  return {
531
867
  status: "invalid",
532
868
  errors: [`unsupported persisted version ${String(parsed.version)}`],
@@ -535,11 +871,12 @@ export function hydrateWorkflowStateChecked(sessionId) {
535
871
  if (!Array.isArray(parsed.records)) {
536
872
  return { status: "invalid", errors: ["persisted records must be an array"] };
537
873
  }
874
+ const version = parsed.version;
538
875
  const errors = [];
539
876
  const records = new Map();
540
877
  for (const record of parsed.records) {
541
878
  const recordErrors = [];
542
- if (!isWorkItemRecord(record, sessionId, recordErrors)) {
879
+ if (!isWorkItemRecord(record, sessionId, version, recordErrors)) {
543
880
  errors.push(...(recordErrors.length > 0
544
881
  ? recordErrors
545
882
  : [
@@ -547,6 +884,12 @@ export function hydrateWorkflowStateChecked(sessionId) {
547
884
  ]));
548
885
  continue;
549
886
  }
887
+ // Versions 1 and 2 never carried recovery histories: hydrate them
888
+ // conservatively with empty histories rather than inferring grants, so
889
+ // their original budgets stay unchanged.
890
+ if (record.mode === "delegated" && record.delegated && version < RECOVERY_STATE_VERSION) {
891
+ record.delegated = { ...record.delegated, recoveryHistory: [] };
892
+ }
550
893
  records.set(`${sessionId}::${record.workItemId}`, record);
551
894
  }
552
895
  const keyIndex = new Map();
@@ -556,37 +899,76 @@ export function hydrateWorkflowStateChecked(sessionId) {
556
899
  const keyIndexBySession = new Map();
557
900
  keyIndexBySession.set(sessionId, keyIndex);
558
901
  const planRuns = new Map();
559
- if (parsed.version === PERSISTED_WORKFLOW_STATE_VERSION) {
902
+ if (version >= 2) {
560
903
  if (!Array.isArray(parsed.planRuns)) {
561
904
  return { status: "invalid", errors: ["version 2 state requires a planRuns array"] };
562
905
  }
563
906
  const recordsByBareId = new Map([...records.values()].map((record) => [record.workItemId, record]));
564
907
  for (const serialized of parsed.planRuns) {
565
908
  const runErrors = [];
566
- if (!validatePlanRun(serialized, recordsByBareId, sessionId, runErrors)) {
909
+ if (!validatePlanRun(serialized, recordsByBareId, sessionId, version, runErrors)) {
567
910
  errors.push(...runErrors);
568
911
  continue;
569
912
  }
570
913
  const run = {
571
914
  ...serialized,
572
915
  tasks: new Map(serialized.tasks.map((task) => [task.taskId, task])),
573
- checkpoints: new Map(serialized.checkpoints.map((checkpoint) => [checkpoint.checkpointId, checkpoint])),
916
+ checkpoints: new Map(serialized.checkpoints.map((checkpoint) => {
917
+ const hydrated = version < RECOVERY_STATE_VERSION
918
+ ? { ...checkpoint, recoveryHistory: checkpoint.recoveryHistory ?? [] }
919
+ : checkpoint;
920
+ return [hydrated.checkpointId, hydrated];
921
+ })),
574
922
  };
575
923
  planRuns.set(run.runId, run);
576
924
  }
577
925
  }
926
+ const executions = new Map();
927
+ const messageClaims = new Map();
928
+ if (version >= PERSISTED_WORKFLOW_STATE_VERSION) {
929
+ if (!Array.isArray(parsed.executions)) {
930
+ return { status: "invalid", errors: ["version 4 state requires an executions array"] };
931
+ }
932
+ for (const serialized of parsed.executions) {
933
+ const executionErrors = [];
934
+ if (!validateWorkflowExecution(serialized, sessionId, executionErrors)) {
935
+ errors.push(...executionErrors);
936
+ continue;
937
+ }
938
+ executions.set(serialized.runId, {
939
+ ...serialized,
940
+ tasks: new Map(serialized.tasks),
941
+ checkpoints: new Map(serialized.checkpoints),
942
+ });
943
+ }
944
+ for (const claim of parsed.messageClaims ?? []) {
945
+ if (!isNonEmptyString(claim?.messageId) || !isNonEmptyString(claim?.runId)) {
946
+ errors.push("message claims require messageId and runId");
947
+ continue;
948
+ }
949
+ if (messageClaims.has(claim.messageId)) {
950
+ errors.push(`message ${claim.messageId} is claimed more than once`);
951
+ continue;
952
+ }
953
+ messageClaims.set(claim.messageId, claim);
954
+ }
955
+ }
578
956
  if (errors.length > 0) {
579
957
  return { status: "invalid", errors };
580
958
  }
581
- return {
582
- status: "valid",
583
- data: {
584
- nextId: typeof parsed.nextId === "number" ? parsed.nextId : records.size + 1,
585
- records,
586
- keyIndexBySession,
587
- planRuns,
588
- },
959
+ const data = {
960
+ nextId: typeof parsed.nextId === "number" ? parsed.nextId : records.size + 1,
961
+ records,
962
+ keyIndexBySession,
963
+ planRuns,
964
+ executions,
965
+ messageClaims,
589
966
  };
967
+ // Materialize compatibility registry entries for existing native plan runs
968
+ // that predate the common registry; this never invents authority or alters
969
+ // native counters.
970
+ ensureNativeExecutions(data);
971
+ return { status: "valid", data };
590
972
  }
591
973
  // START_CONTRACT: hydrateWorkflowState
592
974
  // PURPOSE: Legacy nullable hydrate kept for compatibility with existing callers.
@@ -631,16 +1013,31 @@ export function snapshotWorkflowStateChecked(sessionId, data) {
631
1013
  planRuns.push(serializePlanRun(run));
632
1014
  }
633
1015
  }
1016
+ const executions = [];
1017
+ const sessionRunIds = new Set();
1018
+ for (const execution of data.executions.values()) {
1019
+ if (execution.sessionId === sessionId) {
1020
+ executions.push(serializeWorkflowExecution(execution));
1021
+ sessionRunIds.add(execution.runId);
1022
+ }
1023
+ }
1024
+ const messageClaims = [];
1025
+ for (const claim of data.messageClaims.values()) {
1026
+ if (sessionRunIds.has(claim.runId))
1027
+ messageClaims.push(claim);
1028
+ }
634
1029
  const persisted = {
635
- // Version 2 carries delegated attempts, decisions, and plan runs;
636
- // PERSISTED_WORKFLOW_STATE_VERSION mirrors this literal for hydration.
637
- version: 2,
1030
+ // Version 4 adds the common execution registry and session-wide message
1031
+ // claims on top of version 3's recovery histories and report rejections.
1032
+ version: PERSISTED_WORKFLOW_STATE_VERSION,
638
1033
  updatedAt: new Date().toISOString(),
639
1034
  sessionId,
640
1035
  nextId: data.nextId,
641
1036
  records,
642
1037
  keyIndex,
643
1038
  planRuns,
1039
+ executions,
1040
+ messageClaims,
644
1041
  };
645
1042
  const targetPath = getWorkflowStatePath(sessionId);
646
1043
  const temporaryPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;