@zq-silk/yui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -1,15 +1,18 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { validateConfiguredAgent } from "../agent/agent.js";
6
- import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
6
+ import { validateCapabilityGrant } from "../grant/capabilityGrant.js";
7
+ import { validateReleaseWorkflow } from "../release/releaseWorkflow.js";
8
+ import { reconciliationIntervalMilliseconds, resolveLeaderNextActionMode, resolveResourcesGcAutoQuarantine, resolveResourcesGcMode } from "../config/yuiConfig.js";
7
9
  import { resolveTimeZone } from "../output/timePresentation.js";
8
10
  import { mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
9
11
  import { validateInputRequest } from "../input/inputRequest.js";
10
12
  import { validateRoleSessionSet } from "../executor/agentExecutor.js";
11
13
  import { validateTaskMessage } from "../message/message.js";
12
14
  import { validateAgentRun } from "../run/agentRun.js";
15
+ import { FileSessionOwnerRegistry } from "../runtime/sessionOwnerRegistry.js";
13
16
  import { validateReviewConfig } from "../review/reviewConfig.js";
14
17
  import { validateReviewRound } from "../review/reviewRound.js";
15
18
  import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
@@ -18,6 +21,8 @@ import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeId
18
21
  import { validateAgentProfile } from "../profile/agentProfile.js";
19
22
  import { validateChangeSet } from "../integration/changeSet.js";
20
23
  import { validateIntegrationAttempt } from "../integration/integrationAttempt.js";
24
+ import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
25
+ import { CURRENT_DURABLE_JOB_SCHEMA_VERSION, validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
21
26
  import { validateGlobalRole, validateTaskRole } from "../role/role.js";
22
27
  import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../scheduler/leaderFailure.js";
23
28
  import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../scheduler/operatorNotification.js";
@@ -26,6 +31,7 @@ import { formatAgentRunReceiptId, TASK_RECORD_ID_PREFIXES, validateTaskRecordRef
26
31
  import { workItemExecutionGroupById, validateWorkItem } from "../workItem/workItem.js";
27
32
  import { isExecutionGroupTransition, validateExecutionGroup } from "../execution/executionGroup.js";
28
33
  import { managedWorkspaceKey, validateManagedWorkspace } from "../worktree/managedWorkspace.js";
34
+ import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
29
35
  import { writeTextFileAtomically } from "./durableFile.js";
30
36
  import { assertHomeWritable } from "./upgradeFence.js";
31
37
  import { CURRENT_AGGREGATE_SCHEMA_VERSION, requireCompatibleStorageSchema, requireStorageSchema, writeCurrentStorageManifest } from "./storageSchema.js";
@@ -54,13 +60,15 @@ export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
54
60
  export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
55
61
  export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 9;
56
62
  export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 4;
57
- export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 2;
58
- export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 2;
59
- export const CURRENT_MESSAGE_SCHEMA_VERSION = 2;
63
+ export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 3;
64
+ export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 4;
65
+ export const CURRENT_MESSAGE_SCHEMA_VERSION = 3;
60
66
  export const CURRENT_INPUT_REQUEST_SCHEMA_VERSION = 2;
61
67
  export const CURRENT_DECISION_SCHEMA_VERSION = 1;
62
68
  export const CURRENT_MILESTONE_SCHEMA_VERSION = 1;
63
69
  export const CURRENT_EVENT_SCHEMA_VERSION = 2;
70
+ export const CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION = 1;
71
+ export const CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION = 1;
64
72
  export const CURRENT_WORK_MAILBOX_SCHEMA_VERSION = 1;
65
73
  export const CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION = 3;
66
74
  export const CURRENT_PENDING_WAKEUP_SCHEMA_VERSION = 1;
@@ -80,7 +88,7 @@ export function executionLaneActiveRunKey(executionGroupId, executionLaneId) {
80
88
  function encodeLaneKeyPart(value) {
81
89
  return encodeURIComponent(value).replace(/:/gu, "%3A");
82
90
  }
83
- function executionLaneActiveRunKeyParts(key) {
91
+ export function executionLaneActiveRunKeyParts(key) {
84
92
  const match = /^\/execution-lane\/([^:]+):([^:]+)$/u.exec(key);
85
93
  if (match === null)
86
94
  return null;
@@ -95,23 +103,40 @@ function executionLaneActiveRunKeyParts(key) {
95
103
  }
96
104
  }
97
105
  /** The schema version of each persisted `state.json#/tasks/*` aggregate. */
98
- export const CURRENT_STORED_TASK_SCHEMA_VERSION = 14;
106
+ export const CURRENT_STORED_TASK_SCHEMA_VERSION = 16;
99
107
  /**
100
108
  * Persisted nested-record versions consumed by this store's strict parser.
101
109
  * Keep these named at the storage boundary so the upgrade record-axis map can
102
110
  * assert it is classifying the same bytes the store reads and writes.
103
111
  */
104
112
  export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 4;
105
- export const CURRENT_AGENT_RUN_SCHEMA_VERSION = 6;
113
+ /**
114
+ * v7 combines optional Issue 04 retry/receipt fields and Issue 05 Leader
115
+ * actionability fields. All are optional, so the v6→v7 migration is a
116
+ * version-only rewrite.
117
+ */
118
+ export const CURRENT_AGENT_RUN_SCHEMA_VERSION = 7;
119
+ export const CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION = 1;
106
120
  export class FileTaskStore {
107
121
  rootDir;
108
122
  #transaction = null;
109
123
  #readCache = null;
110
124
  #normalizeState;
125
+ #sessionOwnerRegistry;
111
126
  constructor(rootDir, options = {}) {
112
127
  this.rootDir = rootDir;
113
128
  this.#normalizeState = options.normalizeState;
114
129
  this.#requireReadableSchema();
130
+ const snapshot = options.initialStateSnapshot;
131
+ if (snapshot !== undefined) {
132
+ // The one-snapshot open already fenced these bytes; run the same strict
133
+ // parser the lazy path would, then warm the cache under the snapshot's
134
+ // fingerprint. A later external writer still invalidates it on next read.
135
+ this.#readCache = {
136
+ fingerprint: snapshot.fingerprint,
137
+ state: this.#parseState(snapshot.raw)
138
+ };
139
+ }
115
140
  }
116
141
  rootDirectory() { return this.rootDir; }
117
142
  transaction(execute) {
@@ -135,6 +160,34 @@ export class FileTaskStore {
135
160
  }
136
161
  });
137
162
  }
163
+ withRuntimeEventTransaction(execute) {
164
+ return this.transaction(() => execute());
165
+ }
166
+ async transactionAsync(execute) {
167
+ if (this.#transaction !== null)
168
+ return execute(this);
169
+ const release = acquireStorageLock(this.rootDir);
170
+ try {
171
+ const state = this.#readCachedState();
172
+ this.#transaction = { state, baseRevision: state.revision, dirty: false };
173
+ try {
174
+ const result = await execute(this);
175
+ if (this.#transaction.dirty)
176
+ this.#commit(state, this.#transaction.baseRevision);
177
+ return result;
178
+ }
179
+ catch (error) {
180
+ this.#readCache = null;
181
+ throw error;
182
+ }
183
+ finally {
184
+ this.#transaction = null;
185
+ }
186
+ }
187
+ finally {
188
+ release();
189
+ }
190
+ }
138
191
  getConfig() { return clone(this.#state().config); }
139
192
  getHomeIdentity() { return clone(this.#state().homeIdentity); }
140
193
  saveConfig(config) {
@@ -325,8 +378,39 @@ export class FileTaskStore {
325
378
  state.tasks[stored.id] = aggregate;
326
379
  });
327
380
  }
328
- listTasks() { return values(this.#state().tasks, (aggregate) => aggregate.task.id).map((entry) => clone(entry.task)); }
381
+ listTasks() {
382
+ // Clone only the Task headers, not the whole stored aggregate (events,
383
+ // runs, messages): a scheduler pass lists Tasks per phase, and cloning
384
+ // each aggregate's full event history turned every phase into a 32 MiB
385
+ // projection. Callers that need a Task's events read them explicitly.
386
+ return Object.values(this.#state().tasks)
387
+ .map((aggregate) => clone(aggregate.task))
388
+ .sort((left, right) => numericCompare(left.id, right.id));
389
+ }
390
+ getStateRevision() { return this.#state().revision; }
329
391
  getTask(id) { return optional(this.#state().tasks[id]?.task); }
392
+ readNextActionFacts(taskId) {
393
+ const aggregate = this.#state().tasks[taskId];
394
+ if (aggregate === undefined)
395
+ return null;
396
+ const agentRuns = values(aggregate.agentRuns, "id");
397
+ return {
398
+ task: {
399
+ id: aggregate.task.id,
400
+ status: aggregate.task.status,
401
+ projectBindings: aggregate.task.projectBindings
402
+ },
403
+ workItems: values(aggregate.workItems, "id"),
404
+ changeSets: values(aggregate.changeSets, "id"),
405
+ integrations: values(aggregate.integrationAttempts, "id"),
406
+ reviewRounds: values(aggregate.reviewRounds, "id"),
407
+ reviewConfig: this.getReviewConfig(),
408
+ openInputRequests: values(aggregate.inputRequests, "id")
409
+ .filter((request) => request.status === "open"),
410
+ activeRuns: agentRuns.filter((run) => run.status === "active"),
411
+ leaderRuns: agentRuns.filter((run) => run.roleName === "leader")
412
+ };
413
+ }
330
414
  getReviewConfig() {
331
415
  return optional(this.#state().config.review);
332
416
  }
@@ -352,7 +436,7 @@ export class FileTaskStore {
352
436
  throw new StorageRecordError(`ChangeSet belongs to another Task: ${stored.taskId}.`);
353
437
  }
354
438
  const aggregate = this.#requireTaskForWrite(taskId);
355
- const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === stored.headCommit);
439
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === stored.headCommit);
356
440
  if (evidenceRound !== undefined) {
357
441
  throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${stored.headCommit} cannot become a ChangeSet.`);
358
442
  }
@@ -399,7 +483,7 @@ export class FileTaskStore {
399
483
  if (changeSet.projectId !== stored.projectId) {
400
484
  throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${changeSetId}.`);
401
485
  }
402
- const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
486
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
403
487
  if (evidenceRound !== undefined) {
404
488
  throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
405
489
  }
@@ -425,6 +509,105 @@ export class FileTaskStore {
425
509
  getIntegrationAttempt(taskId, integrationId) {
426
510
  return optional(this.#state().tasks[taskId]?.integrationAttempts[integrationId]);
427
511
  }
512
+ nextIntegrationQueueEntryId(taskId) {
513
+ return this.#nextTaskRecordId(taskId, "integrationQueue");
514
+ }
515
+ saveIntegrationQueueEntry(taskId, entry) {
516
+ const stored = identified(entry, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, "id", entry.id, "Integration queue entry");
517
+ validateIntegrationQueueEntry(stored);
518
+ if (stored.taskId !== taskId) {
519
+ throw new StorageRecordError(`Integration queue entry belongs to another Task: ${stored.taskId}.`);
520
+ }
521
+ const aggregate = this.#requireTaskForWrite(taskId);
522
+ if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
523
+ throw new StorageRecordError(`Integration queue Project does not match Task: ${stored.id}.`);
524
+ }
525
+ const changeSet = aggregate.changeSets[stored.changeSetId];
526
+ if (changeSet === undefined) {
527
+ throw new StorageRecordError(`Integration queue ChangeSet not found: ${stored.changeSetId}.`);
528
+ }
529
+ if (changeSet.projectId !== stored.projectId) {
530
+ throw new StorageRecordError(`Integration queue ChangeSet belongs to another Project: ${stored.changeSetId}.`);
531
+ }
532
+ const existing = aggregate.integrationQueue[stored.id];
533
+ if (existing !== undefined) {
534
+ if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
535
+ throw new StorageRecordError(`Integration queue entry updatedAt cannot move backwards: ${stored.id}.`);
536
+ }
537
+ if (!validIntegrationQueueTransition(existing, stored)) {
538
+ throw new StorageRecordError(`Integration queue entry transition is invalid: ${stored.id}.`);
539
+ }
540
+ }
541
+ this.#mutate((state) => {
542
+ const task = state.tasks[taskId];
543
+ observeTaskRecordId(task, "integrationQueue", stored.id);
544
+ task.integrationQueue[stored.id] = stored;
545
+ });
546
+ }
547
+ listIntegrationQueueEntries(taskId) {
548
+ return values(this.#requireTask(taskId).integrationQueue, "id");
549
+ }
550
+ getIntegrationQueueEntry(taskId, entryId) {
551
+ return optional(this.#state().tasks[taskId]?.integrationQueue[entryId]);
552
+ }
553
+ nextDurableJobId(taskId) {
554
+ return this.#nextTaskRecordId(taskId, "durableJob");
555
+ }
556
+ saveDurableJob(taskId, job) {
557
+ const stored = identified(job, CURRENT_DURABLE_JOB_SCHEMA_VERSION, "id", job.id, "DurableJob");
558
+ validateDurableJob(stored);
559
+ if (stored.taskId !== taskId) {
560
+ throw new StorageRecordError(`DurableJob belongs to another Task: ${stored.taskId}.`);
561
+ }
562
+ const aggregate = this.#requireTaskForWrite(taskId);
563
+ if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
564
+ throw new StorageRecordError(`DurableJob Project does not match Task: ${stored.id}.`);
565
+ }
566
+ const existing = aggregate.durableJobs[stored.id];
567
+ if (existing !== undefined) {
568
+ if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
569
+ throw new StorageRecordError(`DurableJob updatedAt cannot move backwards: ${stored.id}.`);
570
+ }
571
+ if (!validDurableJobTransition(existing, stored)) {
572
+ throw new StorageRecordError(`DurableJob transition is invalid: ${stored.id}.`);
573
+ }
574
+ }
575
+ this.#mutate((state) => {
576
+ const task = state.tasks[taskId];
577
+ observeTaskRecordId(task, "durableJob", stored.id);
578
+ task.durableJobs[stored.id] = stored;
579
+ });
580
+ }
581
+ listDurableJobs(taskId) {
582
+ return values(this.#requireTask(taskId).durableJobs, "id");
583
+ }
584
+ getDurableJob(taskId, jobId) {
585
+ return optional(this.#state().tasks[taskId]?.durableJobs[jobId]);
586
+ }
587
+ findDurableJobByIdempotencyKey(taskId, key) {
588
+ const aggregate = this.#state().tasks[taskId];
589
+ if (aggregate === undefined)
590
+ return null;
591
+ const found = Object.values(aggregate.durableJobs)
592
+ .find((job) => job.idempotencyKey === key);
593
+ return optional(found);
594
+ }
595
+ listAllDurableJobs() {
596
+ const all = [];
597
+ for (const aggregate of Object.values(this.#state().tasks)) {
598
+ all.push(...Object.values(aggregate.durableJobs));
599
+ }
600
+ return all.map((job) => clone(job));
601
+ }
602
+ hasActiveDurableJobs() {
603
+ for (const aggregate of Object.values(this.#state().tasks)) {
604
+ for (const job of Object.values(aggregate.durableJobs)) {
605
+ if (job.status === "queued" || job.status === "running")
606
+ return true;
607
+ }
608
+ }
609
+ return false;
610
+ }
428
611
  saveRole(taskId, role) {
429
612
  const aggregate = this.#requireTaskForWrite(taskId);
430
613
  const stored = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
@@ -564,6 +747,42 @@ export class FileTaskStore {
564
747
  const set = this.getRoleSessionSet(taskId, roleName);
565
748
  return set === null ? null : optional(set.sessions[set.activeAgentId]);
566
749
  }
750
+ getJobCallerKeyHash(taskId, roleName, agentId) {
751
+ const hashes = this.#state().tasks[taskId]?.jobCallerKeyHashes;
752
+ if (hashes === undefined)
753
+ return null;
754
+ return optional(hashes[jobCallerKeyHashKey(roleName, agentId)]);
755
+ }
756
+ setJobCallerKeyHash(taskId, roleName, agentId, hash) {
757
+ this.#requireTaskForWrite(taskId);
758
+ if (!/^[a-f0-9]{64}$/u.test(hash)) {
759
+ throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${roleName}.`);
760
+ }
761
+ this.#mutate((state) => {
762
+ state.tasks[taskId].jobCallerKeyHashes[jobCallerKeyHashKey(roleName, agentId)] = hash;
763
+ });
764
+ }
765
+ saveSessionOwner(identity) {
766
+ this.#sessionOwners().record(identity);
767
+ }
768
+ getSessionOwner(launchId) {
769
+ return this.#sessionOwners().get(launchId);
770
+ }
771
+ listSessionOwners() {
772
+ return this.#sessionOwners().list();
773
+ }
774
+ listSessionOwnersForOwner(owner) {
775
+ return this.#sessionOwners().listForOwner(owner);
776
+ }
777
+ removeSessionOwner(launchId) {
778
+ this.#sessionOwners().remove(launchId);
779
+ }
780
+ #sessionOwners() {
781
+ if (this.#sessionOwnerRegistry === undefined) {
782
+ this.#sessionOwnerRegistry = new FileSessionOwnerRegistry(this.rootDir);
783
+ }
784
+ return this.#sessionOwnerRegistry;
785
+ }
567
786
  nextWorkItemId(taskId) {
568
787
  return this.#nextTaskRecordId(taskId, "workItem");
569
788
  }
@@ -617,6 +836,21 @@ export class FileTaskStore {
617
836
  }
618
837
  getAgentRun(taskId, id) { return optional(this.#state().tasks[taskId]?.agentRuns[id]); }
619
838
  listAgentRuns(taskId) { return values(this.#requireTask(taskId).agentRuns, "id"); }
839
+ listPendingProviderRetries() {
840
+ // The legacy File store can answer the empty case without a scan fallback.
841
+ // If durable retry state exists, the db-only capability must fail closed
842
+ // instead of silently losing the Controller's wake deadline.
843
+ for (const task of this.listTasks()) {
844
+ if (task.status !== "active")
845
+ continue;
846
+ for (const run of this.listAgentRuns(task.id)) {
847
+ if (run.status === "active" && run.providerRetry?.nextAttemptAt !== undefined) {
848
+ throw new StorageRecordError("Provider retry in place requires the SQLite backend; run `yui update` to migrate this Home.");
849
+ }
850
+ }
851
+ }
852
+ return [];
853
+ }
620
854
  saveAgentRun(run) {
621
855
  const stored = identified(run, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", run.id, "Agent run");
622
856
  validateAgentRun(stored);
@@ -690,6 +924,18 @@ export class FileTaskStore {
690
924
  task.reviewRounds[stored.id] = stored;
691
925
  });
692
926
  }
927
+ nextReviewFindingId(taskId) {
928
+ throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
929
+ }
930
+ getReviewFinding(taskId, reviewFindingId) {
931
+ throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
932
+ }
933
+ listReviewFindings(taskId) {
934
+ throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
935
+ }
936
+ saveReviewFinding(taskId, finding) {
937
+ throw new StorageRecordError(`Review findings require the SQLite backend (yui.db); migrate this Home with \`yui update\` before using the finding ledger on Task ${taskId}.`);
938
+ }
693
939
  getActiveAgentRun(taskId, roleName) {
694
940
  const aggregate = this.#state().tasks[taskId];
695
941
  const pointer = aggregate?.activeRuns[roleName];
@@ -933,6 +1179,209 @@ export class FileTaskStore {
933
1179
  });
934
1180
  }
935
1181
  listEvents(taskId) { return values(this.#requireTask(taskId).events, "id"); }
1182
+ removeEvents(taskId, eventIds) {
1183
+ if (eventIds.length === 0)
1184
+ return 0;
1185
+ let removed = 0;
1186
+ this.#mutate((state) => {
1187
+ const aggregate = requireTaskFromState(state, taskId);
1188
+ for (const id of eventIds) {
1189
+ if (aggregate.events[id] !== undefined) {
1190
+ delete aggregate.events[id];
1191
+ removed++;
1192
+ }
1193
+ }
1194
+ });
1195
+ return removed;
1196
+ }
1197
+ nextCapabilityGrantId(taskId) {
1198
+ return this.#nextTaskRecordId(taskId, "capabilityGrant");
1199
+ }
1200
+ saveCapabilityGrant(taskId, grant) {
1201
+ const stored = storedCapabilityGrant(grant);
1202
+ if (stored.taskId !== taskId) {
1203
+ throw new StorageRecordError(`Capability grant belongs to another Task: ${stored.taskId}`);
1204
+ }
1205
+ this.#mutate((state) => {
1206
+ const aggregate = requireTaskFromState(state, taskId);
1207
+ const existing = aggregate.capabilityGrants[stored.id];
1208
+ if (existing === undefined) {
1209
+ if (stored.revokedAt !== undefined) {
1210
+ throw new StorageRecordError(`Capability grant must start unrevoked: ${stored.id}`);
1211
+ }
1212
+ if (stored.usesUsed !== 0) {
1213
+ throw new StorageRecordError(`Capability grant must start unused: ${stored.id}`);
1214
+ }
1215
+ }
1216
+ else if (!isValidCapabilityGrantTransition(existing, stored)) {
1217
+ throw new StorageRecordError(`Capability grant cannot be overwritten: ${taskId}/${stored.id}`);
1218
+ }
1219
+ observeTaskRecordId(aggregate, "capabilityGrant", stored.id);
1220
+ aggregate.capabilityGrants[stored.id] = stored;
1221
+ });
1222
+ }
1223
+ listCapabilityGrants(taskId) {
1224
+ return values(this.#requireTask(taskId).capabilityGrants, "id");
1225
+ }
1226
+ getCapabilityGrant(taskId, grantId) {
1227
+ return this.#requireTask(taskId).capabilityGrants[grantId] ?? null;
1228
+ }
1229
+ nextReleaseWorkflowId(taskId) {
1230
+ return this.#nextTaskRecordId(taskId, "releaseWorkflow");
1231
+ }
1232
+ saveReleaseWorkflow(taskId, workflow) {
1233
+ const stored = storedReleaseWorkflow(workflow);
1234
+ if (stored.taskId !== taskId) {
1235
+ throw new StorageRecordError(`Release workflow belongs to another Task: ${stored.taskId}`);
1236
+ }
1237
+ this.#mutate((state) => {
1238
+ const aggregate = requireTaskFromState(state, taskId);
1239
+ const existing = aggregate.releaseWorkflows[stored.id];
1240
+ if (existing !== undefined && !isValidReleaseWorkflowTransition(existing, stored)) {
1241
+ throw new StorageRecordError(`Release workflow cannot be overwritten: ${taskId}/${stored.id}`);
1242
+ }
1243
+ observeTaskRecordId(aggregate, "releaseWorkflow", stored.id);
1244
+ aggregate.releaseWorkflows[stored.id] = stored;
1245
+ });
1246
+ }
1247
+ listReleaseWorkflows(taskId) {
1248
+ return values(this.#requireTask(taskId).releaseWorkflows, "id");
1249
+ }
1250
+ getReleaseWorkflow(taskId, workflowId) {
1251
+ return this.#requireTask(taskId).releaseWorkflows[workflowId] ?? null;
1252
+ }
1253
+ // -- Gate artifacts (Issue 08) ---------------------------------------------
1254
+ // FileTaskStore delegates to the file-backed artifact namespace under
1255
+ // `<home>/artifacts/gates/`. This is the transitional path for Homes that
1256
+ // have not yet migrated to SQLite; the SqliteTaskStore stores the same
1257
+ // records and logs in `gate_artifacts` / `gate_artifact_logs`.
1258
+ #gateArtifactRecordPath(projectId, key) {
1259
+ return join(this.rootDir, "artifacts", "gates", projectId, `${key}.json`);
1260
+ }
1261
+ #gateArtifactLogsRoot(projectId, key) {
1262
+ return join(this.rootDir, "artifacts", "gates", projectId, key);
1263
+ }
1264
+ saveGateArtifact(artifact, logs) {
1265
+ validateGateArtifact(artifact);
1266
+ const recordPath = this.#gateArtifactRecordPath(artifact.projectId, artifact.key);
1267
+ const logsRoot = this.#gateArtifactLogsRoot(artifact.projectId, artifact.key);
1268
+ this.#mutate(() => {
1269
+ writeTextFileAtomically(recordPath, `${JSON.stringify(artifact, null, 2)}\n`);
1270
+ mkdirSync(logsRoot, { recursive: true, mode: 0o700 });
1271
+ for (const [stepName, content] of logs) {
1272
+ const logPath = join(logsRoot, stepName);
1273
+ const tmpPath = `${logPath}.tmp-${process.pid}`;
1274
+ writeFileSync(tmpPath, content, { mode: 0o600 });
1275
+ renameSync(tmpPath, logPath);
1276
+ }
1277
+ });
1278
+ }
1279
+ touchGateArtifact(artifact) {
1280
+ validateGateArtifact(artifact);
1281
+ const recordPath = this.#gateArtifactRecordPath(artifact.projectId, artifact.key);
1282
+ if (!existsSync(recordPath)) {
1283
+ throw new StorageRecordError(`Gate artifact not found for touch: ${artifact.key}`);
1284
+ }
1285
+ writeTextFileAtomically(recordPath, `${JSON.stringify(artifact, null, 2)}\n`);
1286
+ }
1287
+ getGateArtifact(projectId, key) {
1288
+ const path = this.#gateArtifactRecordPath(projectId, key);
1289
+ if (!existsSync(path))
1290
+ return null;
1291
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1292
+ return validateGateArtifact(parsed);
1293
+ }
1294
+ findGateArtifactByIdentity(identity) {
1295
+ return this.getGateArtifact(identity.projectId, gateArtifactKey(identity));
1296
+ }
1297
+ findL2GateArtifactsForCommit(query) {
1298
+ const root = join(this.rootDir, "artifacts", "gates", query.projectId);
1299
+ if (!existsSync(root))
1300
+ return [];
1301
+ const results = [];
1302
+ for (const entry of readdirSync(root)) {
1303
+ if (!entry.endsWith(".json"))
1304
+ continue;
1305
+ let artifact;
1306
+ try {
1307
+ artifact = this.getGateArtifact(query.projectId, entry.slice(0, -".json".length));
1308
+ }
1309
+ catch {
1310
+ continue;
1311
+ }
1312
+ if (artifact === null
1313
+ || artifact.level !== "L2"
1314
+ || artifact.status !== "complete"
1315
+ || artifact.outcome !== "succeeded"
1316
+ || artifact.commit !== query.commit
1317
+ || artifact.planDigest !== query.planDigest
1318
+ || artifact.toolchainDigest !== query.toolchainDigest
1319
+ || artifact.boundary?.targetRef !== query.targetRef) {
1320
+ continue;
1321
+ }
1322
+ results.push(artifact);
1323
+ }
1324
+ return results;
1325
+ }
1326
+ getGateArtifactLogs(artifactKey) {
1327
+ // The artifact key is content-addressed; we need the projectId to build
1328
+ // the path. Callers that have the artifact should use
1329
+ // getGateArtifactLogsForArtifact; this fallback scans all projects.
1330
+ const gatesRoot = join(this.rootDir, "artifacts", "gates");
1331
+ if (!existsSync(gatesRoot))
1332
+ return new Map();
1333
+ for (const projectId of readdirSync(gatesRoot)) {
1334
+ const logsRoot = join(gatesRoot, projectId, artifactKey);
1335
+ if (existsSync(logsRoot) && statSync(logsRoot).isDirectory()) {
1336
+ return this.#readGateArtifactLogs(logsRoot);
1337
+ }
1338
+ }
1339
+ return new Map();
1340
+ }
1341
+ #readGateArtifactLogs(logsRoot) {
1342
+ const logs = new Map();
1343
+ if (!existsSync(logsRoot))
1344
+ return logs;
1345
+ for (const entry of readdirSync(logsRoot)) {
1346
+ const fullPath = join(logsRoot, entry);
1347
+ if (statSync(fullPath).isFile()) {
1348
+ logs.set(entry, readFileSync(fullPath));
1349
+ }
1350
+ }
1351
+ return logs;
1352
+ }
1353
+ pruneGateArtifacts(projectId, options) {
1354
+ const root = join(this.rootDir, "artifacts", "gates", projectId);
1355
+ if (!existsSync(root))
1356
+ return Object.freeze({ retained: 0, deleted: 0 });
1357
+ let retained = 0;
1358
+ let deleted = 0;
1359
+ for (const entry of readdirSync(root)) {
1360
+ if (!entry.endsWith(".json"))
1361
+ continue;
1362
+ const key = entry.slice(0, -".json".length);
1363
+ let artifact;
1364
+ try {
1365
+ const loaded = this.getGateArtifact(projectId, key);
1366
+ if (loaded === null)
1367
+ continue;
1368
+ artifact = loaded;
1369
+ }
1370
+ catch {
1371
+ retained += 1;
1372
+ continue;
1373
+ }
1374
+ const age = options.now.getTime() - Date.parse(artifact.lastUsedAt);
1375
+ if (options.isReferenced(key) || age < options.ttlMs) {
1376
+ retained += 1;
1377
+ continue;
1378
+ }
1379
+ rmSync(this.#gateArtifactRecordPath(projectId, key), { force: true });
1380
+ rmSync(this.#gateArtifactLogsRoot(projectId, key), { recursive: true, force: true });
1381
+ deleted += 1;
1382
+ }
1383
+ return Object.freeze({ retained, deleted });
1384
+ }
936
1385
  getWorkMailbox(target) {
937
1386
  return optional(this.#state().mailboxes[mailboxTargetKey(target)]);
938
1387
  }
@@ -1134,7 +1583,14 @@ export class FileTaskStore {
1134
1583
  writeCurrentStorageManifest(this.rootDir);
1135
1584
  this.#normalizeState = undefined;
1136
1585
  }
1137
- this.#readCache = null;
1586
+ // Keep the state we just wrote as the warm read cache. The atomic write
1587
+ // produced a new fingerprint, so a concurrent external writer is still
1588
+ // detected on the next read; our own mutations no longer re-parse the
1589
+ // whole Home to observe state they already held under the write lock.
1590
+ this.#readCache = {
1591
+ fingerprint: stateFileFingerprint(join(this.rootDir, STORAGE_STATE_FILE)),
1592
+ state
1593
+ };
1138
1594
  }
1139
1595
  #parseState(raw) {
1140
1596
  return parseState(this.#normalizeState?.(raw) ?? raw);
@@ -1155,7 +1611,13 @@ export class FileTaskStore {
1155
1611
  requireCompatibleStorageSchema(this.rootDir);
1156
1612
  }
1157
1613
  }
1158
- function stateFileFingerprint(path) {
1614
+ /**
1615
+ * The on-disk identity of a `state.json` the store's read cache is keyed on.
1616
+ * The one-snapshot current-Home open fences its single read with the same
1617
+ * fingerprint, so a writer that changes the file between the fence and the
1618
+ * store's next read is detected exactly like a normal cache invalidation.
1619
+ */
1620
+ export function stateFileFingerprint(path) {
1159
1621
  const stat = statSync(path, { bigint: true });
1160
1622
  return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
1161
1623
  }
@@ -1195,6 +1657,10 @@ function emptyState() {
1195
1657
  mailboxes: {}
1196
1658
  };
1197
1659
  }
1660
+ /** rr13: Durable map key for a Session's job caller key hash. */
1661
+ function jobCallerKeyHashKey(roleName, agentId) {
1662
+ return `${roleName}\0${agentId}`;
1663
+ }
1198
1664
  function emptyStoredTask(task) {
1199
1665
  return {
1200
1666
  schemaVersion: CURRENT_STORED_TASK_SCHEMA_VERSION,
@@ -1203,9 +1669,12 @@ function emptyStoredTask(task) {
1203
1669
  brief: null,
1204
1670
  changeSets: {},
1205
1671
  integrationAttempts: {},
1672
+ integrationQueue: {},
1673
+ durableJobs: {},
1206
1674
  roles: {},
1207
1675
  managedWorkspaces: {},
1208
1676
  roleSessionSets: {},
1677
+ jobCallerKeyHashes: {},
1209
1678
  workItems: {},
1210
1679
  agentRuns: {},
1211
1680
  reviewRounds: {},
@@ -1215,6 +1684,8 @@ function emptyStoredTask(task) {
1215
1684
  decisions: {},
1216
1685
  milestones: {},
1217
1686
  events: {},
1687
+ capabilityGrants: {},
1688
+ releaseWorkflows: {},
1218
1689
  leaderFailure: null,
1219
1690
  operatorNotification: null
1220
1691
  };
@@ -1224,13 +1695,18 @@ function emptyTaskIdHighWaterMarks() {
1224
1695
  workItem: 0,
1225
1696
  agentRun: 0,
1226
1697
  reviewRound: 0,
1698
+ reviewFinding: 0,
1227
1699
  changeSet: 0,
1228
1700
  integrationAttempt: 0,
1701
+ integrationQueue: 0,
1702
+ durableJob: 0,
1229
1703
  message: 0,
1230
1704
  inputRequest: 0,
1231
1705
  decision: 0,
1232
1706
  milestone: 0,
1233
- event: 0
1707
+ event: 0,
1708
+ capabilityGrant: 0,
1709
+ releaseWorkflow: 0
1234
1710
  };
1235
1711
  }
1236
1712
  function nextTaskRecordSequence(aggregate, taskId, kind) {
@@ -1406,9 +1882,12 @@ function parseStoredTask(value, taskId) {
1406
1882
  "brief",
1407
1883
  "changeSets",
1408
1884
  "integrationAttempts",
1885
+ "integrationQueue",
1886
+ "durableJobs",
1409
1887
  "roles",
1410
1888
  "managedWorkspaces",
1411
1889
  "roleSessionSets",
1890
+ "jobCallerKeyHashes",
1412
1891
  "workItems",
1413
1892
  "agentRuns",
1414
1893
  "reviewRounds",
@@ -1418,6 +1897,8 @@ function parseStoredTask(value, taskId) {
1418
1897
  "decisions",
1419
1898
  "milestones",
1420
1899
  "events",
1900
+ "capabilityGrants",
1901
+ "releaseWorkflows",
1421
1902
  "leaderFailure",
1422
1903
  "operatorNotification"
1423
1904
  ], `Task aggregate ${taskId}`);
@@ -1437,6 +1918,25 @@ function parseStoredTask(value, taskId) {
1437
1918
  validateIntegrationAttempt(attempt);
1438
1919
  return attempt;
1439
1920
  }, "integrationAttempts");
1921
+ parseMap(aggregate.integrationQueue, (record, key) => {
1922
+ const entry = identified(record, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, "id", key, "Integration queue entry");
1923
+ if (entry.taskId !== taskId) {
1924
+ throw new StorageRecordError(`Integration queue entry belongs to another Task: ${entry.taskId}.`);
1925
+ }
1926
+ if (aggregate.changeSets[entry.changeSetId] === undefined) {
1927
+ throw new StorageRecordError(`Integration queue entry ChangeSet not found: ${entry.changeSetId}.`);
1928
+ }
1929
+ validateIntegrationQueueEntry(entry);
1930
+ return entry;
1931
+ }, "integrationQueue");
1932
+ parseMap(aggregate.durableJobs, (record, key) => {
1933
+ const job = identified(record, CURRENT_DURABLE_JOB_SCHEMA_VERSION, "id", key, "DurableJob");
1934
+ if (job.taskId !== taskId) {
1935
+ throw new StorageRecordError(`DurableJob belongs to another Task: ${job.taskId}.`);
1936
+ }
1937
+ validateDurableJob(job);
1938
+ return job;
1939
+ }, "durableJobs");
1440
1940
  versioned(aggregate, CURRENT_STORED_TASK_SCHEMA_VERSION, `Task aggregate ${taskId}`);
1441
1941
  validateTaskIdHighWaterMarks(aggregate.idHighWaterMarks, taskId);
1442
1942
  validateTask(identified(aggregate.task, CURRENT_TASK_SCHEMA_VERSION, "id", taskId, "Task"));
@@ -1462,6 +1962,12 @@ function parseStoredTask(value, taskId) {
1462
1962
  }, "managedWorkspaces");
1463
1963
  parseMap(aggregate.roleSessionSets, (record, key) => { const set = taskSessions(record); if (set.owner.taskId !== taskId || set.owner.roleName !== key)
1464
1964
  throw new StorageRecordError(`Task Role session set identity is inconsistent: ${taskId}/${key}`); return set; }, "roleSessionSets");
1965
+ parseMap(aggregate.jobCallerKeyHashes, (hash, key) => {
1966
+ if (typeof hash !== "string" || !/^[a-f0-9]{64}$/u.test(hash)) {
1967
+ throw new StorageRecordError(`Job caller key hash is invalid: ${taskId}/${key}.`);
1968
+ }
1969
+ return hash;
1970
+ }, "jobCallerKeyHashes");
1465
1971
  parseMap(aggregate.workItems, (record, key) => {
1466
1972
  const item = identified(record, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", key, "Work item");
1467
1973
  if (item.taskId !== taskId) {
@@ -1548,6 +2054,26 @@ function parseStoredTask(value, taskId) {
1548
2054
  }
1549
2055
  return event;
1550
2056
  }, "events");
2057
+ parseMap(aggregate.capabilityGrants, (record, key) => {
2058
+ const grant = storedCapabilityGrant(record);
2059
+ if (grant.id !== key) {
2060
+ throw new StorageRecordError(`Capability grant identity is inconsistent: ${key}.`);
2061
+ }
2062
+ if (grant.taskId !== taskId) {
2063
+ throw new StorageRecordError(`Capability grant belongs to another Task: ${grant.taskId}`);
2064
+ }
2065
+ return grant;
2066
+ }, "capabilityGrants");
2067
+ parseMap(aggregate.releaseWorkflows, (record, key) => {
2068
+ const workflow = storedReleaseWorkflow(record);
2069
+ if (workflow.id !== key) {
2070
+ throw new StorageRecordError(`Release workflow identity is inconsistent: ${key}.`);
2071
+ }
2072
+ if (workflow.taskId !== taskId) {
2073
+ throw new StorageRecordError(`Release workflow belongs to another Task: ${workflow.taskId}`);
2074
+ }
2075
+ return workflow;
2076
+ }, "releaseWorkflows");
1551
2077
  for (const [key, label] of [["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
1552
2078
  const record = aggregate[key];
1553
2079
  if (record !== null) {
@@ -1565,13 +2091,20 @@ function validateTaskIdHighWaterCoverage(aggregate, taskId) {
1565
2091
  workItem: aggregate.workItems,
1566
2092
  agentRun: aggregate.agentRuns,
1567
2093
  reviewRound: aggregate.reviewRounds,
2094
+ // Issue 06 dbonly: review findings are SQLite-native; the file aggregate
2095
+ // never carries them, so coverage is trivially empty.
2096
+ reviewFinding: {},
1568
2097
  changeSet: aggregate.changeSets,
1569
2098
  integrationAttempt: aggregate.integrationAttempts,
2099
+ integrationQueue: aggregate.integrationQueue,
2100
+ durableJob: aggregate.durableJobs,
1570
2101
  message: aggregate.messages,
1571
2102
  inputRequest: aggregate.inputRequests,
1572
2103
  decision: aggregate.decisions,
1573
2104
  milestone: aggregate.milestones,
1574
- event: aggregate.events
2105
+ event: aggregate.events,
2106
+ capabilityGrant: aggregate.capabilityGrants,
2107
+ releaseWorkflow: aggregate.releaseWorkflows
1575
2108
  };
1576
2109
  for (const kind of Object.keys(TASK_RECORD_ID_PREFIXES)) {
1577
2110
  const pattern = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`);
@@ -1595,12 +2128,15 @@ function observeTaskRecordId(aggregate, kind, id) {
1595
2128
  const sequence = Number.parseInt(match[1], 10);
1596
2129
  aggregate.idHighWaterMarks[kind] = Math.max(aggregate.idHighWaterMarks[kind], sequence);
1597
2130
  }
1598
- function validateYuiConfig(config) {
2131
+ export function validateYuiConfig(config) {
1599
2132
  try {
1600
2133
  reconciliationIntervalMilliseconds(config.reconciliationIntervalSeconds);
1601
2134
  resolveTimeZone(config.timeZone);
1602
2135
  if (config.review !== undefined)
1603
2136
  validateReviewConfig(config.review);
2137
+ resolveLeaderNextActionMode(config.leaderNextActionMode);
2138
+ resolveResourcesGcMode(config.resourcesGcMode);
2139
+ resolveResourcesGcAutoQuarantine(config.resourcesGcAutoQuarantine);
1604
2140
  }
1605
2141
  catch (error) {
1606
2142
  throw new StorageRecordError(error instanceof Error ? error.message : "Yui reconciliation interval is invalid.");
@@ -1741,6 +2277,372 @@ function storedTaskEvent(value) {
1741
2277
  requireTimestamp(event.createdAt, "Task event createdAt");
1742
2278
  return event;
1743
2279
  }
2280
+ export function storedCapabilityGrant(value) {
2281
+ const grant = versioned(value, CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION, "Capability grant");
2282
+ const fields = [
2283
+ "schemaVersion", "id", "taskId", "granter", "scope", "actions",
2284
+ "parameterBounds", "usesUsed", "irreversibilityCeiling", "createdAt", "updatedAt"
2285
+ ];
2286
+ if (grant.expiresAt !== undefined)
2287
+ fields.push("expiresAt");
2288
+ if (grant.maxUses !== undefined)
2289
+ fields.push("maxUses");
2290
+ if (grant.useReservations !== undefined)
2291
+ fields.push("useReservations");
2292
+ if (grant.revokedAt !== undefined)
2293
+ fields.push("revokedAt");
2294
+ if (grant.revokedBy !== undefined)
2295
+ fields.push("revokedBy");
2296
+ exact(grant, fields, "Capability grant");
2297
+ requireRecordIdentity(grant.id, "Capability grant id");
2298
+ requireRecordIdentity(grant.taskId, "Capability grant Task id");
2299
+ validateTaskRecordReference({ taskId: grant.taskId, localId: grant.id }, "capabilityGrant");
2300
+ requireNormalizedText(grant.granter, "Capability grant granter");
2301
+ storedCapabilityGrantScope(grant.scope, grant.taskId);
2302
+ if (!Array.isArray(grant.actions) || grant.actions.length === 0) {
2303
+ throw new StorageRecordError("Capability grant actions must be a non-empty array.");
2304
+ }
2305
+ const actions = grant.actions.map((action) => requireNormalizedText(action, "Capability grant action"));
2306
+ if (new Set(actions).size !== actions.length) {
2307
+ throw new StorageRecordError("Capability grant actions must be unique.");
2308
+ }
2309
+ const bounds = object(grant.parameterBounds, "Capability grant parameterBounds");
2310
+ for (const [name, allowed] of Object.entries(bounds)) {
2311
+ requireRecordIdentity(name, "Capability grant parameter");
2312
+ if (!Array.isArray(allowed) || allowed.length === 0) {
2313
+ throw new StorageRecordError(`Capability grant parameter bound must list allowed values: ${name}.`);
2314
+ }
2315
+ const values = allowed.map((entry) => requireNormalizedText(entry, `Capability grant parameter ${name} value`));
2316
+ if (new Set(values).size !== values.length) {
2317
+ throw new StorageRecordError(`Capability grant parameter bound values must be unique: ${name}.`);
2318
+ }
2319
+ }
2320
+ if (grant.expiresAt !== undefined) {
2321
+ requireTimestamp(grant.expiresAt, "Capability grant expiresAt");
2322
+ }
2323
+ if (grant.maxUses !== undefined
2324
+ && (!Number.isSafeInteger(grant.maxUses) || grant.maxUses < 1)) {
2325
+ throw new StorageRecordError("Capability grant maxUses must be a positive integer.");
2326
+ }
2327
+ if (!Number.isSafeInteger(grant.usesUsed) || grant.usesUsed < 0) {
2328
+ throw new StorageRecordError("Capability grant usesUsed must be a non-negative integer.");
2329
+ }
2330
+ if (grant.maxUses !== undefined && grant.usesUsed > grant.maxUses) {
2331
+ throw new StorageRecordError("Capability grant usesUsed cannot exceed maxUses.");
2332
+ }
2333
+ if (!["none", "reversible", "irreversible"].includes(grant.irreversibilityCeiling)) {
2334
+ throw new StorageRecordError(`Capability grant irreversibility ceiling is invalid: ${String(grant.irreversibilityCeiling)}.`);
2335
+ }
2336
+ if ((grant.revokedAt === undefined) !== (grant.revokedBy === undefined)) {
2337
+ throw new StorageRecordError("Capability grant revocation requires both revokedAt and revokedBy.");
2338
+ }
2339
+ if (grant.revokedAt !== undefined) {
2340
+ requireTimestamp(grant.revokedAt, "Capability grant revokedAt");
2341
+ requireNormalizedText(grant.revokedBy, "Capability grant revokedBy");
2342
+ }
2343
+ requireTimestamp(grant.createdAt, "Capability grant createdAt");
2344
+ requireTimestamp(grant.updatedAt, "Capability grant updatedAt");
2345
+ try {
2346
+ return validateCapabilityGrant(grant);
2347
+ }
2348
+ catch (error) {
2349
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
2350
+ }
2351
+ }
2352
+ function storedCapabilityGrantScope(scope, taskId) {
2353
+ const value = object(scope, "Capability grant scope");
2354
+ const fields = [];
2355
+ if (value.taskId !== undefined)
2356
+ fields.push("taskId");
2357
+ if (value.projectIds !== undefined)
2358
+ fields.push("projectIds");
2359
+ if (value.repositories !== undefined)
2360
+ fields.push("repositories");
2361
+ if (value.packages !== undefined)
2362
+ fields.push("packages");
2363
+ if (value.homePath !== undefined)
2364
+ fields.push("homePath");
2365
+ exact(value, fields, "Capability grant scope");
2366
+ if (fields.length === 0) {
2367
+ throw new StorageRecordError(`Capability grant scope requires at least one selector: ${taskId}.`);
2368
+ }
2369
+ if (value.taskId !== undefined) {
2370
+ requireRecordIdentity(value.taskId, "Capability grant scope taskId");
2371
+ }
2372
+ if (value.projectIds !== undefined) {
2373
+ if (!Array.isArray(value.projectIds) || value.projectIds.length === 0) {
2374
+ throw new StorageRecordError("Capability grant scope projectIds must be a non-empty array.");
2375
+ }
2376
+ const projectIds = value.projectIds.map((entry) => requireRecordIdentity(entry, "Capability grant scope Project"));
2377
+ if (new Set(projectIds).size !== projectIds.length) {
2378
+ throw new StorageRecordError("Capability grant scope Project ids must be unique.");
2379
+ }
2380
+ }
2381
+ if (value.repositories !== undefined) {
2382
+ if (!Array.isArray(value.repositories) || value.repositories.length === 0) {
2383
+ throw new StorageRecordError("Capability grant scope repositories must be a non-empty array.");
2384
+ }
2385
+ const repositories = value.repositories.map((entry) => {
2386
+ const repository = object(entry, "Capability grant scope repository");
2387
+ exact(repository, ["owner", "name"], "Capability grant scope repository");
2388
+ return {
2389
+ owner: requireNormalizedText(repository.owner, "Capability grant scope repository owner"),
2390
+ name: requireNormalizedText(repository.name, "Capability grant scope repository name")
2391
+ };
2392
+ });
2393
+ const keys = new Set(repositories.map(({ owner, name }) => `${owner}/${name}`));
2394
+ if (keys.size !== repositories.length) {
2395
+ throw new StorageRecordError("Capability grant scope repositories must be unique.");
2396
+ }
2397
+ }
2398
+ if (value.packages !== undefined) {
2399
+ if (!Array.isArray(value.packages) || value.packages.length === 0) {
2400
+ throw new StorageRecordError("Capability grant scope packages must be a non-empty array.");
2401
+ }
2402
+ const packages = value.packages.map((entry) => requireNormalizedText(entry, "Capability grant scope package"));
2403
+ if (new Set(packages).size !== packages.length) {
2404
+ throw new StorageRecordError("Capability grant scope packages must be unique.");
2405
+ }
2406
+ }
2407
+ if (value.homePath !== undefined) {
2408
+ requireNormalizedText(value.homePath, "Capability grant scope homePath");
2409
+ }
2410
+ }
2411
+ export function isValidCapabilityGrantTransition(existing, candidate) {
2412
+ if (existing.revokedAt !== undefined) {
2413
+ // Idempotent re-revoke: the domain returns the revoked record unchanged.
2414
+ return isDeepStrictEqual(candidate, existing);
2415
+ }
2416
+ const immutable = candidate.id === existing.id
2417
+ && candidate.taskId === existing.taskId
2418
+ && candidate.granter === existing.granter
2419
+ && isDeepStrictEqual(candidate.scope, existing.scope)
2420
+ && isDeepStrictEqual(candidate.actions, existing.actions)
2421
+ && isDeepStrictEqual(candidate.parameterBounds, existing.parameterBounds)
2422
+ && candidate.expiresAt === existing.expiresAt
2423
+ && candidate.maxUses === existing.maxUses
2424
+ && candidate.irreversibilityCeiling === existing.irreversibilityCeiling
2425
+ && candidate.createdAt === existing.createdAt;
2426
+ if (!immutable)
2427
+ return false;
2428
+ if (candidate.revokedAt !== undefined) {
2429
+ // Revocation consumes no uses and records no reservations.
2430
+ return candidate.usesUsed === existing.usesUsed
2431
+ && isDeepStrictEqual(candidate.useReservations, existing.useReservations)
2432
+ && Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt);
2433
+ }
2434
+ // A use record must advance the counter (compare-and-swap): a stale equal
2435
+ // increment from a concurrent reader is rejected, so two workflows cannot
2436
+ // spend the same maxUses slot. Reservations are append-only, one per use.
2437
+ return candidate.usesUsed > existing.usesUsed
2438
+ && reservationsAppendOnly(existing.useReservations, candidate.useReservations)
2439
+ && Date.parse(candidate.updatedAt) >= Date.parse(existing.updatedAt);
2440
+ }
2441
+ function reservationsAppendOnly(existing, candidate) {
2442
+ if (existing === undefined || existing.length === 0)
2443
+ return true;
2444
+ if (candidate === undefined || candidate.length < existing.length)
2445
+ return false;
2446
+ return existing.every((key, index) => candidate[index] === key);
2447
+ }
2448
+ export function storedReleaseWorkflow(value) {
2449
+ const workflow = versioned(value, CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, "Release workflow");
2450
+ const fields = [
2451
+ "schemaVersion", "id", "taskId", "grantId", "source", "plan", "steps",
2452
+ "createdAt", "updatedAt"
2453
+ ];
2454
+ exact(workflow, fields, "Release workflow");
2455
+ requireRecordIdentity(workflow.id, "Release workflow id");
2456
+ requireRecordIdentity(workflow.taskId, "Release workflow Task id");
2457
+ validateTaskRecordReference({ taskId: workflow.taskId, localId: workflow.id }, "releaseWorkflow");
2458
+ requireNormalizedText(workflow.grantId, "Release workflow grantId");
2459
+ storedReleaseWorkflowSource(workflow.source);
2460
+ if (!Array.isArray(workflow.plan) || workflow.plan.length === 0) {
2461
+ throw new StorageRecordError("Release workflow plan must be a non-empty array.");
2462
+ }
2463
+ const planIds = new Set();
2464
+ for (const entry of workflow.plan) {
2465
+ const plan = object(entry, "Release workflow plan entry");
2466
+ const planFields = ["id", "kind", "idempotencyKey"];
2467
+ if (plan.params !== undefined)
2468
+ planFields.push("params");
2469
+ if (plan.irreversibility !== undefined)
2470
+ planFields.push("irreversibility");
2471
+ exact(plan, planFields, "Release workflow plan entry");
2472
+ const planId = requireRecordIdentity(plan.id, "Release step id");
2473
+ if (planIds.has(planId)) {
2474
+ throw new StorageRecordError(`Release workflow plan ids must be unique: ${planId}.`);
2475
+ }
2476
+ planIds.add(planId);
2477
+ if (!RELEASE_WORKFLOW_KINDS.has(plan.kind)) {
2478
+ throw new StorageRecordError(`Release step kind is invalid: ${String(plan.kind)}.`);
2479
+ }
2480
+ requireNormalizedText(plan.idempotencyKey, "Release step idempotencyKey");
2481
+ if (plan.params !== undefined) {
2482
+ const params = object(plan.params, "Release step params");
2483
+ for (const [name, paramValue] of Object.entries(params)) {
2484
+ requireRecordIdentity(name, "Release step param");
2485
+ requireNormalizedText(paramValue, `Release step param ${name}`);
2486
+ }
2487
+ }
2488
+ if (plan.irreversibility !== undefined
2489
+ && !["none", "reversible", "irreversible"].includes(plan.irreversibility)) {
2490
+ throw new StorageRecordError(`Release step irreversibility is invalid: ${String(plan.irreversibility)}.`);
2491
+ }
2492
+ }
2493
+ const steps = object(workflow.steps, "Release workflow steps");
2494
+ for (const planId of planIds) {
2495
+ if (!Object.hasOwn(steps, planId)) {
2496
+ throw new StorageRecordError(`Release workflow step record is missing: ${planId}.`);
2497
+ }
2498
+ }
2499
+ for (const key of Object.keys(steps)) {
2500
+ if (!planIds.has(key)) {
2501
+ throw new StorageRecordError(`Release workflow step record has no plan entry: ${key}.`);
2502
+ }
2503
+ }
2504
+ for (const planId of planIds) {
2505
+ storedReleaseStep(steps[planId], planId);
2506
+ }
2507
+ requireTimestamp(workflow.createdAt, "Release workflow createdAt");
2508
+ requireTimestamp(workflow.updatedAt, "Release workflow updatedAt");
2509
+ try {
2510
+ return validateReleaseWorkflow(workflow);
2511
+ }
2512
+ catch (error) {
2513
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
2514
+ }
2515
+ }
2516
+ function storedReleaseWorkflowSource(source) {
2517
+ const value = object(source, "Release workflow source");
2518
+ const sourceFields = ["repository", "commit"];
2519
+ if (value.artifact !== undefined)
2520
+ sourceFields.push("artifact");
2521
+ exact(value, sourceFields, "Release workflow source");
2522
+ const repository = object(value.repository, "Release workflow source repository");
2523
+ exact(repository, ["owner", "name"], "Release workflow source repository");
2524
+ requireNormalizedText(repository.owner, "Release workflow source repository owner");
2525
+ requireNormalizedText(repository.name, "Release workflow source repository name");
2526
+ requireNormalizedText(value.commit, "Release workflow source commit");
2527
+ if (value.artifact !== undefined) {
2528
+ const artifact = object(value.artifact, "Release workflow source artifact");
2529
+ exact(artifact, ["name", "integrity"], "Release workflow source artifact");
2530
+ requireNormalizedText(artifact.name, "Release workflow source artifact name");
2531
+ requireNormalizedText(artifact.integrity, "Release workflow source artifact integrity");
2532
+ }
2533
+ }
2534
+ function storedReleaseStep(step, planId) {
2535
+ const value = object(step, `Release step record ${planId}`);
2536
+ const stepFields = ["planId", "status", "attempts", "logs"];
2537
+ if (value.externalId !== undefined)
2538
+ stepFields.push("externalId");
2539
+ if (value.externalIdentity !== undefined)
2540
+ stepFields.push("externalIdentity");
2541
+ if (value.lastAttemptAt !== undefined)
2542
+ stepFields.push("lastAttemptAt");
2543
+ if (value.terminalAt !== undefined)
2544
+ stepFields.push("terminalAt");
2545
+ exact(value, stepFields, `Release step record ${planId}`);
2546
+ requireNormalizedText(value.planId, `Release step planId ${planId}`);
2547
+ if (value.planId !== planId) {
2548
+ throw new StorageRecordError(`Release step record planId ${String(value.planId)} does not match its key: ${planId}.`);
2549
+ }
2550
+ if (!RELEASE_WORKFLOW_STATUSES.has(value.status)) {
2551
+ throw new StorageRecordError(`Release step status is invalid: ${String(value.status)}.`);
2552
+ }
2553
+ if (!Number.isSafeInteger(value.attempts) || value.attempts < 0) {
2554
+ throw new StorageRecordError(`Release step attempts must be a non-negative integer: ${planId}.`);
2555
+ }
2556
+ if (value.externalId !== undefined) {
2557
+ requireNormalizedText(value.externalId, `Release step externalId ${planId}`);
2558
+ }
2559
+ if (value.externalIdentity !== undefined) {
2560
+ const identity = object(value.externalIdentity, `Release step externalIdentity ${planId}`);
2561
+ exact(identity, ["kind", "value"], `Release step externalIdentity ${planId}`);
2562
+ requireNormalizedText(identity.kind, `Release step externalIdentity kind ${planId}`);
2563
+ requireNormalizedText(identity.value, `Release step externalIdentity value ${planId}`);
2564
+ }
2565
+ // An `unknown` step without an externalIdentity is a crash-recovery state:
2566
+ // the process died during executeStep after the effect may have landed, but
2567
+ // before an identity was recorded. The domain validation permits it and the
2568
+ // engine fails closed (unconfirmed) on resume; the store must persist it.
2569
+ if (!Array.isArray(value.logs)) {
2570
+ throw new StorageRecordError(`Release step logs must be an array: ${planId}.`);
2571
+ }
2572
+ for (const line of value.logs) {
2573
+ requireNormalizedText(line, `Release step log ${planId}`);
2574
+ }
2575
+ if (value.lastAttemptAt !== undefined) {
2576
+ requireTimestamp(value.lastAttemptAt, `Release step lastAttemptAt ${planId}`);
2577
+ }
2578
+ if (value.status === "running" && value.lastAttemptAt === undefined) {
2579
+ throw new StorageRecordError(`Release step running status requires lastAttemptAt: ${planId}.`);
2580
+ }
2581
+ if (value.terminalAt !== undefined) {
2582
+ requireTimestamp(value.terminalAt, `Release step terminalAt ${planId}`);
2583
+ }
2584
+ if ((value.status === "succeeded" || value.status === "skipped") && value.terminalAt === undefined) {
2585
+ throw new StorageRecordError(`Release step ${String(value.status)} status requires terminalAt: ${planId}.`);
2586
+ }
2587
+ }
2588
+ const RELEASE_WORKFLOW_KINDS = new Set([
2589
+ "pr-create-or-reuse", "ci-confirm", "merge", "version-tag",
2590
+ "npm-publish", "fresh-install-smoke", "cli-update",
2591
+ "controller-replace", "project-migrate", "post-verify"
2592
+ ]);
2593
+ const RELEASE_WORKFLOW_STATUSES = new Set([
2594
+ "pending", "running", "succeeded", "failed", "unknown", "skipped"
2595
+ ]);
2596
+ /**
2597
+ * Workflow records only move forward in time: a save with an older updatedAt
2598
+ * than the stored record is rejected. Equal updatedAt permits idempotent
2599
+ * re-saves of the same record. The exact source and the predeclared plan are
2600
+ * immutable after create, and each step status may only follow the release
2601
+ * state machine (no rewinds that could re-trigger a side effect).
2602
+ */
2603
+ export function isValidReleaseWorkflowTransition(existing, candidate) {
2604
+ if (Date.parse(candidate.updatedAt) < Date.parse(existing.updatedAt))
2605
+ return false;
2606
+ if (!isDeepStrictEqual(candidate.source, existing.source))
2607
+ return false;
2608
+ if (!isDeepStrictEqual(candidate.plan, existing.plan))
2609
+ return false;
2610
+ const existingKeys = Object.keys(existing.steps);
2611
+ const candidateKeys = Object.keys(candidate.steps);
2612
+ if (existingKeys.length !== candidateKeys.length)
2613
+ return false;
2614
+ for (const key of existingKeys) {
2615
+ const from = existing.steps[key];
2616
+ const to = candidate.steps[key];
2617
+ if (from === undefined || to === undefined)
2618
+ return false;
2619
+ if (!isLegalStepTransition(from.status, to.status))
2620
+ return false;
2621
+ if (to.attempts < from.attempts)
2622
+ return false;
2623
+ }
2624
+ return true;
2625
+ }
2626
+ /**
2627
+ * The legal step-status transitions. Self-transitions are always allowed
2628
+ * (idempotent re-saves). `pending -> failed` is permitted because the engine
2629
+ * records an authorization denial atomically (start then fail in one save).
2630
+ * `failed -> succeeded` is permitted only via an authoritative query that
2631
+ * proves the effect landed (confirmFailedStep).
2632
+ */
2633
+ const LEGAL_STEP_TRANSITIONS = {
2634
+ pending: ["running", "skipped", "failed"],
2635
+ running: ["running", "succeeded", "failed", "unknown"],
2636
+ failed: ["running", "succeeded"],
2637
+ unknown: ["running", "succeeded"],
2638
+ succeeded: [],
2639
+ skipped: []
2640
+ };
2641
+ function isLegalStepTransition(from, to) {
2642
+ if (from === to)
2643
+ return true;
2644
+ return LEGAL_STEP_TRANSITIONS[from]?.includes(to) ?? false;
2645
+ }
1744
2646
  function isValidDecisionSupersession(existing, candidate) {
1745
2647
  return existing.status === "active"
1746
2648
  && candidate.status === "superseded"
@@ -1866,7 +2768,7 @@ function values(records, identity) {
1866
2768
  return Object.values(records).map(clone).sort((left, right) => numericCompare(typeof identity === "function" ? identity(left) : String(left[identity]), typeof identity === "function" ? identity(right) : String(right[identity])));
1867
2769
  }
1868
2770
  function numericCompare(left, right) { return left.localeCompare(right, undefined, { numeric: true }); }
1869
- function pendingWakeupProjection(mailbox) {
2771
+ export function pendingWakeupProjection(mailbox) {
1870
2772
  if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
1871
2773
  || mailbox.pending === null) {
1872
2774
  return null;
@@ -2043,7 +2945,7 @@ function validateCanonicalTaskReferences(state, aggregate) {
2043
2945
  if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === changeSet.projectId)) {
2044
2946
  throw new StorageRecordError(`ChangeSet Project does not match Task: ${changeSet.id}.`);
2045
2947
  }
2046
- const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
2948
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
2047
2949
  if (evidenceRound !== undefined) {
2048
2950
  throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become a ChangeSet.`);
2049
2951
  }
@@ -2060,7 +2962,7 @@ function validateCanonicalTaskReferences(state, aggregate) {
2060
2962
  if (changeSet.projectId !== integration.projectId) {
2061
2963
  throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${integration.id}/${changeSetId}.`);
2062
2964
  }
2063
- const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
2965
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit, reviewBaseCommit }) => evidenceCommit !== undefined && evidenceCommit !== reviewBaseCommit && evidenceCommit === changeSet.headCommit);
2064
2966
  if (evidenceRound !== undefined) {
2065
2967
  throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
2066
2968
  }
@@ -2209,11 +3111,38 @@ function validIntegrationTransition(before, after) {
2209
3111
  running: ["running", "blocked", "validating", "failed"],
2210
3112
  blocked: ["blocked", "validating", "failed"],
2211
3113
  validating: ["validating", "committed", "failed"],
2212
- committed: ["committed"],
3114
+ committed: ["committed", "superseded"],
3115
+ superseded: ["superseded"],
2213
3116
  failed: ["failed"]
2214
3117
  };
2215
3118
  return allowed[before.status].includes(after.status);
2216
3119
  }
3120
+ /**
3121
+ * Storage-level defence in depth for the integration queue state machine: the
3122
+ * identity fields and the check/evidence lists are immutable once written, and
3123
+ * a status may only move along the queue's legal transitions. The service
3124
+ * owns the CAS claim; this rejects a stale or forged write that slipped past it.
3125
+ */
3126
+ function validIntegrationQueueTransition(before, after) {
3127
+ if (before.id !== after.id
3128
+ || before.taskId !== after.taskId
3129
+ || before.projectId !== after.projectId
3130
+ || before.changeSetId !== after.changeSetId
3131
+ || before.targetRef !== after.targetRef
3132
+ || !isDeepStrictEqual(before.checkCommands, after.checkCommands)
3133
+ || !isDeepStrictEqual(before.evidenceRefs, after.evidenceRefs)
3134
+ || before.createdAt !== after.createdAt)
3135
+ return false;
3136
+ const allowed = {
3137
+ queued: ["queued", "running", "validated", "superseded"],
3138
+ running: ["running", "conflicted", "committed"],
3139
+ conflicted: ["conflicted", "running", "committed", "queued", "superseded"],
3140
+ validated: ["validated", "running", "queued", "superseded"],
3141
+ committed: ["committed"],
3142
+ superseded: ["superseded"]
3143
+ };
3144
+ return allowed[before.status].includes(after.status);
3145
+ }
2217
3146
  function assertAcyclicWorkItems(items) {
2218
3147
  const visiting = new Set();
2219
3148
  const visited = new Set();
@@ -2536,7 +3465,13 @@ function validReviewRoundTransition(existing, candidate) {
2536
3465
  || !isDeepStrictEqual(existing.taskCandidate, candidate.taskCandidate)
2537
3466
  || !sameTaskFinalReviewContract(existing.taskFinalReviewContract, candidate.taskFinalReviewContract)
2538
3467
  || !compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup)
2539
- || existing.requestedBy !== candidate.requestedBy
3468
+ // Issue 06: a Leader retry resets a failed Task-final Round to pending;
3469
+ // the retry is itself a Leader request, so requestedBy may change from
3470
+ // the original policy/contract value to "leader".
3471
+ || (existing.requestedBy !== candidate.requestedBy
3472
+ && !(existing.status === "failed"
3473
+ && candidate.status === "pending"
3474
+ && (candidate.scope ?? "work-item") === "task"))
2540
3475
  || existing.createdAt !== candidate.createdAt)
2541
3476
  return false;
2542
3477
  if (existing.status === "pending") {
@@ -2577,6 +3512,24 @@ function validReviewRoundTransition(existing, candidate) {
2577
3512
  && existing.reviewerRunId === candidate.reviewerRunId
2578
3513
  && isDeepStrictEqual(existing.workspace, candidate.workspace);
2579
3514
  }
3515
+ // Issue 06: a failed Task-final execution attempt may be reset to pending
3516
+ // under the same semantic Round ID. AgentRun history remains the attempt
3517
+ // trail; terminal Review metadata is cleared by retryTaskReviewRound.
3518
+ if (existing.status === "failed"
3519
+ && candidate.status === "pending"
3520
+ && (candidate.scope ?? "work-item") === "task") {
3521
+ return candidate.reviewerRunId === undefined
3522
+ && candidate.summary === undefined
3523
+ && candidate.report === undefined
3524
+ && candidate.checks === undefined
3525
+ && candidate.evidenceCommit === undefined
3526
+ && candidate.endedAt === undefined
3527
+ && candidate.workspaceDisposition === undefined
3528
+ && (existing.workspace === undefined
3529
+ || candidate.workspace === undefined
3530
+ || isDeepStrictEqual(existing.workspace, candidate.workspace))
3531
+ && compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup);
3532
+ }
2580
3533
  if (existing.status === candidate.status
2581
3534
  && (existing.status === "completed" || existing.status === "failed")) {
2582
3535
  const { workspaceDisposition: _existingDisposition, ...existingResult } = existing;