@zq-silk/yui 0.2.0 → 0.4.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 (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -4,34 +4,119 @@ import { join, resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { validateConfiguredAgent } from "../agent/agent.js";
6
6
  import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
7
+ import { resolveTimeZone } from "../output/timePresentation.js";
8
+ import { mailboxTargetKey, validateWorkMailbox } from "../coordination/workMailbox.js";
7
9
  import { validateInputRequest } from "../input/inputRequest.js";
8
10
  import { validateRoleSessionSet } from "../executor/agentExecutor.js";
9
11
  import { validateTaskMessage } from "../message/message.js";
10
12
  import { validateAgentRun } from "../run/agentRun.js";
11
- import { validateRepository } from "../repository/repository.js";
13
+ import { validateReviewConfig } from "../review/reviewConfig.js";
14
+ import { validateReviewRound } from "../review/reviewRound.js";
15
+ import { sameTaskFinalReviewContract } from "../review/taskFinalReviewContract.js";
16
+ import { assertProjectCatalog, validateProject } from "../repository/project.js";
17
+ import { validateAgentProfile } from "../profile/agentProfile.js";
18
+ import { validateChangeSet } from "../integration/changeSet.js";
19
+ import { validateIntegrationAttempt } from "../integration/integrationAttempt.js";
12
20
  import { validateGlobalRole, validateTaskRole } from "../role/role.js";
21
+ import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../scheduler/leaderFailure.js";
22
+ import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../scheduler/operatorNotification.js";
13
23
  import { validateTask } from "../task/task.js";
14
- import { validateRoleWorkspace } from "../worktree/roleWorkspace.js";
24
+ import { formatAgentRunReceiptId, TASK_RECORD_ID_PREFIXES, validateTaskRecordReference } from "../task/taskRecordReference.js";
25
+ import { workItemExecutionGroupById, validateWorkItem } from "../workItem/workItem.js";
26
+ import { isExecutionGroupTransition, validateExecutionGroup } from "../execution/executionGroup.js";
27
+ import { managedWorkspaceKey, validateManagedWorkspace } from "../worktree/managedWorkspace.js";
15
28
  import { writeTextFileAtomically } from "./durableFile.js";
16
- import { requireStorageSchema } from "./storageSchema.js";
29
+ import { assertHomeWritable } from "./upgradeFence.js";
30
+ import { CURRENT_AGGREGATE_SCHEMA_VERSION, requireCompatibleStorageSchema, requireStorageSchema, writeCurrentStorageManifest } from "./storageSchema.js";
17
31
  export const STORAGE_STATE_FILE = "state.json";
32
+ /** The root StorageState schema is the persisted aggregate document version. */
33
+ export const CURRENT_STORAGE_STATE_SCHEMA_VERSION = CURRENT_AGGREGATE_SCHEMA_VERSION;
34
+ export const CURRENT_CONFIG_SCHEMA_VERSION = 1;
35
+ export const CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION = 3;
36
+ /**
37
+ * Persisted StorageState/StoredTask family versions owned by this boundary.
38
+ *
39
+ * Keep these names next to the strict parser/writer so the upgrade record map
40
+ * can classify exactly the bytes this store accepts and emits. Nested session
41
+ * members and the PendingWakeup projection are included as parser boundaries
42
+ * below, but are not independent record-axis families.
43
+ */
44
+ export const CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION = 2;
45
+ export const CURRENT_PROJECT_SCHEMA_VERSION = 2;
46
+ export const CURRENT_AGENT_PROFILE_SCHEMA_VERSION = 2;
47
+ export const CURRENT_GLOBAL_ROLE_SCHEMA_VERSION = 3;
48
+ export const CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION = 3;
49
+ export const CURRENT_TASK_SCHEMA_VERSION = 3;
50
+ export const CURRENT_TASK_BRIEF_SCHEMA_VERSION = 2;
51
+ export const CURRENT_TASK_ROLE_SCHEMA_VERSION = 3;
52
+ export const CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION = 2;
53
+ export const CURRENT_WORK_ITEM_SCHEMA_VERSION = 9;
54
+ export const CURRENT_REVIEW_ROUND_SCHEMA_VERSION = 4;
55
+ export const CURRENT_CHANGE_SET_SCHEMA_VERSION = 2;
56
+ export const CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION = 2;
57
+ export const CURRENT_MESSAGE_SCHEMA_VERSION = 2;
58
+ export const CURRENT_INPUT_REQUEST_SCHEMA_VERSION = 2;
59
+ export const CURRENT_DECISION_SCHEMA_VERSION = 1;
60
+ export const CURRENT_MILESTONE_SCHEMA_VERSION = 1;
61
+ export const CURRENT_EVENT_SCHEMA_VERSION = 2;
62
+ export const CURRENT_WORK_MAILBOX_SCHEMA_VERSION = 1;
63
+ export const CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION = 3;
64
+ export const CURRENT_PENDING_WAKEUP_SCHEMA_VERSION = 1;
18
65
  const STORAGE_LOCK_DIRECTORY = ".state.lock";
19
66
  const LOCK_TIMEOUT_MS = 5_000;
20
67
  const LOCK_RETRY_MS = 10;
21
68
  export const COMPLETION_SHELLS = ["bash", "zsh", "fish"];
69
+ /**
70
+ * Lane-scoped active pointers live beside the legacy role pointers. The
71
+ * namespace starts with a slash because Role identities reject slashes; this
72
+ * makes the two key spaces disjoint even for legal Role names such as
73
+ * `lane:worker:1`.
74
+ */
75
+ export function executionLaneActiveRunKey(executionGroupId, executionLaneId) {
76
+ return `/execution-lane/${encodeLaneKeyPart(executionGroupId)}:${encodeLaneKeyPart(executionLaneId)}`;
77
+ }
78
+ function encodeLaneKeyPart(value) {
79
+ return encodeURIComponent(value).replace(/:/gu, "%3A");
80
+ }
81
+ function executionLaneActiveRunKeyParts(key) {
82
+ const match = /^\/execution-lane\/([^:]+):([^:]+)$/u.exec(key);
83
+ if (match === null)
84
+ return null;
85
+ try {
86
+ return {
87
+ executionGroupId: decodeURIComponent(match[1]),
88
+ executionLaneId: decodeURIComponent(match[2])
89
+ };
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /** The schema version of each persisted `state.json#/tasks/*` aggregate. */
96
+ export const CURRENT_STORED_TASK_SCHEMA_VERSION = 14;
97
+ /**
98
+ * Persisted nested-record versions consumed by this store's strict parser.
99
+ * Keep these named at the storage boundary so the upgrade record-axis map can
100
+ * assert it is classifying the same bytes the store reads and writes.
101
+ */
102
+ export const CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION = 4;
103
+ export const CURRENT_AGENT_RUN_SCHEMA_VERSION = 6;
22
104
  export class FileTaskStore {
23
105
  rootDir;
24
106
  #transaction = null;
25
- constructor(rootDir) {
107
+ #readCache = null;
108
+ #normalizeState;
109
+ constructor(rootDir, options = {}) {
26
110
  this.rootDir = rootDir;
27
- requireStorageSchema(rootDir);
111
+ this.#normalizeState = options.normalizeState;
112
+ this.#requireReadableSchema();
28
113
  }
29
114
  rootDirectory() { return this.rootDir; }
30
115
  transaction(execute) {
31
116
  if (this.#transaction !== null)
32
117
  return synchronousResult(execute(this));
33
118
  return this.#withWriteLock(() => {
34
- const state = this.#readState();
119
+ const state = this.#readCachedState();
35
120
  this.#transaction = { state, baseRevision: state.revision, dirty: false };
36
121
  try {
37
122
  const result = synchronousResult(execute(this));
@@ -39,6 +124,10 @@ export class FileTaskStore {
39
124
  this.#commit(state, this.#transaction.baseRevision);
40
125
  return result;
41
126
  }
127
+ catch (error) {
128
+ this.#readCache = null;
129
+ throw error;
130
+ }
42
131
  finally {
43
132
  this.#transaction = null;
44
133
  }
@@ -46,12 +135,12 @@ export class FileTaskStore {
46
135
  }
47
136
  getConfig() { return clone(this.#state().config); }
48
137
  saveConfig(config) {
49
- const stored = versioned(config, 1, "Yui config");
138
+ const stored = versioned(config, CURRENT_CONFIG_SCHEMA_VERSION, "Yui config");
50
139
  validateYuiConfig(stored);
51
140
  this.#mutate((state) => { state.config = stored; });
52
141
  }
53
142
  saveConfiguredAgent(agent) {
54
- const stored = identified(agent, 2, "id", agent.id, "Configured Agent");
143
+ const stored = identified(agent, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "id", agent.id, "Configured Agent");
55
144
  validateConfiguredAgent(stored);
56
145
  this.#mutate((state) => { state.configuredAgents[stored.id] = stored; });
57
146
  }
@@ -72,7 +161,7 @@ export class FileTaskStore {
72
161
  ...existing,
73
162
  ...clone(patch),
74
163
  updatedAt: now.toISOString()
75
- }, 2, "Configured Agent");
164
+ }, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "Configured Agent");
76
165
  const unchanged = isDeepStrictEqual({ ...existing, updatedAt: candidate.updatedAt }, candidate);
77
166
  if (unchanged)
78
167
  return { status: "unchanged", agent: existing };
@@ -85,36 +174,87 @@ export class FileTaskStore {
85
174
  removeConfiguredAgent(id) {
86
175
  return this.#remove((state) => state.configuredAgents, id);
87
176
  }
88
- nextRepositoryId() {
89
- return this.#nextGlobalId("repository", (state) => Object.keys(state.repositories));
177
+ nextProjectId() {
178
+ return this.#nextGlobalId("project", (state) => Object.keys(state.projects));
90
179
  }
91
- saveRepository(repository) {
92
- const stored = identified(repository, 1, "id", repository.id, "Repository");
93
- validateRepository(stored);
94
- this.#mutate((state) => { state.repositories[stored.id] = stored; });
180
+ saveProject(project) {
181
+ const stored = identified(project, CURRENT_PROJECT_SCHEMA_VERSION, "id", project.id, "Project");
182
+ validateProject(stored);
183
+ this.#mutate((state) => {
184
+ assertProjectCatalog([
185
+ ...Object.values(state.projects).filter(({ id }) => id !== stored.id),
186
+ stored
187
+ ]);
188
+ state.projects[stored.id] = stored;
189
+ });
95
190
  }
96
- createRepositoryIfAbsent(repository) {
191
+ createProjectIfAbsent(project) {
192
+ validateProject(project);
97
193
  return this.transaction((store) => {
98
- if (store.getRepository(repository.id) !== null)
194
+ if (store.getProject(project.id) !== null)
99
195
  return null;
100
- if (store.listRepositories().some((entry) => (entry.name === repository.name || entry.path === repository.path)))
196
+ try {
197
+ assertProjectCatalog([...store.listProjects(), project]);
198
+ }
199
+ catch {
101
200
  return null;
102
- store.saveRepository(repository);
103
- return clone(repository);
201
+ }
202
+ store.saveProject(project);
203
+ return clone(project);
104
204
  });
105
205
  }
106
- listRepositories() { return values(this.#state().repositories, "id"); }
107
- getRepository(id) { return optional(this.#state().repositories[id]); }
108
- removeRepository(id) {
206
+ listProjects() { return values(this.#state().projects, "id"); }
207
+ getProject(id) { return optional(this.#state().projects[id]); }
208
+ removeProject(id) {
109
209
  return this.transaction((store) => {
110
- if (store.listTasks().some((task) => task.repositoryId === id)) {
111
- throw new StorageRecordError(`Repository is still used by a Task: ${id}`);
210
+ if (store.listTasks().some((task) => task.projectBindings.some((binding) => binding.projectId === id))) {
211
+ throw new StorageRecordError(`Project is still used by a Task: ${id}`);
112
212
  }
113
- return this.#remove((state) => state.repositories, id);
213
+ return this.#remove((state) => state.projects, id);
114
214
  });
115
215
  }
216
+ saveAgentProfile(profile) {
217
+ const stored = identified(profile, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", profile.id, "Agent Profile");
218
+ validateAgentProfile(stored);
219
+ this.#mutate((state) => {
220
+ const existing = state.agentProfiles[stored.id];
221
+ if (existing !== undefined) {
222
+ if (stored.revision < existing.revision) {
223
+ throw new StorageRecordError(`Agent Profile revision cannot move backwards: ${stored.id}.`);
224
+ }
225
+ if (stored.revision === existing.revision
226
+ && !isDeepStrictEqual(stored, existing)) {
227
+ throw new StorageRecordError(`Agent Profile revision cannot be overwritten: ${stored.id}/${stored.revision}.`);
228
+ }
229
+ if (stored.revision > existing.revision + 1) {
230
+ throw new StorageRecordError(`Agent Profile revision must be contiguous: ${stored.id}/${stored.revision}.`);
231
+ }
232
+ }
233
+ else if (stored.revision !== 1) {
234
+ throw new StorageRecordError(`A new Agent Profile must start at revision 1: ${stored.id}.`);
235
+ }
236
+ state.agentProfiles[stored.id] = stored;
237
+ });
238
+ }
239
+ createAgentProfileIfAbsent(profile) {
240
+ return this.transaction((store) => {
241
+ if (store.getAgentProfile(profile.id) !== null)
242
+ return null;
243
+ store.saveAgentProfile(profile);
244
+ return clone(profile);
245
+ });
246
+ }
247
+ listAgentProfiles() {
248
+ return values(this.#state().agentProfiles, "id");
249
+ }
250
+ getAgentProfile(id) {
251
+ return optional(this.#state().agentProfiles[id]);
252
+ }
253
+ removeAgentProfile(id) {
254
+ return this.#remove((state) => state.agentProfiles, id);
255
+ }
116
256
  saveGlobalRole(role) {
117
- const stored = identified(role, 2, "name", role.name, "Global Role");
257
+ const stored = identified(role, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", role.name, "Global Role");
118
258
  validateGlobalRole(stored);
119
259
  const sessions = this.getGlobalRoleSessionSet(stored.name);
120
260
  if (sessions !== null)
@@ -122,7 +262,7 @@ export class FileTaskStore {
122
262
  this.#mutate((state) => { state.globalRoles[stored.name] = stored; });
123
263
  }
124
264
  saveGlobalRoleWithSessionSet(role, sessions) {
125
- const storedRole = identified(role, 2, "name", role.name, "Global Role");
265
+ const storedRole = identified(role, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", role.name, "Global Role");
126
266
  validateGlobalRole(storedRole);
127
267
  const storedSessions = sessions === null ? null : globalSessions(sessions);
128
268
  if (storedSessions !== null)
@@ -170,10 +310,12 @@ export class FileTaskStore {
170
310
  }
171
311
  nextTaskId() { return this.#nextGlobalId("task", (state) => Object.keys(state.tasks)); }
172
312
  saveTask(task) {
173
- const stored = validateTask(identified(task, 1, "id", task.id, "Task"));
313
+ const stored = validateTask(identified(task, CURRENT_TASK_SCHEMA_VERSION, "id", task.id, "Task"));
174
314
  this.#mutate((state) => {
175
- if (stored.repositoryId !== undefined && state.repositories[stored.repositoryId] === undefined) {
176
- throw new StorageRecordError(`Task Repository not found: ${stored.repositoryId}`);
315
+ for (const binding of stored.projectBindings) {
316
+ if (state.projects[binding.projectId] === undefined) {
317
+ throw new StorageRecordError(`Task Project not found: ${binding.projectId}`);
318
+ }
177
319
  }
178
320
  const aggregate = state.tasks[stored.id] ?? emptyStoredTask(stored);
179
321
  aggregate.task = stored;
@@ -182,6 +324,9 @@ export class FileTaskStore {
182
324
  }
183
325
  listTasks() { return values(this.#state().tasks, (aggregate) => aggregate.task.id).map((entry) => clone(entry.task)); }
184
326
  getTask(id) { return optional(this.#state().tasks[id]?.task); }
327
+ getReviewConfig() {
328
+ return optional(this.#state().config.review);
329
+ }
185
330
  getTaskBrief(taskId) {
186
331
  return optional(this.#state().tasks[taskId]?.brief ?? undefined);
187
332
  }
@@ -194,9 +339,92 @@ export class FileTaskStore {
194
339
  this.#requireTaskForWrite(taskId);
195
340
  this.#mutate((state) => { state.tasks[taskId].brief = null; });
196
341
  }
342
+ nextChangeSetId(taskId) {
343
+ return this.#nextTaskRecordId(taskId, "changeSet");
344
+ }
345
+ saveChangeSet(taskId, changeSet) {
346
+ const stored = identifiedChangeSet(changeSet, changeSet.id);
347
+ validateChangeSet(stored);
348
+ if (stored.taskId !== taskId) {
349
+ throw new StorageRecordError(`ChangeSet belongs to another Task: ${stored.taskId}.`);
350
+ }
351
+ const aggregate = this.#requireTaskForWrite(taskId);
352
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === stored.headCommit);
353
+ if (evidenceRound !== undefined) {
354
+ throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${stored.headCommit} cannot become a ChangeSet.`);
355
+ }
356
+ if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
357
+ throw new StorageRecordError(`ChangeSet Project does not match Task: ${stored.id}.`);
358
+ }
359
+ if (aggregate.workItems[stored.workItemId] === undefined) {
360
+ throw new StorageRecordError(`ChangeSet Work Item not found: ${stored.workItemId}.`);
361
+ }
362
+ const existing = aggregate.changeSets[stored.id];
363
+ if (existing !== undefined && !isDeepStrictEqual(existing, stored)) {
364
+ throw new StorageRecordError(`ChangeSet is immutable: ${stored.id}.`);
365
+ }
366
+ this.#mutate((state) => {
367
+ const task = state.tasks[taskId];
368
+ observeTaskRecordId(task, "changeSet", stored.id);
369
+ task.changeSets[stored.id] = stored;
370
+ });
371
+ }
372
+ listChangeSets(taskId) {
373
+ return values(this.#requireTask(taskId).changeSets, "id");
374
+ }
375
+ getChangeSet(taskId, changeSetId) {
376
+ return optional(this.#state().tasks[taskId]?.changeSets[changeSetId]);
377
+ }
378
+ nextIntegrationAttemptId(taskId) {
379
+ return this.#nextTaskRecordId(taskId, "integrationAttempt");
380
+ }
381
+ saveIntegrationAttempt(taskId, attempt) {
382
+ const stored = identified(attempt, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, "id", attempt.id, "Integration Attempt");
383
+ validateIntegrationAttempt(stored);
384
+ if (stored.taskId !== taskId) {
385
+ throw new StorageRecordError(`Integration Attempt belongs to another Task: ${stored.taskId}.`);
386
+ }
387
+ const aggregate = this.#requireTaskForWrite(taskId);
388
+ if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === stored.projectId)) {
389
+ throw new StorageRecordError(`Integration Project does not match Task: ${stored.id}.`);
390
+ }
391
+ for (const changeSetId of stored.changeSetIds) {
392
+ const changeSet = aggregate.changeSets[changeSetId];
393
+ if (changeSet === undefined) {
394
+ throw new StorageRecordError(`Integration ChangeSet not found: ${changeSetId}.`);
395
+ }
396
+ if (changeSet.projectId !== stored.projectId) {
397
+ throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${changeSetId}.`);
398
+ }
399
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
400
+ if (evidenceRound !== undefined) {
401
+ throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
402
+ }
403
+ }
404
+ const existing = aggregate.integrationAttempts[stored.id];
405
+ if (existing !== undefined) {
406
+ if (Date.parse(stored.updatedAt) < Date.parse(existing.updatedAt)) {
407
+ throw new StorageRecordError(`Integration Attempt updatedAt cannot move backwards: ${stored.id}.`);
408
+ }
409
+ if (!validIntegrationTransition(existing, stored)) {
410
+ throw new StorageRecordError(`Integration Attempt transition is invalid: ${stored.id}.`);
411
+ }
412
+ }
413
+ this.#mutate((state) => {
414
+ const task = state.tasks[taskId];
415
+ observeTaskRecordId(task, "integrationAttempt", stored.id);
416
+ task.integrationAttempts[stored.id] = stored;
417
+ });
418
+ }
419
+ listIntegrationAttempts(taskId) {
420
+ return values(this.#requireTask(taskId).integrationAttempts, "id");
421
+ }
422
+ getIntegrationAttempt(taskId, integrationId) {
423
+ return optional(this.#state().tasks[taskId]?.integrationAttempts[integrationId]);
424
+ }
197
425
  saveRole(taskId, role) {
198
426
  const aggregate = this.#requireTaskForWrite(taskId);
199
- const stored = identified(role, 2, "name", role.name, "Task Role");
427
+ const stored = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
200
428
  if (stored.taskId !== taskId)
201
429
  throw new StorageRecordError(`Task Role belongs to another Task: ${stored.taskId}`);
202
430
  validateTaskRole(stored);
@@ -208,7 +436,7 @@ export class FileTaskStore {
208
436
  listRoles(taskId) { return values(this.#requireTask(taskId).roles, "name"); }
209
437
  getRole(taskId, name) { return optional(this.#state().tasks[taskId]?.roles[name]); }
210
438
  saveTaskRoleWithSessionSet(role, sessions) {
211
- const storedRole = identified(role, 2, "name", role.name, "Task Role");
439
+ const storedRole = identified(role, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", role.name, "Task Role");
212
440
  validateTaskRole(storedRole);
213
441
  const storedSessions = taskSessions(sessions);
214
442
  assertSessionsMatchRole(storedSessions, storedRole);
@@ -223,40 +451,91 @@ export class FileTaskStore {
223
451
  removeTaskRole(taskId, name) {
224
452
  return this.transaction(() => {
225
453
  const aggregate = this.#requireTask(taskId);
226
- if (aggregate.roleWorkspaces[name] !== undefined) {
227
- throw new StorageRecordError(`Task Role workspace must be cleaned before removing the Role: ${taskId}/${name}`);
228
- }
229
454
  const removed = this.#remove(() => aggregate.roles, name);
230
- this.#mutate(() => { delete aggregate.roleSessionSets[name]; delete aggregate.activeRuns[name]; });
455
+ this.#mutate((state) => {
456
+ delete aggregate.roleSessionSets[name];
457
+ delete aggregate.activeRuns[name];
458
+ delete state.mailboxes[mailboxTargetKey({ kind: "role", taskId, roleName: name })];
459
+ });
231
460
  return removed;
232
461
  });
233
462
  }
234
- saveRoleWorkspace(taskId, workspace) {
463
+ saveManagedWorkspace(workspace) {
464
+ const stored = versioned(workspace, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, "Managed workspace");
465
+ validateManagedWorkspace(stored);
466
+ const taskId = stored.owner.taskId;
235
467
  const aggregate = this.#requireTaskForWrite(taskId);
236
- const stored = clone(workspace);
237
- validateRoleWorkspace(stored);
238
- if (stored.taskId !== taskId) {
239
- throw new StorageRecordError(`RoleWorkspace belongs to another Task: ${stored.taskId}`);
468
+ if (stored.owner.type === "work-item"
469
+ && aggregate.workItems[stored.owner.workItemId] === undefined) {
470
+ throw new StorageRecordError(`Managed workspace WorkItem not found: ${taskId}/${stored.owner.workItemId}.`);
240
471
  }
241
- if (aggregate.roles[stored.roleName] === undefined) {
242
- throw new StorageRecordError(`Task Role not found: ${taskId}/${stored.roleName}`);
472
+ if (stored.owner.type === "review-round"
473
+ && aggregate.reviewRounds[stored.owner.reviewRoundId] === undefined) {
474
+ throw new StorageRecordError(`Managed workspace ReviewRound not found: ${taskId}/${stored.owner.reviewRoundId}.`);
243
475
  }
244
- if (aggregate.task.repositoryId !== stored.repositoryId) {
245
- throw new StorageRecordError(`RoleWorkspace Repository does not match Task: ${taskId}/${stored.roleName}`);
476
+ if (stored.owner.type === "integration-attempt"
477
+ && aggregate.integrationAttempts[stored.owner.integrationAttemptId] === undefined) {
478
+ throw new StorageRecordError(`Managed workspace Integration Attempt not found: ${taskId}/${stored.owner.integrationAttemptId}.`);
479
+ }
480
+ if (stored.owner.type === "execution-lane") {
481
+ const groupOwner = stored.owner;
482
+ if (groupOwner.purpose === "execution") {
483
+ const item = groupOwner.workItemId === undefined
484
+ ? undefined
485
+ : aggregate.workItems[groupOwner.workItemId];
486
+ if (item === undefined
487
+ || workItemExecutionGroupById(item, groupOwner.executionGroupId) === undefined
488
+ || !workItemExecutionGroupById(item, groupOwner.executionGroupId).lanes.some(({ id }) => id === groupOwner.executionLaneId)) {
489
+ throw new StorageRecordError(`Managed execution Lane WorkItem lineage is invalid: ${taskId}/${groupOwner.executionGroupId}/${groupOwner.executionLaneId}.`);
490
+ }
491
+ }
492
+ else {
493
+ const round = groupOwner.reviewRoundId === undefined
494
+ ? undefined
495
+ : aggregate.reviewRounds[groupOwner.reviewRoundId];
496
+ if (round === undefined || round.executionGroup?.id !== groupOwner.executionGroupId
497
+ || !round.executionGroup.lanes.some(({ id }) => id === groupOwner.executionLaneId)) {
498
+ throw new StorageRecordError(`Managed review Lane ReviewRound lineage is invalid: ${taskId}/${groupOwner.executionGroupId}/${groupOwner.executionLaneId}.`);
499
+ }
500
+ }
501
+ }
502
+ assertManagedWorkspaceReferences(aggregate, stored, "Managed workspace");
503
+ const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
504
+ if (stored.entries.some(({ projectId }) => !boundProjects.has(projectId))) {
505
+ throw new StorageRecordError(`Managed workspace Project does not match Task: ${taskId}.`);
246
506
  }
247
507
  this.#mutate((state) => {
248
- state.tasks[taskId].roleWorkspaces[stored.roleName] = stored;
508
+ state.tasks[taskId].managedWorkspaces[managedWorkspaceKey(stored.owner)] = stored;
249
509
  });
250
510
  }
251
- listRoleWorkspaces(taskId) {
252
- return values(this.#requireTask(taskId).roleWorkspaces, "roleName");
511
+ listManagedWorkspaces(taskId) {
512
+ return values(this.#requireTask(taskId).managedWorkspaces, (workspace) => managedWorkspaceKey(workspace.owner));
253
513
  }
254
- getRoleWorkspace(taskId, roleName) {
255
- return optional(this.#state().tasks[taskId]?.roleWorkspaces[roleName]);
514
+ listManagedWorkspace(taskId) {
515
+ return this.listManagedWorkspaces(taskId);
256
516
  }
257
- removeRoleWorkspace(taskId, roleName) {
258
- this.#requireTaskForWrite(taskId);
259
- return this.#remove((state) => state.tasks[taskId].roleWorkspaces, roleName);
517
+ getTaskWorkspace(taskId) {
518
+ return this.getManagedWorkspace({ type: "task", taskId });
519
+ }
520
+ getWorkItemWorkspace(taskId, workItemId) {
521
+ return this.getManagedWorkspace({ type: "work-item", taskId, workItemId });
522
+ }
523
+ getReviewRoundWorkspace(taskId, reviewRoundId) {
524
+ return this.getManagedWorkspace({ type: "review-round", taskId, reviewRoundId });
525
+ }
526
+ getIntegrationWorkspace(taskId, integrationAttemptId) {
527
+ return this.getManagedWorkspace({
528
+ type: "integration-attempt",
529
+ taskId,
530
+ integrationAttemptId
531
+ });
532
+ }
533
+ getManagedWorkspace(owner) {
534
+ return optional(this.#state().tasks[owner.taskId]?.managedWorkspaces[managedWorkspaceKey(owner)]);
535
+ }
536
+ removeManagedWorkspace(owner) {
537
+ this.#requireTaskForWrite(owner.taskId);
538
+ return this.#remove((state) => state.tasks[owner.taskId].managedWorkspaces, managedWorkspaceKey(owner));
260
539
  }
261
540
  getRoleSessionSet(taskId, roleName) {
262
541
  return optional(this.#state().tasks[taskId]?.roleSessionSets[roleName]);
@@ -282,26 +561,131 @@ export class FileTaskStore {
282
561
  const set = this.getRoleSessionSet(taskId, roleName);
283
562
  return set === null ? null : optional(set.sessions[set.activeAgentId]);
284
563
  }
285
- nextWorkItemId(_taskId) { return this.#nextGlobalId("work-item", (state) => allKeys(state, "workItems")); }
564
+ nextWorkItemId(taskId) {
565
+ return this.#nextTaskRecordId(taskId, "workItem");
566
+ }
286
567
  getWorkItem(taskId, id) { return optional(this.#state().tasks[taskId]?.workItems[id]); }
287
- findWorkItem(id) { return findUnique(this.#state(), "workItems", id, "Work item"); }
288
568
  listWorkItems(taskId) { return values(this.#requireTask(taskId).workItems, "id"); }
289
569
  saveWorkItem(taskId, item) {
290
- const stored = identified(item, 1, "id", item.id, "Work item");
570
+ const stored = identified(item, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", item.id, "Work item");
291
571
  if (stored.taskId !== taskId)
292
572
  throw new StorageRecordError(`Work item belongs to another Task: ${stored.taskId}`);
293
- this.#requireTaskForWrite(taskId);
294
- this.#mutate((state) => { state.tasks[taskId].workItems[stored.id] = stored; });
573
+ validateWorkItem(stored);
574
+ const aggregate = this.#requireTaskForWrite(taskId);
575
+ const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
576
+ if (stored.writeProjectIds.some((projectId) => !boundProjects.has(projectId))) {
577
+ throw new StorageRecordError(`Work Item writable Project does not belong to Task: ${stored.id}.`);
578
+ }
579
+ const writableProjects = new Set(stored.writeProjectIds);
580
+ if (stored.baseRefs?.some(({ projectId }) => !boundProjects.has(projectId))) {
581
+ throw new StorageRecordError(`Work Item base-ref Project does not belong to Task: ${stored.id}.`);
582
+ }
583
+ if (stored.baseRefs?.some(({ projectId }) => !writableProjects.has(projectId))) {
584
+ throw new StorageRecordError(`Work Item base-ref Project must be writable: ${stored.id}.`);
585
+ }
586
+ for (const dependencyId of stored.dependsOn) {
587
+ const dependency = this.getWorkItem(taskId, dependencyId);
588
+ if (dependency === null) {
589
+ throw new StorageRecordError(`Work Item dependency not found: ${dependencyId}.`);
590
+ }
591
+ }
592
+ assertAcyclicWorkItems({
593
+ ...aggregate.workItems,
594
+ [stored.id]: stored
595
+ });
596
+ for (const candidate of stored.candidates) {
597
+ assertWorkItemCandidateReferences(aggregate, stored, candidate, "Work Item candidate");
598
+ }
599
+ const existing = this.getWorkItem(taskId, stored.id);
600
+ if (existing !== null && !validWorkItemTransition(existing, stored)) {
601
+ throw new StorageRecordError(`Work Item transition is invalid: ${stored.id}.`);
602
+ }
603
+ this.#mutate((state) => {
604
+ const task = state.tasks[taskId];
605
+ observeTaskRecordId(task, "workItem", stored.id);
606
+ task.workItems[stored.id] = stored;
607
+ });
608
+ }
609
+ nextAgentRunId(taskId) {
610
+ return this.#nextTaskRecordId(taskId, "agentRun");
611
+ }
612
+ peekNextAgentRunId(taskId) {
613
+ return this.#peekTaskRecordId(taskId, "agentRun");
295
614
  }
296
- nextAgentRunId(_taskId) { return this.#nextGlobalId("agent-run", (state) => allKeys(state, "agentRuns")); }
297
615
  getAgentRun(taskId, id) { return optional(this.#state().tasks[taskId]?.agentRuns[id]); }
298
- findAgentRun(id) { return findUnique(this.#state(), "agentRuns", id, "Agent run"); }
299
616
  listAgentRuns(taskId) { return values(this.#requireTask(taskId).agentRuns, "id"); }
300
617
  saveAgentRun(run) {
301
- const stored = identified(run, 1, "id", run.id, "Agent run");
618
+ const stored = identified(run, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", run.id, "Agent run");
302
619
  validateAgentRun(stored);
303
- this.#requireTaskForWrite(stored.taskId);
304
- this.#mutate((state) => { state.tasks[stored.taskId].agentRuns[stored.id] = stored; });
620
+ const aggregate = this.#requireTaskForWrite(stored.taskId);
621
+ if (stored.purpose === "review"
622
+ && aggregate.task.projectBindings.length > 0
623
+ && stored.workspace === undefined) {
624
+ throw new StorageRecordError(`A Project-backed review Agent run requires its ReviewRound workspace: ${stored.id}.`);
625
+ }
626
+ if (stored.reviewRoundId !== undefined) {
627
+ const round = aggregate.reviewRounds[stored.reviewRoundId];
628
+ if (round === undefined) {
629
+ throw new StorageRecordError(`Agent run ReviewRound not found: ${stored.reviewRoundId}.`);
630
+ }
631
+ const laneRole = round.executionGroup?.lanes.find(({ id }) => id === stored.executionLaneId)?.roleName;
632
+ if (round.workItemId !== stored.workItemId
633
+ || (round.reviewerRoleName !== stored.roleName && laneRole !== stored.roleName)) {
634
+ throw new StorageRecordError(`Agent run does not match ReviewRound: ${stored.id}.`);
635
+ }
636
+ }
637
+ assertAgentRunExecutionReferences(aggregate, stored);
638
+ this.#mutate((state) => {
639
+ const task = state.tasks[stored.taskId];
640
+ observeTaskRecordId(task, "agentRun", stored.id);
641
+ task.agentRuns[stored.id] = stored;
642
+ });
643
+ }
644
+ nextReviewRoundId(taskId) {
645
+ return this.#nextTaskRecordId(taskId, "reviewRound");
646
+ }
647
+ getReviewRound(taskId, reviewRoundId) {
648
+ return optional(this.#state().tasks[taskId]?.reviewRounds[reviewRoundId]);
649
+ }
650
+ listReviewRounds(taskId) {
651
+ return values(this.#requireTask(taskId).reviewRounds, "id");
652
+ }
653
+ saveReviewRound(taskId, round) {
654
+ const stored = identified(round, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, "id", round.id, "ReviewRound");
655
+ validateReviewRound(stored);
656
+ if (stored.taskId !== taskId) {
657
+ throw new StorageRecordError(`ReviewRound belongs to another Task: ${stored.taskId}.`);
658
+ }
659
+ const aggregate = this.#requireTaskForWrite(taskId);
660
+ const item = aggregate.workItems[stored.workItemId];
661
+ if (item === undefined) {
662
+ throw new StorageRecordError(`ReviewRound Work Item not found: ${stored.workItemId}.`);
663
+ }
664
+ const candidate = item.candidates.find(({ id }) => id === stored.candidateId);
665
+ if (candidate === undefined) {
666
+ throw new StorageRecordError(`ReviewRound Candidate not found: ${stored.candidateId}.`);
667
+ }
668
+ assertWorkItemCandidateReferences(aggregate, item, candidate, `ReviewRound candidate ${stored.id}`);
669
+ if ((stored.scope ?? "work-item") === "task"
670
+ && !sameTaskFinalReviewContract(stored.taskFinalReviewContract, candidate.taskFinalReviewContract)) {
671
+ throw new StorageRecordError(`Task ReviewRound contract does not match its Candidate: ${stored.id}.`);
672
+ }
673
+ if (stored.reviewerRunId !== undefined) {
674
+ const reviewerRun = aggregate.agentRuns[stored.reviewerRunId];
675
+ if (reviewerRun !== undefined
676
+ && (reviewerRun.reviewRoundId !== stored.id || reviewerRun.purpose !== "review")) {
677
+ throw new StorageRecordError(`ReviewRound Reviewer Run is invalid: ${stored.reviewerRunId}.`);
678
+ }
679
+ }
680
+ const existing = aggregate.reviewRounds[stored.id];
681
+ if (existing !== undefined && !validReviewRoundTransition(existing, stored)) {
682
+ throw new StorageRecordError(`ReviewRound transition is invalid: ${stored.id}.`);
683
+ }
684
+ this.#mutate((state) => {
685
+ const task = state.tasks[taskId];
686
+ observeTaskRecordId(task, "reviewRound", stored.id);
687
+ task.reviewRounds[stored.id] = stored;
688
+ });
305
689
  }
306
690
  getActiveAgentRun(taskId, roleName) {
307
691
  const aggregate = this.#state().tasks[taskId];
@@ -314,6 +698,10 @@ export class FileTaskStore {
314
698
  return clone(run);
315
699
  }
316
700
  saveActiveAgentRun(run) {
701
+ if (run.executionGroupId !== undefined && run.executionLaneId !== undefined) {
702
+ this.saveActiveExecutionLaneRun(run);
703
+ return;
704
+ }
317
705
  if (run.status !== "active")
318
706
  throw new StorageRecordError(`Active Agent run must have active status: ${run.id}`);
319
707
  this.transaction((store) => {
@@ -321,36 +709,135 @@ export class FileTaskStore {
321
709
  if (current !== null && current.id !== run.id) {
322
710
  throw new StorageRecordError(`Role already has an active Agent run: ${run.taskId}/${run.roleName}`);
323
711
  }
712
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
713
+ if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
714
+ throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
715
+ }
324
716
  store.saveAgentRun(run);
325
717
  this.#mutate((state) => {
326
- state.tasks[run.taskId].activeRuns[run.roleName] = { schemaVersion: 1, runId: run.id };
718
+ state.tasks[run.taskId].activeRuns[run.roleName] = {
719
+ schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
720
+ runId: run.id
721
+ };
327
722
  });
328
723
  });
329
724
  }
330
725
  clearActiveAgentRun(taskId, roleName) {
331
- this.#mutate((state) => { const task = state.tasks[taskId]; if (task !== undefined)
332
- delete task.activeRuns[roleName]; });
726
+ this.#mutate((state) => {
727
+ const task = state.tasks[taskId];
728
+ if (task === undefined)
729
+ return;
730
+ const rolePointer = task.activeRuns[roleName];
731
+ delete task.activeRuns[roleName];
732
+ // Older Controller paths only know the Role key. When that key points
733
+ // at a lane-backed Run, remove the matching lane pointer too; preserve
734
+ // every other lane for the same Role in a multi-lane group.
735
+ if (rolePointer === undefined)
736
+ return;
737
+ for (const [key, pointer] of Object.entries(task.activeRuns)) {
738
+ if (executionLaneActiveRunKeyParts(key) !== null && pointer.runId === rolePointer.runId) {
739
+ delete task.activeRuns[key];
740
+ }
741
+ }
742
+ });
743
+ }
744
+ getActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
745
+ const aggregate = this.#state().tasks[taskId];
746
+ const key = executionLaneActiveRunKey(executionGroupId, executionLaneId);
747
+ const pointer = aggregate?.activeRuns[key];
748
+ if (aggregate === undefined || pointer === undefined)
749
+ return null;
750
+ const run = aggregate.agentRuns[pointer.runId];
751
+ if (run === undefined) {
752
+ throw new StorageRecordError(`Active Agent run pointer is dangling: ${taskId}/${key}`);
753
+ }
754
+ if (run.executionGroupId !== executionGroupId
755
+ || run.executionLaneId !== executionLaneId
756
+ || run.status !== "active") {
757
+ throw new StorageRecordError(`Active Agent run pointer is invalid: ${taskId}/${key}`);
758
+ }
759
+ return clone(run);
760
+ }
761
+ saveActiveExecutionLaneRun(run) {
762
+ if (run.status !== "active") {
763
+ throw new StorageRecordError(`Active Agent run must have active status: ${run.id}`);
764
+ }
765
+ if (run.executionGroupId === undefined || run.executionLaneId === undefined) {
766
+ throw new StorageRecordError(`Lane active Agent run requires execution lineage: ${run.id}`);
767
+ }
768
+ this.transaction((store) => {
769
+ const key = executionLaneActiveRunKey(run.executionGroupId, run.executionLaneId);
770
+ const current = store.getActiveExecutionLaneRun(run.taskId, run.executionGroupId, run.executionLaneId);
771
+ if (current !== null && current.id !== run.id) {
772
+ throw new StorageRecordError(`Execution Lane already has an active Agent run: ${run.taskId}/${key}`);
773
+ }
774
+ const sessions = store.getTaskRoleSessionSet(run.taskId, run.roleName);
775
+ if (sessions !== null && sessions.inFlight !== null && sessions.inFlight.runId !== run.id) {
776
+ throw new StorageRecordError(`Role still has an in-flight Turn: ${run.taskId}/${run.roleName}/${sessions.inFlight.runId}`);
777
+ }
778
+ store.saveAgentRun(run);
779
+ this.#mutate((state) => {
780
+ const task = state.tasks[run.taskId];
781
+ task.activeRuns[key] = {
782
+ schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
783
+ runId: run.id
784
+ };
785
+ // Preserve the legacy role pointer for the single-lane delivery path.
786
+ // A second lane for the same Role keeps the first pointer unchanged;
787
+ // lane-aware readers use the exact key above.
788
+ if (task.activeRuns[run.roleName] === undefined) {
789
+ task.activeRuns[run.roleName] = {
790
+ schemaVersion: CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION,
791
+ runId: run.id
792
+ };
793
+ }
794
+ });
795
+ });
796
+ }
797
+ clearActiveExecutionLaneRun(taskId, executionGroupId, executionLaneId) {
798
+ this.#mutate((state) => {
799
+ const task = state.tasks[taskId];
800
+ if (task === undefined)
801
+ return;
802
+ const key = executionLaneActiveRunKey(executionGroupId, executionLaneId);
803
+ const pointer = task.activeRuns[key];
804
+ delete task.activeRuns[key];
805
+ const runId = pointer?.runId;
806
+ for (const [roleName, rolePointer] of Object.entries(task.activeRuns)) {
807
+ if (executionLaneActiveRunKeyParts(roleName) !== null)
808
+ continue;
809
+ const roleRun = task.agentRuns[rolePointer.runId];
810
+ const matches = runId !== undefined
811
+ ? rolePointer.runId === runId
812
+ : roleRun?.executionGroupId === executionGroupId
813
+ && roleRun.executionLaneId === executionLaneId;
814
+ if (matches) {
815
+ delete task.activeRuns[roleName];
816
+ }
817
+ }
818
+ });
819
+ }
820
+ nextMessageId(taskId) {
821
+ return this.#nextTaskRecordId(taskId, "message");
333
822
  }
334
- nextMessageId(_taskId) { return this.#nextGlobalId("message", (state) => allKeys(state, "messages")); }
335
823
  saveMessage(taskId, message) {
336
824
  validateTaskMessage(message);
825
+ if (message.taskId !== taskId) {
826
+ throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
827
+ }
337
828
  this.#saveTaskRecord(taskId, "messages", message, "Message");
338
829
  }
339
830
  listMessages(taskId) { return values(this.#requireTask(taskId).messages, "id"); }
340
- nextInputRequestId(_taskId) {
341
- return this.#nextGlobalId("input", (state) => allKeys(state, "inputRequests"));
831
+ nextInputRequestId(taskId) {
832
+ return this.#nextTaskRecordId(taskId, "inputRequest");
342
833
  }
343
834
  saveInputRequest(taskId, request) {
344
- const stored = validateInputRequest(request);
835
+ const stored = validateInputRequest(versioned(request, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, "Input request"));
345
836
  if (stored.taskId !== taskId) {
346
837
  throw new StorageRecordError(`Input request belongs to another Task: ${stored.taskId}`);
347
838
  }
348
839
  this.#mutate((state) => {
349
840
  const aggregate = requireTaskFromState(state, taskId);
350
- const owner = taskRecordOwner(state, "inputRequests", stored.id);
351
- if (owner !== null && owner !== taskId) {
352
- throw new StorageRecordError(`Input request already exists in another Task: ${stored.id}`);
353
- }
354
841
  const existing = aggregate.inputRequests[stored.id];
355
842
  if (existing === undefined && stored.status !== "open") {
356
843
  throw new StorageRecordError(`Input request must start open: ${stored.id}`);
@@ -358,25 +845,25 @@ export class FileTaskStore {
358
845
  if (existing !== undefined && !isValidInputRequestTransition(existing, stored)) {
359
846
  throw new StorageRecordError(`Input request cannot be overwritten: ${stored.id}`);
360
847
  }
848
+ observeTaskRecordId(aggregate, "inputRequest", stored.id);
361
849
  aggregate.inputRequests[stored.id] = stored;
362
850
  });
363
851
  }
364
852
  getInputRequest(taskId, requestId) {
365
853
  return optional(this.#state().tasks[taskId]?.inputRequests[requestId]);
366
854
  }
367
- findInputRequest(requestId) {
368
- return findUnique(this.#state(), "inputRequests", requestId, "Input request");
369
- }
370
855
  listInputRequests(taskId) {
371
856
  return values(this.#requireTask(taskId).inputRequests, "id");
372
857
  }
373
858
  listAllInputRequests() {
374
859
  return Object.values(this.#state().tasks)
375
860
  .flatMap((aggregate) => Object.values(aggregate.inputRequests).map(clone))
376
- .sort((left, right) => numericCompare(left.id, right.id));
861
+ .sort((left, right) => (left.createdAt.localeCompare(right.createdAt)
862
+ || left.taskId.localeCompare(right.taskId)
863
+ || numericCompare(left.id, right.id)));
377
864
  }
378
- nextDecisionId(_taskId) {
379
- return this.#nextGlobalId("decision", (state) => allKeys(state, "decisions"));
865
+ nextDecisionId(taskId) {
866
+ return this.#nextTaskRecordId(taskId, "decision");
380
867
  }
381
868
  saveDecision(taskId, decision) {
382
869
  const stored = storedDecision(decision);
@@ -385,10 +872,6 @@ export class FileTaskStore {
385
872
  }
386
873
  this.#mutate((state) => {
387
874
  const aggregate = requireTaskFromState(state, taskId);
388
- const owner = taskRecordOwner(state, "decisions", stored.id);
389
- if (owner !== null && owner !== taskId) {
390
- throw new StorageRecordError(`Decision already exists in another Task: ${stored.id}`);
391
- }
392
875
  const existing = aggregate.decisions[stored.id];
393
876
  if (existing === undefined && stored.status !== "active") {
394
877
  throw new StorageRecordError(`Decision must start active: ${stored.id}`);
@@ -396,6 +879,7 @@ export class FileTaskStore {
396
879
  if (existing !== undefined && !isValidDecisionSupersession(existing, stored)) {
397
880
  throw new StorageRecordError(`Decision cannot be overwritten: ${stored.id}`);
398
881
  }
882
+ observeTaskRecordId(aggregate, "decision", stored.id);
399
883
  aggregate.decisions[stored.id] = stored;
400
884
  });
401
885
  }
@@ -405,8 +889,8 @@ export class FileTaskStore {
405
889
  getDecision(taskId, decisionId) {
406
890
  return this.#requireTask(taskId).decisions[decisionId] ?? null;
407
891
  }
408
- nextMilestoneId(_taskId) {
409
- return this.#nextGlobalId("milestone", (state) => allKeys(state, "milestones"));
892
+ nextMilestoneId(taskId) {
893
+ return this.#nextTaskRecordId(taskId, "milestone");
410
894
  }
411
895
  saveMilestone(taskId, milestone) {
412
896
  const stored = storedMilestone(milestone);
@@ -415,9 +899,10 @@ export class FileTaskStore {
415
899
  }
416
900
  this.#mutate((state) => {
417
901
  const aggregate = requireTaskFromState(state, taskId);
418
- if (taskRecordOwner(state, "milestones", stored.id) !== null) {
419
- throw new StorageRecordError(`Milestone already exists: ${stored.id}`);
902
+ if (aggregate.milestones[stored.id] !== undefined) {
903
+ throw new StorageRecordError(`Milestone already exists: ${taskId}/${stored.id}`);
420
904
  }
905
+ observeTaskRecordId(aggregate, "milestone", stored.id);
421
906
  aggregate.milestones[stored.id] = stored;
422
907
  });
423
908
  }
@@ -427,24 +912,91 @@ export class FileTaskStore {
427
912
  getMilestone(taskId, milestoneId) {
428
913
  return this.#requireTask(taskId).milestones[milestoneId] ?? null;
429
914
  }
430
- nextEventId(_taskId) { return this.#nextGlobalId("event", (state) => allKeys(state, "events")); }
915
+ nextEventId(taskId) {
916
+ return this.#nextTaskRecordId(taskId, "event");
917
+ }
431
918
  saveEvent(taskId, event) {
432
919
  const stored = storedTaskEvent(event);
920
+ if (stored.taskId !== taskId) {
921
+ throw new StorageRecordError(`Task event belongs to another Task: ${stored.taskId}`);
922
+ }
433
923
  this.#mutate((state) => {
434
924
  const aggregate = requireTaskFromState(state, taskId);
435
- if (taskRecordOwner(state, "events", stored.id) !== null) {
436
- throw new StorageRecordError(`Task event already exists: ${stored.id}`);
925
+ if (aggregate.events[stored.id] !== undefined) {
926
+ throw new StorageRecordError(`Task event already exists: ${taskId}/${stored.id}`);
437
927
  }
928
+ observeTaskRecordId(aggregate, "event", stored.id);
438
929
  aggregate.events[stored.id] = stored;
439
930
  });
440
931
  }
441
932
  listEvents(taskId) { return values(this.#requireTask(taskId).events, "id"); }
442
- getPendingWakeup(taskId) { return optional(this.#state().tasks[taskId]?.pendingWakeup ?? undefined); }
933
+ getWorkMailbox(target) {
934
+ return optional(this.#state().mailboxes[mailboxTargetKey(target)]);
935
+ }
936
+ listWorkMailboxes() {
937
+ return Object.entries(this.#state().mailboxes)
938
+ .sort(([left], [right]) => left.localeCompare(right))
939
+ .map(([, mailbox]) => clone(mailbox));
940
+ }
941
+ saveWorkMailbox(value) {
942
+ let mailbox;
943
+ try {
944
+ mailbox = validateWorkMailbox(versioned(value, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "WorkMailbox"));
945
+ }
946
+ catch (error) {
947
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
948
+ }
949
+ this.#mutate((state) => {
950
+ validateMailboxReferences(state, mailbox);
951
+ state.mailboxes[mailboxTargetKey(mailbox.target)] = clone(mailbox);
952
+ });
953
+ }
954
+ removeWorkMailbox(target) {
955
+ return this.#remove((state) => state.mailboxes, mailboxTargetKey(target));
956
+ }
957
+ getPendingWakeup(taskId) {
958
+ return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));
959
+ }
443
960
  listPendingWakeups() {
444
- return Object.values(this.#state().tasks).flatMap((task) => task.pendingWakeup === null ? [] : [clone(task.pendingWakeup)]).sort((a, b) => numericCompare(a.taskId, b.taskId));
961
+ return this.listWorkMailboxes()
962
+ .flatMap((mailbox) => {
963
+ const wakeup = pendingWakeupProjection(mailbox);
964
+ return wakeup === null ? [] : [wakeup];
965
+ })
966
+ .sort((a, b) => numericCompare(a.taskId, b.taskId));
967
+ }
968
+ savePendingWakeup(value) {
969
+ const wakeup = identified(value, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, "taskId", value.taskId, "Pending wakeup");
970
+ const target = { kind: "role", taskId: wakeup.taskId, roleName: "leader" };
971
+ this.transaction(() => {
972
+ const existing = this.getWorkMailbox(target);
973
+ if (existing !== null && existing.pending !== null
974
+ && wakeup.requestCount <= existing.pending.requestCount) {
975
+ throw new StorageRecordError(`Pending wakeup is stale: ${wakeup.taskId}`);
976
+ }
977
+ const fromSequence = existing?.pending?.fromSequence ?? existing?.nextSequence ?? 1;
978
+ const toSequence = fromSequence + wakeup.requestCount - 1;
979
+ this.saveWorkMailbox({
980
+ schemaVersion: CURRENT_WORK_MAILBOX_SCHEMA_VERSION,
981
+ target,
982
+ nextSequence: Math.max(existing?.nextSequence ?? 1, toSequence + 1),
983
+ processing: existing?.processing ?? null,
984
+ pending: {
985
+ ...existing?.pending,
986
+ fromSequence,
987
+ toSequence,
988
+ reasons: [...wakeup.reasons],
989
+ refs: existing?.pending?.refs ?? [],
990
+ requestCount: wakeup.requestCount,
991
+ firstQueuedAt: wakeup.firstRequestedAt,
992
+ lastQueuedAt: wakeup.lastRequestedAt
993
+ }
994
+ });
995
+ });
996
+ }
997
+ clearPendingWakeup(taskId) {
998
+ this.removeWorkMailbox({ kind: "role", taskId, roleName: "leader" });
445
999
  }
446
- savePendingWakeup(value) { this.#saveSingleton(value.taskId, "pendingWakeup", value, "Pending wakeup"); }
447
- clearPendingWakeup(taskId) { this.#clearSingleton(taskId, "pendingWakeup"); }
448
1000
  getLeaderFailure(taskId) { return optional(this.#state().tasks[taskId]?.leaderFailure ?? undefined); }
449
1001
  saveLeaderFailure(value) { this.#saveSingleton(value.taskId, "leaderFailure", value, "Leader failure"); }
450
1002
  clearLeaderFailure(taskId) { this.#clearSingleton(taskId, "leaderFailure"); }
@@ -452,7 +1004,10 @@ export class FileTaskStore {
452
1004
  saveOperatorNotification(value) { this.#saveSingleton(value.taskId, "operatorNotification", value, "Operator notification"); }
453
1005
  clearOperatorNotification(taskId) { this.#clearSingleton(taskId, "operatorNotification"); }
454
1006
  #saveSingleton(taskId, key, value, label) {
455
- const stored = identified(value, 1, "taskId", taskId, label);
1007
+ const schemaVersion = key === "leaderFailure"
1008
+ ? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
1009
+ : CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
1010
+ const stored = identified(value, schemaVersion, "taskId", taskId, label);
456
1011
  this.#requireTaskForWrite(taskId);
457
1012
  this.#mutate((state) => { state.tasks[taskId][key] = stored; });
458
1013
  }
@@ -461,12 +1016,13 @@ export class FileTaskStore {
461
1016
  state.tasks[key][field] = null; });
462
1017
  }
463
1018
  #saveTaskRecord(taskId, key, value, label) {
464
- const record = versioned(value, 1, label);
1019
+ const record = versioned(value, CURRENT_MESSAGE_SCHEMA_VERSION, label);
465
1020
  this.#requireTaskForWrite(taskId);
466
1021
  this.#mutate((state) => {
467
- if (taskRecordOwner(state, key, record.id) !== null) {
468
- throw new StorageRecordError(`${label} already exists: ${record.id}`);
1022
+ if (state.tasks[taskId][key][record.id] !== undefined) {
1023
+ throw new StorageRecordError(`${label} already exists: ${taskId}/${record.id}`);
469
1024
  }
1025
+ observeTaskRecordId(state.tasks[taskId], "message", record.id);
470
1026
  state.tasks[taskId][key][record.id] = clone(value);
471
1027
  });
472
1028
  }
@@ -477,7 +1033,7 @@ export class FileTaskStore {
477
1033
  return aggregate;
478
1034
  }
479
1035
  #requireTaskForWrite(taskId) { return this.#requireTask(taskId); }
480
- #state() { return this.#transaction?.state ?? this.#readState(); }
1036
+ #state() { return this.#transaction?.state ?? this.#readCachedState(); }
481
1037
  #mutate(execute) { this.#mutateResult((state) => { execute(state); }); }
482
1038
  #mutateResult(execute) {
483
1039
  if (this.#transaction !== null) {
@@ -486,10 +1042,16 @@ export class FileTaskStore {
486
1042
  return result;
487
1043
  }
488
1044
  return this.#withWriteLock(() => {
489
- const state = this.#readState();
490
- const result = execute(state);
491
- this.#commit(state, state.revision);
492
- return result;
1045
+ const state = this.#readCachedState();
1046
+ try {
1047
+ const result = execute(state);
1048
+ this.#commit(state, state.revision);
1049
+ return result;
1050
+ }
1051
+ catch (error) {
1052
+ this.#readCache = null;
1053
+ throw error;
1054
+ }
493
1055
  });
494
1056
  }
495
1057
  #remove(select, id) {
@@ -509,14 +1071,49 @@ export class FileTaskStore {
509
1071
  }, 0);
510
1072
  return `${prefix}-${maximum + 1}`;
511
1073
  }
1074
+ #nextTaskRecordId(taskId, kind) {
1075
+ return this.#mutateResult((state) => {
1076
+ const aggregate = requireTaskFromState(state, taskId);
1077
+ const next = nextTaskRecordSequence(aggregate, taskId, kind);
1078
+ aggregate.idHighWaterMarks[kind] = next;
1079
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${next}`;
1080
+ });
1081
+ }
1082
+ #peekTaskRecordId(taskId, kind) {
1083
+ const aggregate = this.#requireTask(taskId);
1084
+ return `${TASK_RECORD_ID_PREFIXES[kind]}-${nextTaskRecordSequence(aggregate, taskId, kind)}`;
1085
+ }
512
1086
  #readState() {
513
- requireStorageSchema(this.rootDir);
1087
+ this.#requireReadableSchema();
514
1088
  const path = join(this.rootDir, STORAGE_STATE_FILE);
515
1089
  if (!existsSync(path))
516
1090
  return emptyState();
517
- return parseState(readFileSync(path, "utf8"));
1091
+ return this.#parseState(readFileSync(path, "utf8"));
1092
+ }
1093
+ #readCachedState() {
1094
+ this.#requireReadableSchema();
1095
+ const path = join(this.rootDir, STORAGE_STATE_FILE);
1096
+ if (!existsSync(path)) {
1097
+ if (this.#readCache?.fingerprint === "missing")
1098
+ return this.#readCache.state;
1099
+ const state = emptyState();
1100
+ this.#readCache = { fingerprint: "missing", state };
1101
+ return state;
1102
+ }
1103
+ const fingerprint = stateFileFingerprint(path);
1104
+ if (this.#readCache?.fingerprint === fingerprint)
1105
+ return this.#readCache.state;
1106
+ const state = this.#parseState(readFileSync(path, "utf8"));
1107
+ this.#readCache = { fingerprint, state };
1108
+ return state;
518
1109
  }
519
1110
  #commit(state, expectedRevision) {
1111
+ // The upgrade admission fence is honored at the single write moment, so both
1112
+ // baseline CLI writers and the Controller (which mutate through this same
1113
+ // store) refuse to persist while an upgrade owns the Home. Reads and
1114
+ // read-only transactions never reach here, and the fencing process itself is
1115
+ // exempt so it can re-pin the revision under the lock.
1116
+ assertHomeWritable(this.rootDir);
520
1117
  const current = this.#readState();
521
1118
  if (current.revision !== expectedRevision) {
522
1119
  throw new StorageConflictError(`Storage changed concurrently (expected revision ${expectedRevision}, found ${current.revision}).`);
@@ -525,6 +1122,19 @@ export class FileTaskStore {
525
1122
  const content = `${JSON.stringify(state, null, 2)}\n`;
526
1123
  parseState(content);
527
1124
  writeTextFileAtomically(join(this.rootDir, STORAGE_STATE_FILE), content);
1125
+ if (this.#normalizeState !== undefined) {
1126
+ // A compatible read never rewrites the Home. Its first actual mutation
1127
+ // emits current-only state, then advances the durable manifest to the
1128
+ // same current family versions under the existing storage write lock.
1129
+ // A crash between the two atomic files is detected as manifest/state
1130
+ // inconsistency on the next open; it is never silently accepted.
1131
+ writeCurrentStorageManifest(this.rootDir);
1132
+ this.#normalizeState = undefined;
1133
+ }
1134
+ this.#readCache = null;
1135
+ }
1136
+ #parseState(raw) {
1137
+ return parseState(this.#normalizeState?.(raw) ?? raw);
528
1138
  }
529
1139
  #withWriteLock(execute) {
530
1140
  const release = acquireStorageLock(this.rootDir);
@@ -535,6 +1145,16 @@ export class FileTaskStore {
535
1145
  release();
536
1146
  }
537
1147
  }
1148
+ #requireReadableSchema() {
1149
+ if (this.#normalizeState === undefined)
1150
+ requireStorageSchema(this.rootDir);
1151
+ else
1152
+ requireCompatibleStorageSchema(this.rootDir);
1153
+ }
1154
+ }
1155
+ function stateFileFingerprint(path) {
1156
+ const stat = statSync(path, { bigint: true });
1157
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
538
1158
  }
539
1159
  export class StorageRecordError extends Error {
540
1160
  constructor(message) { super(message); this.name = "StorageRecordError"; }
@@ -549,29 +1169,74 @@ export function resolveYuiHome(env) {
549
1169
  }
550
1170
  export function ensureYuiHome(rootDir) { mkdirSync(rootDir, { recursive: true, mode: 0o700 }); }
551
1171
  function emptyState() {
552
- return { schemaVersion: 1, revision: 0, config: { schemaVersion: 1 }, configuredAgents: {}, repositories: {}, globalRoles: {}, globalRoleSessionSets: {}, tasks: {} };
1172
+ return {
1173
+ schemaVersion: CURRENT_STORAGE_STATE_SCHEMA_VERSION,
1174
+ revision: 0,
1175
+ config: { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION },
1176
+ configuredAgents: {},
1177
+ projects: {},
1178
+ agentProfiles: {},
1179
+ globalRoles: {},
1180
+ globalRoleSessionSets: {},
1181
+ tasks: {},
1182
+ mailboxes: {}
1183
+ };
553
1184
  }
554
1185
  function emptyStoredTask(task) {
555
1186
  return {
556
- schemaVersion: 1,
1187
+ schemaVersion: CURRENT_STORED_TASK_SCHEMA_VERSION,
557
1188
  task,
1189
+ idHighWaterMarks: emptyTaskIdHighWaterMarks(),
558
1190
  brief: null,
1191
+ changeSets: {},
1192
+ integrationAttempts: {},
559
1193
  roles: {},
560
- roleWorkspaces: {},
1194
+ managedWorkspaces: {},
561
1195
  roleSessionSets: {},
562
1196
  workItems: {},
563
1197
  agentRuns: {},
1198
+ reviewRounds: {},
564
1199
  activeRuns: {},
565
1200
  messages: {},
566
1201
  inputRequests: {},
567
1202
  decisions: {},
568
1203
  milestones: {},
569
1204
  events: {},
570
- pendingWakeup: null,
571
1205
  leaderFailure: null,
572
1206
  operatorNotification: null
573
1207
  };
574
1208
  }
1209
+ function emptyTaskIdHighWaterMarks() {
1210
+ return {
1211
+ workItem: 0,
1212
+ agentRun: 0,
1213
+ reviewRound: 0,
1214
+ changeSet: 0,
1215
+ integrationAttempt: 0,
1216
+ message: 0,
1217
+ inputRequest: 0,
1218
+ decision: 0,
1219
+ milestone: 0,
1220
+ event: 0
1221
+ };
1222
+ }
1223
+ function nextTaskRecordSequence(aggregate, taskId, kind) {
1224
+ const next = aggregate.idHighWaterMarks[kind] + 1;
1225
+ if (!Number.isSafeInteger(next)) {
1226
+ throw new StorageRecordError(`Task ${kind} id space is exhausted: ${taskId}.`);
1227
+ }
1228
+ return next;
1229
+ }
1230
+ function validateTaskIdHighWaterMarks(value, taskId) {
1231
+ const marks = object(value, `Task id high-water marks ${taskId}`);
1232
+ const kinds = Object.keys(TASK_RECORD_ID_PREFIXES);
1233
+ exact(marks, kinds, `Task id high-water marks ${taskId}`);
1234
+ for (const kind of kinds) {
1235
+ if (!Number.isSafeInteger(marks[kind]) || marks[kind] < 0) {
1236
+ throw new StorageRecordError(`Task id high-water mark is invalid: ${taskId}/${kind}.`);
1237
+ }
1238
+ }
1239
+ }
575
1240
  function parseState(raw) {
576
1241
  let parsed;
577
1242
  try {
@@ -581,30 +1246,65 @@ function parseState(raw) {
581
1246
  throw new StorageRecordError(`Invalid ${STORAGE_STATE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
582
1247
  }
583
1248
  const state = object(parsed, "Storage state");
584
- exact(state, ["schemaVersion", "revision", "config", "configuredAgents", "repositories", "globalRoles", "globalRoleSessionSets", "tasks"], "Storage state");
585
- if (state.schemaVersion !== 1 || !Number.isInteger(state.revision) || state.revision < 0)
1249
+ exact(state, [
1250
+ "schemaVersion",
1251
+ "revision",
1252
+ "config",
1253
+ "configuredAgents",
1254
+ "projects",
1255
+ "agentProfiles",
1256
+ "globalRoles",
1257
+ "globalRoleSessionSets",
1258
+ "tasks",
1259
+ "mailboxes"
1260
+ ], "Storage state");
1261
+ if (state.schemaVersion !== CURRENT_STORAGE_STATE_SCHEMA_VERSION || !Number.isInteger(state.revision) || state.revision < 0)
586
1262
  throw new StorageRecordError("Storage state schemaVersion/revision is invalid.");
587
1263
  const result = clone(state);
588
- result.config = versioned(result.config, 1, "Yui config");
1264
+ result.config = versioned(result.config, CURRENT_CONFIG_SCHEMA_VERSION, "Yui config");
589
1265
  validateYuiConfig(result.config);
590
1266
  parseMap(result.configuredAgents, (value, key) => {
591
- const agent = identified(value, 2, "id", key, "Configured Agent");
1267
+ const agent = identified(value, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, "id", key, "Configured Agent");
592
1268
  validateConfiguredAgent(agent);
593
1269
  return agent;
594
1270
  }, "configuredAgents");
595
- parseMap(result.repositories, (value, key) => {
596
- const repository = identified(value, 1, "id", key, "Repository");
597
- validateRepository(repository);
598
- return repository;
599
- }, "repositories");
1271
+ parseMap(result.projects, (value, key) => {
1272
+ const project = identified(value, CURRENT_PROJECT_SCHEMA_VERSION, "id", key, "Project");
1273
+ validateProject(project);
1274
+ return project;
1275
+ }, "projects");
1276
+ try {
1277
+ assertProjectCatalog(Object.values(result.projects));
1278
+ }
1279
+ catch (error) {
1280
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
1281
+ }
1282
+ parseMap(result.agentProfiles, (value, key) => {
1283
+ const profile = identified(value, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", key, "Agent Profile");
1284
+ validateAgentProfile(profile);
1285
+ return profile;
1286
+ }, "agentProfiles");
600
1287
  parseMap(result.globalRoles, (value, key) => {
601
- const role = identified(value, 2, "name", key, "Global Role");
1288
+ const role = identified(value, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, "name", key, "Global Role");
602
1289
  validateGlobalRole(role);
603
1290
  return role;
604
1291
  }, "globalRoles");
605
1292
  parseMap(result.globalRoleSessionSets, (value, key) => { const set = globalSessions(value); if (set.owner.roleName !== key)
606
1293
  throw new StorageRecordError(`Global Role session set identity is inconsistent: ${key}`); return set; }, "globalRoleSessionSets");
607
1294
  parseMap(result.tasks, (value, key) => parseStoredTask(value, key), "tasks");
1295
+ parseMap(result.mailboxes, (value, key) => {
1296
+ let mailbox;
1297
+ try {
1298
+ mailbox = validateWorkMailbox(versioned(value, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "WorkMailbox"));
1299
+ }
1300
+ catch (error) {
1301
+ throw new StorageRecordError(error instanceof Error ? error.message : String(error));
1302
+ }
1303
+ if (mailboxTargetKey(mailbox.target) !== key) {
1304
+ throw new StorageRecordError(`WorkMailbox identity is inconsistent: ${key}`);
1305
+ }
1306
+ return mailbox;
1307
+ }, "mailboxes");
608
1308
  for (const [name, role] of Object.entries(result.globalRoles)) {
609
1309
  const sessions = result.globalRoleSessionSets[name];
610
1310
  if (sessions !== undefined)
@@ -616,78 +1316,183 @@ function parseState(raw) {
616
1316
  }
617
1317
  }
618
1318
  for (const aggregate of Object.values(result.tasks)) {
619
- if (aggregate.task.repositoryId !== undefined
620
- && result.repositories[aggregate.task.repositoryId] === undefined) {
621
- throw new StorageRecordError(`Task Repository not found: ${aggregate.task.id}/${aggregate.task.repositoryId}`);
1319
+ for (const binding of aggregate.task.projectBindings) {
1320
+ if (result.projects[binding.projectId] === undefined) {
1321
+ throw new StorageRecordError(`Task Project not found: ${aggregate.task.id}/${binding.projectId}`);
1322
+ }
622
1323
  }
623
1324
  for (const [name, role] of Object.entries(aggregate.roles)) {
624
1325
  const sessions = aggregate.roleSessionSets[name];
625
1326
  if (sessions !== undefined)
626
1327
  assertSessionsMatchRole(sessions, role);
627
- const workspace = aggregate.roleWorkspaces[name];
628
- if (workspace !== undefined && workspace.path !== role.workspace) {
629
- throw new StorageRecordError(`Task Role workspace path is inconsistent: ${aggregate.task.id}/${name}`);
630
- }
631
1328
  }
632
1329
  for (const name of Object.keys(aggregate.roleSessionSets)) {
633
1330
  if (aggregate.roles[name] === undefined) {
634
1331
  throw new StorageRecordError(`Task Role session set has no Role: ${aggregate.task.id}/${name}`);
635
1332
  }
636
1333
  }
637
- for (const [name, workspace] of Object.entries(aggregate.roleWorkspaces)) {
638
- if (aggregate.roles[name] === undefined) {
639
- throw new StorageRecordError(`RoleWorkspace has no Task Role: ${aggregate.task.id}/${name}`);
1334
+ for (const [key, workspace] of Object.entries(aggregate.managedWorkspaces)) {
1335
+ if (workspace.owner.taskId !== aggregate.task.id) {
1336
+ throw new StorageRecordError(`Managed workspace belongs to another Task: ${key}`);
640
1337
  }
641
- if (aggregate.task.repositoryId !== workspace.repositoryId) {
642
- throw new StorageRecordError(`RoleWorkspace Repository does not match Task: ${aggregate.task.id}/${name}`);
1338
+ const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
1339
+ if (workspace.entries.some(({ projectId }) => !boundProjects.has(projectId))) {
1340
+ throw new StorageRecordError(`Managed workspace Project does not match Task: ${aggregate.task.id}/${key}`);
1341
+ }
1342
+ if (workspace.owner.type === "work-item"
1343
+ && aggregate.workItems[workspace.owner.workItemId] === undefined) {
1344
+ throw new StorageRecordError(`Managed workspace WorkItem not found: ${aggregate.task.id}/${workspace.owner.workItemId}`);
1345
+ }
1346
+ if (workspace.owner.type === "review-round"
1347
+ && aggregate.reviewRounds[workspace.owner.reviewRoundId] === undefined) {
1348
+ throw new StorageRecordError(`Managed workspace ReviewRound not found: ${aggregate.task.id}/${workspace.owner.reviewRoundId}`);
1349
+ }
1350
+ if (workspace.owner.type === "integration-attempt"
1351
+ && aggregate.integrationAttempts[workspace.owner.integrationAttemptId] === undefined) {
1352
+ throw new StorageRecordError(`Managed workspace Integration Attempt not found: ${aggregate.task.id}/${workspace.owner.integrationAttemptId}`);
1353
+ }
1354
+ if (workspace.owner.type === "execution-lane") {
1355
+ const laneOwner = workspace.owner;
1356
+ const laneItem = laneOwner.purpose === "execution"
1357
+ ? aggregate.workItems[laneOwner.workItemId ?? ""]
1358
+ : undefined;
1359
+ const group = laneOwner.purpose === "execution"
1360
+ ? (laneItem === undefined
1361
+ ? undefined
1362
+ : workItemExecutionGroupById(laneItem, laneOwner.executionGroupId))
1363
+ : aggregate.reviewRounds[laneOwner.reviewRoundId ?? ""]?.executionGroup;
1364
+ if (group?.id !== laneOwner.executionGroupId
1365
+ || !group.lanes.some(({ id }) => id === laneOwner.executionLaneId)) {
1366
+ throw new StorageRecordError(`Managed workspace Execution Lane not found: ${aggregate.task.id}/${key}`);
1367
+ }
643
1368
  }
644
1369
  }
1370
+ validateCanonicalTaskReferences(result, aggregate);
645
1371
  }
646
- assertGloballyUniqueTaskRecordIds(result, "inputRequests", "Input request");
647
- assertGloballyUniqueTaskRecordIds(result, "decisions", "Decision");
648
- assertGloballyUniqueTaskRecordIds(result, "milestones", "Milestone");
649
- assertGloballyUniqueTaskRecordIds(result, "events", "Task event");
650
- assertGloballyUniqueTaskRecordIds(result, "messages", "Message");
1372
+ for (const mailbox of Object.values(result.mailboxes))
1373
+ validateMailboxReferences(result, mailbox);
651
1374
  return result;
652
1375
  }
1376
+ /** Strict current-model gate used by the compatible loader before any writer opens. */
1377
+ export function validateCurrentStorageStateSnapshot(value) {
1378
+ parseState(`${JSON.stringify(value)}\n`);
1379
+ }
653
1380
  function parseStoredTask(value, taskId) {
654
1381
  const aggregate = object(value, `Task aggregate ${taskId}`);
655
- exact(aggregate, ["schemaVersion", "task", "brief", "roles", "roleWorkspaces", "roleSessionSets", "workItems", "agentRuns", "activeRuns", "messages", "inputRequests", "decisions", "milestones", "events", "pendingWakeup", "leaderFailure", "operatorNotification"], `Task aggregate ${taskId}`);
656
- versioned(aggregate, 1, `Task aggregate ${taskId}`);
657
- validateTask(identified(aggregate.task, 1, "id", taskId, "Task"));
1382
+ exact(aggregate, [
1383
+ "schemaVersion",
1384
+ "task",
1385
+ "idHighWaterMarks",
1386
+ "brief",
1387
+ "changeSets",
1388
+ "integrationAttempts",
1389
+ "roles",
1390
+ "managedWorkspaces",
1391
+ "roleSessionSets",
1392
+ "workItems",
1393
+ "agentRuns",
1394
+ "reviewRounds",
1395
+ "activeRuns",
1396
+ "messages",
1397
+ "inputRequests",
1398
+ "decisions",
1399
+ "milestones",
1400
+ "events",
1401
+ "leaderFailure",
1402
+ "operatorNotification"
1403
+ ], `Task aggregate ${taskId}`);
1404
+ parseMap(aggregate.changeSets, (record, key) => {
1405
+ const changeSet = identifiedChangeSet(record, key);
1406
+ if (changeSet.taskId !== taskId) {
1407
+ throw new StorageRecordError(`ChangeSet belongs to another Task: ${changeSet.taskId}.`);
1408
+ }
1409
+ validateChangeSet(changeSet);
1410
+ return changeSet;
1411
+ }, "changeSets");
1412
+ parseMap(aggregate.integrationAttempts, (record, key) => {
1413
+ const attempt = identified(record, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, "id", key, "Integration Attempt");
1414
+ if (attempt.taskId !== taskId) {
1415
+ throw new StorageRecordError(`Integration Attempt belongs to another Task: ${attempt.taskId}.`);
1416
+ }
1417
+ validateIntegrationAttempt(attempt);
1418
+ return attempt;
1419
+ }, "integrationAttempts");
1420
+ versioned(aggregate, CURRENT_STORED_TASK_SCHEMA_VERSION, `Task aggregate ${taskId}`);
1421
+ validateTaskIdHighWaterMarks(aggregate.idHighWaterMarks, taskId);
1422
+ validateTask(identified(aggregate.task, CURRENT_TASK_SCHEMA_VERSION, "id", taskId, "Task"));
658
1423
  if (aggregate.brief !== null)
659
1424
  storedTaskBrief(aggregate.brief);
660
- parseMap(aggregate.roles, (record, key) => { const role = identified(record, 2, "name", key, "Task Role"); if (role.taskId !== taskId)
661
- throw new StorageRecordError(`Task Role belongs to another Task: ${role.taskId}`); validateTaskRole(role); return role; }, "roles");
662
- parseMap(aggregate.roleWorkspaces, (record, key) => {
663
- const workspace = identified(record, 1, "roleName", key, "RoleWorkspace");
664
- if (workspace.taskId !== taskId) {
665
- throw new StorageRecordError(`RoleWorkspace belongs to another Task: ${workspace.taskId}`);
666
- }
667
- validateRoleWorkspace(workspace);
1425
+ parseMap(aggregate.roles, (record, key) => {
1426
+ const role = identified(record, CURRENT_TASK_ROLE_SCHEMA_VERSION, "name", key, "Task Role");
1427
+ if (role.taskId !== taskId)
1428
+ throw new StorageRecordError(`Task Role belongs to another Task: ${role.taskId}`);
1429
+ validateTaskRole(role);
1430
+ return role;
1431
+ }, "roles");
1432
+ parseMap(aggregate.managedWorkspaces, (record, key) => {
1433
+ const workspace = versioned(record, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, "Managed workspace");
1434
+ validateManagedWorkspace(workspace);
1435
+ if (workspace.owner.taskId !== taskId) {
1436
+ throw new StorageRecordError(`Managed workspace belongs to another Task: ${workspace.owner.taskId}`);
1437
+ }
1438
+ if (managedWorkspaceKey(workspace.owner) !== key) {
1439
+ throw new StorageRecordError(`Managed workspace identity is inconsistent: ${taskId}/${key}`);
1440
+ }
668
1441
  return workspace;
669
- }, "roleWorkspaces");
1442
+ }, "managedWorkspaces");
670
1443
  parseMap(aggregate.roleSessionSets, (record, key) => { const set = taskSessions(record); if (set.owner.taskId !== taskId || set.owner.roleName !== key)
671
1444
  throw new StorageRecordError(`Task Role session set identity is inconsistent: ${taskId}/${key}`); return set; }, "roleSessionSets");
672
- parseMap(aggregate.workItems, (record, key) => { const item = identified(record, 1, "id", key, "Work item"); if (item.taskId !== taskId)
673
- throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`); return item; }, "workItems");
674
- parseMap(aggregate.agentRuns, (record, key) => { const run = identified(record, 1, "id", key, "Agent run"); if (run.taskId !== taskId)
675
- throw new StorageRecordError(`Agent run belongs to another Task: ${run.taskId}`); validateAgentRun(run); return run; }, "agentRuns");
1445
+ parseMap(aggregate.workItems, (record, key) => {
1446
+ const item = identified(record, CURRENT_WORK_ITEM_SCHEMA_VERSION, "id", key, "Work item");
1447
+ if (item.taskId !== taskId) {
1448
+ throw new StorageRecordError(`Work item belongs to another Task: ${item.taskId}`);
1449
+ }
1450
+ validateWorkItem(item);
1451
+ return item;
1452
+ }, "workItems");
1453
+ parseMap(aggregate.agentRuns, (record, key) => {
1454
+ const run = identified(record, CURRENT_AGENT_RUN_SCHEMA_VERSION, "id", key, "Agent run");
1455
+ if (run.taskId !== taskId) {
1456
+ throw new StorageRecordError(`Agent run belongs to another Task: ${run.taskId}`);
1457
+ }
1458
+ validateAgentRun(run);
1459
+ return run;
1460
+ }, "agentRuns");
1461
+ parseMap(aggregate.reviewRounds, (record, key) => {
1462
+ const round = identified(record, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, "id", key, "ReviewRound");
1463
+ if (round.taskId !== taskId) {
1464
+ throw new StorageRecordError(`ReviewRound belongs to another Task: ${round.taskId}.`);
1465
+ }
1466
+ validateReviewRound(round);
1467
+ return round;
1468
+ }, "reviewRounds");
676
1469
  parseMap(aggregate.activeRuns, (record, key) => {
677
- const pointer = versioned(record, 1, `Active run ${key}`);
1470
+ const pointer = versioned(record, CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, `Active run ${key}`);
678
1471
  const run = typeof pointer.runId === "string" ? aggregate.agentRuns[pointer.runId] : undefined;
679
- if (run === undefined || run.status !== "active" || run.roleName !== key) {
1472
+ const laneMatch = executionLaneActiveRunKeyParts(key);
1473
+ const validLanePointer = laneMatch !== null
1474
+ && run !== undefined
1475
+ && run.executionGroupId === laneMatch.executionGroupId
1476
+ && run.executionLaneId === laneMatch.executionLaneId;
1477
+ const validRolePointer = laneMatch === null
1478
+ && run !== undefined
1479
+ && run.roleName === key;
1480
+ if (run === undefined || run.status !== "active"
1481
+ || (!validLanePointer && !validRolePointer)) {
680
1482
  throw new StorageRecordError(`Active run pointer is invalid: ${taskId}/${key}`);
681
1483
  }
682
1484
  return pointer;
683
1485
  }, "activeRuns");
684
1486
  parseMap(aggregate.messages, (record, key) => {
685
- const message = identified(record, 1, "id", key, "Message");
1487
+ const message = identified(record, CURRENT_MESSAGE_SCHEMA_VERSION, "id", key, "Message");
1488
+ if (message.taskId !== taskId) {
1489
+ throw new StorageRecordError(`Message belongs to another Task: ${message.taskId}`);
1490
+ }
686
1491
  validateTaskMessage(message);
687
1492
  return message;
688
1493
  }, "messages");
689
1494
  parseMap(aggregate.inputRequests, (record, key) => {
690
- const request = validateInputRequest(record);
1495
+ const request = validateInputRequest(versioned(record, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, "Input request"));
691
1496
  if (request.id !== key) {
692
1497
  throw new StorageRecordError(`Input request identity is inconsistent: ${key}.`);
693
1498
  }
@@ -718,25 +1523,71 @@ function parseStoredTask(value, taskId) {
718
1523
  const event = storedTaskEvent(record);
719
1524
  if (event.id !== key)
720
1525
  throw new StorageRecordError(`Task event identity is inconsistent: ${key}.`);
1526
+ if (event.taskId !== taskId) {
1527
+ throw new StorageRecordError(`Task event belongs to another Task: ${event.taskId}`);
1528
+ }
721
1529
  return event;
722
1530
  }, "events");
723
- for (const [key, label] of [["pendingWakeup", "Pending wakeup"], ["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
1531
+ for (const [key, label] of [["leaderFailure", "Leader failure"], ["operatorNotification", "Operator notification"]]) {
724
1532
  const record = aggregate[key];
725
- if (record !== null)
726
- identified(record, 1, "taskId", taskId, label);
1533
+ if (record !== null) {
1534
+ const schemaVersion = key === "leaderFailure"
1535
+ ? CURRENT_LEADER_FAILURE_SCHEMA_VERSION
1536
+ : CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION;
1537
+ identified(record, schemaVersion, "taskId", taskId, label);
1538
+ }
727
1539
  }
1540
+ validateTaskIdHighWaterCoverage(aggregate, taskId);
728
1541
  return aggregate;
729
1542
  }
1543
+ function validateTaskIdHighWaterCoverage(aggregate, taskId) {
1544
+ const records = {
1545
+ workItem: aggregate.workItems,
1546
+ agentRun: aggregate.agentRuns,
1547
+ reviewRound: aggregate.reviewRounds,
1548
+ changeSet: aggregate.changeSets,
1549
+ integrationAttempt: aggregate.integrationAttempts,
1550
+ message: aggregate.messages,
1551
+ inputRequest: aggregate.inputRequests,
1552
+ decision: aggregate.decisions,
1553
+ milestone: aggregate.milestones,
1554
+ event: aggregate.events
1555
+ };
1556
+ for (const kind of Object.keys(TASK_RECORD_ID_PREFIXES)) {
1557
+ const pattern = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`);
1558
+ for (const id of Object.keys(records[kind])) {
1559
+ const match = pattern.exec(id);
1560
+ if (match === null) {
1561
+ throw new StorageRecordError(`Task-local ${kind} id is invalid: ${taskId}/${id}.`);
1562
+ }
1563
+ const sequence = Number.parseInt(match[1], 10);
1564
+ if (sequence > aggregate.idHighWaterMarks[kind]) {
1565
+ throw new StorageRecordError(`Task id high-water mark is behind ${taskId}/${kind}: ${id}.`);
1566
+ }
1567
+ }
1568
+ }
1569
+ }
1570
+ function observeTaskRecordId(aggregate, kind, id) {
1571
+ validateTaskRecordReference({ taskId: aggregate.task.id, localId: id }, kind);
1572
+ const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-(\\d+)$`).exec(id);
1573
+ if (match === null)
1574
+ throw new StorageRecordError(`Task-local ${kind} id is invalid: ${id}.`);
1575
+ const sequence = Number.parseInt(match[1], 10);
1576
+ aggregate.idHighWaterMarks[kind] = Math.max(aggregate.idHighWaterMarks[kind], sequence);
1577
+ }
730
1578
  function validateYuiConfig(config) {
731
1579
  try {
732
1580
  reconciliationIntervalMilliseconds(config.reconciliationIntervalSeconds);
1581
+ resolveTimeZone(config.timeZone);
1582
+ if (config.review !== undefined)
1583
+ validateReviewConfig(config.review);
733
1584
  }
734
1585
  catch (error) {
735
1586
  throw new StorageRecordError(error instanceof Error ? error.message : "Yui reconciliation interval is invalid.");
736
1587
  }
737
1588
  }
738
1589
  function globalSessions(value) {
739
- const set = versioned(value, 1, "Global Role session set");
1590
+ const set = versioned(value, CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION, "Global Role session set");
740
1591
  if (set.owner?.scope !== "global" || typeof set.owner.roleName !== "string")
741
1592
  throw new StorageRecordError("Global Role session owner is invalid.");
742
1593
  validateSessions(set.sessions);
@@ -744,7 +1595,7 @@ function globalSessions(value) {
744
1595
  return set;
745
1596
  }
746
1597
  function taskSessions(value) {
747
- const set = versioned(value, 1, "Task Role session set");
1598
+ const set = versioned(value, CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION, "Task Role session set");
748
1599
  if (set.owner?.scope !== "task" || typeof set.owner.taskId !== "string" || typeof set.owner.roleName !== "string")
749
1600
  throw new StorageRecordError("Task Role session owner is invalid.");
750
1601
  validateSessions(set.sessions);
@@ -752,11 +1603,11 @@ function taskSessions(value) {
752
1603
  return set;
753
1604
  }
754
1605
  function validateSessions(sessions) {
755
- parseMap(sessions, (record, key) => identified(record, 1, "agentId", key, "Role Agent session"), "sessions");
1606
+ parseMap(sessions, (record, key) => identified(record, CURRENT_ROLE_AGENT_SESSION_SCHEMA_VERSION, "agentId", key, "Role Agent session"), "sessions");
756
1607
  }
757
1608
  function assertSessionsMatchRole(sessions, role) {
758
1609
  validateRoleSessionSet(sessions);
759
- if (sessions.owner.roleName !== role.name || sessions.activeAgentId !== role.activeAgentId) {
1610
+ if (sessions.owner.roleName !== role.name) {
760
1611
  throw new StorageRecordError(`Role session set does not match Role: ${role.name}`);
761
1612
  }
762
1613
  if ("taskId" in role && (sessions.owner.scope !== "task" || sessions.owner.taskId !== role.taskId)) {
@@ -765,7 +1616,16 @@ function assertSessionsMatchRole(sessions, role) {
765
1616
  if (!("taskId" in role) && sessions.owner.scope !== "global") {
766
1617
  throw new StorageRecordError(`Global Role session owner is inconsistent: ${role.name}`);
767
1618
  }
768
- for (const [agentId, session] of Object.entries(sessions.sessions)) {
1619
+ const ownedSessions = [
1620
+ ...Object.entries(sessions.sessions),
1621
+ ...(sessions.owner.scope === "global"
1622
+ ? Object.entries(sessions.history ?? {})
1623
+ : [
1624
+ ...(sessions.history ?? []).map((session, index) => [`history-${index}`, session])
1625
+ ])
1626
+ ];
1627
+ for (const [, session] of ownedSessions) {
1628
+ const agentId = session.agentId;
769
1629
  const binding = role.agentBindings[agentId];
770
1630
  if (binding === undefined || binding.adapterId !== session.adapterId) {
771
1631
  throw new StorageRecordError(`Role Agent session has no matching binding: ${role.name}/${agentId}`);
@@ -773,9 +1633,19 @@ function assertSessionsMatchRole(sessions, role) {
773
1633
  }
774
1634
  }
775
1635
  function storedTaskBrief(value) {
776
- const brief = versioned(value, 1, "Task Brief");
777
- exact(brief, ["schemaVersion", "objective", "boundaries", "currentFocus", "leaderSummary", "updatedAt", "updatedBy"], "Task Brief");
1636
+ const brief = versioned(value, CURRENT_TASK_BRIEF_SCHEMA_VERSION, "Task Brief");
1637
+ exact(brief, [
1638
+ "schemaVersion",
1639
+ "objective",
1640
+ "boundaries",
1641
+ "technicalApproach",
1642
+ "currentFocus",
1643
+ "leaderSummary",
1644
+ "updatedAt",
1645
+ "updatedBy"
1646
+ ], "Task Brief");
778
1647
  requireNormalizedText(brief.objective, "Task Brief objective");
1648
+ requireOptionalNormalizedText(brief.technicalApproach, "Task Brief technical approach");
779
1649
  requireNormalizedText(brief.currentFocus, "Task Brief current focus");
780
1650
  requireNormalizedText(brief.leaderSummary, "Task Brief leader summary");
781
1651
  requireNormalizedText(brief.updatedBy, "Task Brief updatedBy");
@@ -790,7 +1660,7 @@ function storedTaskBrief(value) {
790
1660
  return brief;
791
1661
  }
792
1662
  function storedDecision(value) {
793
- const decision = versioned(value, 1, "Decision");
1663
+ const decision = versioned(value, CURRENT_DECISION_SCHEMA_VERSION, "Decision");
794
1664
  const baseFields = [
795
1665
  "schemaVersion", "id", "taskId", "title", "rationale", "status", "createdAt", "updatedAt"
796
1666
  ];
@@ -810,6 +1680,7 @@ function storedDecision(value) {
810
1680
  }
811
1681
  requireRecordIdentity(decision.id, "Decision id");
812
1682
  requireRecordIdentity(decision.taskId, "Decision Task id");
1683
+ validateTaskRecordReference({ taskId: decision.taskId, localId: decision.id }, "decision");
813
1684
  requireNormalizedText(decision.title, "Decision title");
814
1685
  requireNormalizedText(decision.rationale, "Decision rationale");
815
1686
  requireTimestamp(decision.createdAt, "Decision createdAt");
@@ -820,10 +1691,11 @@ function storedDecision(value) {
820
1691
  return decision;
821
1692
  }
822
1693
  function storedMilestone(value) {
823
- const milestone = versioned(value, 1, "Milestone");
1694
+ const milestone = versioned(value, CURRENT_MILESTONE_SCHEMA_VERSION, "Milestone");
824
1695
  exact(milestone, ["schemaVersion", "id", "taskId", "title", "summary", "createdBy", "createdAt"], "Milestone");
825
1696
  requireRecordIdentity(milestone.id, "Milestone id");
826
1697
  requireRecordIdentity(milestone.taskId, "Milestone Task id");
1698
+ validateTaskRecordReference({ taskId: milestone.taskId, localId: milestone.id }, "milestone");
827
1699
  requireNormalizedText(milestone.title, "Milestone title");
828
1700
  requireNormalizedText(milestone.summary, "Milestone summary");
829
1701
  if (milestone.createdBy !== "leader") {
@@ -833,9 +1705,11 @@ function storedMilestone(value) {
833
1705
  return milestone;
834
1706
  }
835
1707
  function storedTaskEvent(value) {
836
- const event = versioned(value, 1, "Task event");
837
- exact(event, ["schemaVersion", "id", "type", "payload", "createdAt"], "Task event");
1708
+ const event = versioned(value, CURRENT_EVENT_SCHEMA_VERSION, "Task event");
1709
+ exact(event, ["schemaVersion", "id", "taskId", "type", "payload", "createdAt"], "Task event");
838
1710
  requireRecordIdentity(event.id, "Task event id");
1711
+ requireRecordIdentity(event.taskId, "Task event Task id");
1712
+ validateTaskRecordReference({ taskId: event.taskId, localId: event.id }, "event");
839
1713
  requireNormalizedText(event.type, "Task event type");
840
1714
  const payload = object(event.payload, "Task event payload");
841
1715
  for (const [key, payloadValue] of Object.entries(payload)) {
@@ -876,25 +1750,6 @@ function requireTaskFromState(state, taskId) {
876
1750
  throw new StorageRecordError(`Task not found: ${taskId}`);
877
1751
  return aggregate;
878
1752
  }
879
- function taskRecordOwner(state, key, id) {
880
- for (const [taskId, aggregate] of Object.entries(state.tasks)) {
881
- if (aggregate[key][id] !== undefined)
882
- return taskId;
883
- }
884
- return null;
885
- }
886
- function assertGloballyUniqueTaskRecordIds(state, key, label) {
887
- const owners = new Map();
888
- for (const [taskId, aggregate] of Object.entries(state.tasks)) {
889
- for (const id of Object.keys(aggregate[key])) {
890
- const owner = owners.get(id);
891
- if (owner !== undefined) {
892
- throw new StorageRecordError(`${label} id is duplicated across Tasks: ${id} (${owner}, ${taskId}).`);
893
- }
894
- owners.set(id, taskId);
895
- }
896
- }
897
- }
898
1753
  function requireRecordIdentity(value, label) {
899
1754
  const normalized = requireNormalizedText(value, label);
900
1755
  if (["__proto__", "prototype", "constructor", ".", ".."].includes(normalized)
@@ -914,6 +1769,15 @@ function requireNormalizedText(value, label) {
914
1769
  throw new StorageRecordError(`${label} must be normalized.`);
915
1770
  return normalized;
916
1771
  }
1772
+ function requireOptionalNormalizedText(value, label) {
1773
+ if (typeof value !== "string" || value.includes("\0")) {
1774
+ throw new StorageRecordError(`${label} is invalid.`);
1775
+ }
1776
+ const normalized = value.trim();
1777
+ if (normalized !== value)
1778
+ throw new StorageRecordError(`${label} must be normalized.`);
1779
+ return normalized;
1780
+ }
917
1781
  function requireTimestamp(value, label) {
918
1782
  if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
919
1783
  throw new StorageRecordError(`${label} is invalid.`);
@@ -938,6 +1802,9 @@ function identified(value, schemaVersion, key, expected, label) {
938
1802
  throw new StorageRecordError(`${label} identity is inconsistent: ${expected}.`);
939
1803
  return record;
940
1804
  }
1805
+ function identifiedChangeSet(value, expectedId) {
1806
+ return identified(value, CURRENT_CHANGE_SET_SCHEMA_VERSION, "id", expectedId, "ChangeSet");
1807
+ }
941
1808
  function object(value, label) {
942
1809
  if (typeof value !== "object" || value === null || Array.isArray(value))
943
1810
  throw new StorageRecordError(`${label} must be an object.`);
@@ -979,12 +1846,726 @@ function values(records, identity) {
979
1846
  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])));
980
1847
  }
981
1848
  function numericCompare(left, right) { return left.localeCompare(right, undefined, { numeric: true }); }
982
- function allKeys(state, key) { return Object.values(state.tasks).flatMap((task) => Object.keys(task[key])); }
983
- function findUnique(state, key, id, label) {
984
- const matches = Object.values(state.tasks).flatMap((task) => task[key][id] === undefined ? [] : [task[key][id]]);
985
- if (matches.length > 1)
986
- throw new StorageRecordError(`${label} id is ambiguous: ${id}`);
987
- return matches[0] === undefined ? null : clone(matches[0]);
1849
+ function pendingWakeupProjection(mailbox) {
1850
+ if (mailbox === null || mailbox.target.kind !== "role" || mailbox.target.roleName !== "leader"
1851
+ || mailbox.pending === null) {
1852
+ return null;
1853
+ }
1854
+ return {
1855
+ schemaVersion: CURRENT_PENDING_WAKEUP_SCHEMA_VERSION,
1856
+ taskId: mailbox.target.taskId,
1857
+ reasons: [...mailbox.pending.reasons],
1858
+ requestCount: mailbox.pending.requestCount,
1859
+ firstRequestedAt: mailbox.pending.firstQueuedAt,
1860
+ lastRequestedAt: mailbox.pending.lastQueuedAt
1861
+ };
1862
+ }
1863
+ function validateMailboxReferences(state, mailbox) {
1864
+ if (mailbox.target.kind === "task"
1865
+ || mailbox.target.kind === "role"
1866
+ || mailbox.target.kind === "role-runtime") {
1867
+ const aggregate = state.tasks[mailbox.target.taskId];
1868
+ if (aggregate === undefined) {
1869
+ throw new StorageRecordError(`WorkMailbox target Task not found: ${mailbox.target.taskId}`);
1870
+ }
1871
+ if (mailbox.target.kind === "role" && aggregate.roles[mailbox.target.roleName] === undefined) {
1872
+ throw new StorageRecordError(`WorkMailbox target Role not found: ${mailbox.target.taskId}/${mailbox.target.roleName}`);
1873
+ }
1874
+ }
1875
+ const refs = [];
1876
+ if (mailbox.processing !== null) {
1877
+ refs.push(...mailbox.processing.batch.refs);
1878
+ if (mailbox.processing.executionRef !== undefined)
1879
+ refs.push(mailbox.processing.executionRef);
1880
+ }
1881
+ if (mailbox.pending !== null)
1882
+ refs.push(...mailbox.pending.refs);
1883
+ for (const ref of refs) {
1884
+ if (!mailboxReferenceExists(state, ref)) {
1885
+ const identity = "taskId" in ref ? `${ref.taskId}/${ref.id}` : ref.id;
1886
+ throw new StorageRecordError(`WorkMailbox reference does not exist: ${ref.type}/${identity}`);
1887
+ }
1888
+ }
1889
+ }
1890
+ function validateCanonicalTaskReferences(state, aggregate) {
1891
+ const taskId = aggregate.task.id;
1892
+ if (aggregate.task.replacementTaskId !== undefined) {
1893
+ const replacement = state.tasks[aggregate.task.replacementTaskId];
1894
+ if (replacement === undefined || replacement.task.id === taskId) {
1895
+ throw new StorageRecordError(`Replacement Task reference is invalid: ${taskId}/${aggregate.task.replacementTaskId}.`);
1896
+ }
1897
+ }
1898
+ const boundProjects = new Set(aggregate.task.projectBindings.map(({ projectId }) => projectId));
1899
+ assertAcyclicWorkItems(aggregate.workItems);
1900
+ for (const item of Object.values(aggregate.workItems)) {
1901
+ for (const dependencyId of item.dependsOn) {
1902
+ if (aggregate.workItems[dependencyId] === undefined) {
1903
+ throw new StorageRecordError(`Work Item dependency not found: ${taskId}/${dependencyId}.`);
1904
+ }
1905
+ }
1906
+ if (item.writeProjectIds.some((projectId) => !boundProjects.has(projectId))) {
1907
+ throw new StorageRecordError(`Work Item writable Project does not belong to Task: ${taskId}/${item.id}.`);
1908
+ }
1909
+ const writableProjects = new Set(item.writeProjectIds);
1910
+ if (item.baseRefs?.some(({ projectId }) => !boundProjects.has(projectId))) {
1911
+ throw new StorageRecordError(`Work Item base-ref Project does not belong to Task: ${taskId}/${item.id}.`);
1912
+ }
1913
+ if (item.baseRefs?.some(({ projectId }) => !writableProjects.has(projectId))) {
1914
+ throw new StorageRecordError(`Work Item base-ref Project must be writable: ${taskId}/${item.id}.`);
1915
+ }
1916
+ const replacementWorkItemId = item.disposition?.replacementWorkItemId;
1917
+ if (replacementWorkItemId !== undefined) {
1918
+ if (replacementWorkItemId === item.id) {
1919
+ throw new StorageRecordError(`Work Item cannot replace itself: ${taskId}/${item.id}.`);
1920
+ }
1921
+ const replacement = aggregate.workItems[replacementWorkItemId];
1922
+ if (replacement === undefined || replacement.taskId !== taskId) {
1923
+ throw new StorageRecordError(`Replacement Work Item must belong to the same Task: ${taskId}/${replacementWorkItemId}.`);
1924
+ }
1925
+ }
1926
+ for (const candidate of item.candidates) {
1927
+ assertWorkItemCandidateReferences(aggregate, item, candidate, `Work Item candidate ${item.id}`);
1928
+ }
1929
+ }
1930
+ for (const workspace of Object.values(aggregate.managedWorkspaces)) {
1931
+ if (workspace.owner.type === "work-item"
1932
+ && aggregate.workItems[workspace.owner.workItemId] === undefined) {
1933
+ throw new StorageRecordError(`Managed workspace Work Item not found: ${taskId}/${workspace.owner.workItemId}.`);
1934
+ }
1935
+ if (workspace.owner.type === "review-round") {
1936
+ const round = aggregate.reviewRounds[workspace.owner.reviewRoundId];
1937
+ if (round === undefined) {
1938
+ throw new StorageRecordError(`Managed workspace ReviewRound is invalid: ${taskId}/${workspace.owner.reviewRoundId}.`);
1939
+ }
1940
+ }
1941
+ }
1942
+ for (const [roleName, sessions] of Object.entries(aggregate.roleSessionSets)) {
1943
+ if (sessions.inFlight !== null) {
1944
+ const run = aggregate.agentRuns[sessions.inFlight.runId];
1945
+ if (run === undefined || run.roleName !== roleName
1946
+ || sessions.inFlight.receiptId !== formatAgentRunReceiptId(taskId, run.id)) {
1947
+ throw new StorageRecordError(`Task Role in-flight Run is invalid: ${taskId}/${roleName}.`);
1948
+ }
1949
+ }
1950
+ if (sessions.pendingTurnCompletion !== null) {
1951
+ const completion = sessions.pendingTurnCompletion;
1952
+ const run = aggregate.agentRuns[completion.runId];
1953
+ if (completion.taskId !== taskId || run === undefined || run.roleName !== roleName) {
1954
+ throw new StorageRecordError(`Task Role pending completion Run is invalid: ${taskId}/${roleName}.`);
1955
+ }
1956
+ }
1957
+ }
1958
+ for (const run of Object.values(aggregate.agentRuns)) {
1959
+ if (run.workItemId !== undefined && aggregate.workItems[run.workItemId] === undefined) {
1960
+ throw new StorageRecordError(`Agent Run Work Item not found: ${taskId}/${run.id}.`);
1961
+ }
1962
+ if (run.reviewRoundId !== undefined
1963
+ && aggregate.reviewRounds[run.reviewRoundId] === undefined) {
1964
+ throw new StorageRecordError(`Agent Run ReviewRound not found: ${taskId}/${run.id}.`);
1965
+ }
1966
+ assertAgentRunExecutionReferences(aggregate, run);
1967
+ }
1968
+ for (const message of Object.values(aggregate.messages)) {
1969
+ if (message.runId !== undefined && aggregate.agentRuns[message.runId] === undefined) {
1970
+ throw new StorageRecordError(`Message Run not found: ${taskId}/${message.id}.`);
1971
+ }
1972
+ if (message.workItemId !== undefined
1973
+ && aggregate.workItems[message.workItemId] === undefined) {
1974
+ throw new StorageRecordError(`Message Work Item not found: ${taskId}/${message.id}.`);
1975
+ }
1976
+ }
1977
+ for (const request of Object.values(aggregate.inputRequests)) {
1978
+ if (aggregate.agentRuns[request.requester.runId] === undefined) {
1979
+ throw new StorageRecordError(`Input requester Run not found: ${taskId}/${request.id}.`);
1980
+ }
1981
+ for (const reference of request.blockedRefs) {
1982
+ const found = reference.type === "run"
1983
+ ? aggregate.agentRuns[reference.id]
1984
+ : aggregate.workItems[reference.id];
1985
+ if (found === undefined) {
1986
+ throw new StorageRecordError(`Input blocked ${reference.type} not found: ${taskId}/${request.id}/${reference.id}.`);
1987
+ }
1988
+ }
1989
+ }
1990
+ for (const round of Object.values(aggregate.reviewRounds)) {
1991
+ const item = aggregate.workItems[round.workItemId];
1992
+ if (item === undefined) {
1993
+ throw new StorageRecordError(`ReviewRound references are invalid: ${round.id}.`);
1994
+ }
1995
+ const candidate = item.candidates.find(({ id }) => id === round.candidateId);
1996
+ if (candidate === undefined) {
1997
+ throw new StorageRecordError(`ReviewRound Candidate not found: ${round.candidateId}.`);
1998
+ }
1999
+ assertWorkItemCandidateReferences(aggregate, item, candidate, `ReviewRound candidate ${round.id}`);
2000
+ if ((round.scope ?? "work-item") === "task") {
2001
+ const frozenProjects = round.taskCandidate?.projects ?? [];
2002
+ if (frozenProjects.length !== boundProjects.size
2003
+ || frozenProjects.some(({ projectId }) => !boundProjects.has(projectId))) {
2004
+ throw new StorageRecordError(`Task ReviewRound Projects do not match Task scope: ${round.id}.`);
2005
+ }
2006
+ if (!sameTaskFinalReviewContract(round.taskFinalReviewContract, candidate.taskFinalReviewContract)) {
2007
+ throw new StorageRecordError(`Task ReviewRound contract does not match its Candidate: ${round.id}.`);
2008
+ }
2009
+ }
2010
+ if (round.reviewerRunId !== undefined) {
2011
+ const reviewerRun = aggregate.agentRuns[round.reviewerRunId];
2012
+ if (reviewerRun === undefined
2013
+ || reviewerRun.reviewRoundId !== round.id
2014
+ || reviewerRun.purpose !== "review") {
2015
+ throw new StorageRecordError(`ReviewRound Reviewer Run is invalid: ${round.id}.`);
2016
+ }
2017
+ }
2018
+ }
2019
+ for (const changeSet of Object.values(aggregate.changeSets)) {
2020
+ if (aggregate.workItems[changeSet.workItemId] === undefined) {
2021
+ throw new StorageRecordError(`ChangeSet Work Item not found: ${changeSet.id}.`);
2022
+ }
2023
+ if (!aggregate.task.projectBindings.some(({ projectId }) => projectId === changeSet.projectId)) {
2024
+ throw new StorageRecordError(`ChangeSet Project does not match Task: ${changeSet.id}.`);
2025
+ }
2026
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
2027
+ if (evidenceRound !== undefined) {
2028
+ throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become a ChangeSet.`);
2029
+ }
2030
+ }
2031
+ for (const integration of Object.values(aggregate.integrationAttempts)) {
2032
+ if (!boundProjects.has(integration.projectId)) {
2033
+ throw new StorageRecordError(`Integration Project does not match Task: ${integration.id}.`);
2034
+ }
2035
+ for (const changeSetId of integration.changeSetIds) {
2036
+ const changeSet = aggregate.changeSets[changeSetId];
2037
+ if (changeSet === undefined) {
2038
+ throw new StorageRecordError(`Integration ChangeSet not found: ${integration.id}/${changeSetId}.`);
2039
+ }
2040
+ if (changeSet.projectId !== integration.projectId) {
2041
+ throw new StorageRecordError(`Integration ChangeSet belongs to another Project: ${integration.id}/${changeSetId}.`);
2042
+ }
2043
+ const evidenceRound = Object.values(aggregate.reviewRounds).find(({ evidenceCommit }) => evidenceCommit === changeSet.headCommit);
2044
+ if (evidenceRound !== undefined) {
2045
+ throw new StorageRecordError(`ReviewRound evidence commit ${evidenceRound.id}/${changeSet.headCommit} cannot become an Integration source.`);
2046
+ }
2047
+ }
2048
+ }
2049
+ for (const workspace of Object.values(aggregate.managedWorkspaces)) {
2050
+ assertManagedWorkspaceReferences(aggregate, workspace, "Managed workspace");
2051
+ }
2052
+ }
2053
+ /**
2054
+ * Enforce the owner-specific scope at the storage boundary. The map key and
2055
+ * foreign-record checks prevent dangling identities; these checks prevent a
2056
+ * valid owner from being paired with another lifecycle's Project scope.
2057
+ */
2058
+ function assertManagedWorkspaceReferences(aggregate, workspace, label, workItemOverride) {
2059
+ const taskId = aggregate.task.id;
2060
+ const boundProjects = aggregate.task.projectBindings.map(({ projectId }) => projectId).sort();
2061
+ const actualProjects = workspace.entries.map(({ projectId }) => projectId).sort();
2062
+ const requireVisibleTaskScope = () => {
2063
+ // Project bindings are persisted before the physical workspace is
2064
+ // reconciled. During that bounded hand-off a workspace may be missing a
2065
+ // newly-bound Project, but it must never expose a Project outside the
2066
+ // current Task. Launch preparation closes the temporary subset before
2067
+ // the workspace can be used.
2068
+ if (actualProjects.some((projectId) => !boundProjects.includes(projectId))) {
2069
+ throw new StorageRecordError(`${label} Project scope does not match Task: ${taskId}.`);
2070
+ }
2071
+ };
2072
+ switch (workspace.owner.type) {
2073
+ case "task":
2074
+ requireVisibleTaskScope();
2075
+ if (workspace.entries.some(({ access }) => access !== "write")) {
2076
+ throw new StorageRecordError(`${label} Task workspace must be writable: ${taskId}.`);
2077
+ }
2078
+ return;
2079
+ case "work-item": {
2080
+ const item = workItemOverride?.id === workspace.owner.workItemId
2081
+ ? workItemOverride
2082
+ : aggregate.workItems[workspace.owner.workItemId];
2083
+ if (item === undefined) {
2084
+ throw new StorageRecordError(`${label} WorkItem not found: ${taskId}/${workspace.owner.workItemId}.`);
2085
+ }
2086
+ requireVisibleTaskScope();
2087
+ const writable = workspace.entries
2088
+ .filter(({ access }) => access === "write")
2089
+ .map(({ projectId }) => projectId)
2090
+ .sort();
2091
+ const expectedWritable = [...item.writeProjectIds].sort();
2092
+ // Write scope expansion is persisted before the physical WorkItem
2093
+ // workspace is reconciled. The stored workspace may therefore expose
2094
+ // a temporary subset, but it may never grant write access outside the
2095
+ // current WorkItem authorization.
2096
+ if (writable.some((projectId) => !expectedWritable.includes(projectId))) {
2097
+ throw new StorageRecordError(`${label} WorkItem write scope does not match: ${taskId}/${item.id}.`);
2098
+ }
2099
+ return;
2100
+ }
2101
+ case "review-round": {
2102
+ const round = aggregate.reviewRounds[workspace.owner.reviewRoundId];
2103
+ if (round === undefined) {
2104
+ throw new StorageRecordError(`${label} ReviewRound not found: ${taskId}/${workspace.owner.reviewRoundId}.`);
2105
+ }
2106
+ requireVisibleTaskScope();
2107
+ if (workspace.entries.some(({ access }) => access !== "write")) {
2108
+ throw new StorageRecordError(`${label} ReviewRound workspace must be writable: ${taskId}/${round.id}.`);
2109
+ }
2110
+ const item = aggregate.workItems[round.workItemId];
2111
+ const candidate = item?.candidates.find(({ id }) => id === round.candidateId);
2112
+ const frozenProjects = round.scope === "task"
2113
+ ? round.taskCandidate?.projects
2114
+ : candidate?.gitSnapshot?.projects;
2115
+ if (frozenProjects !== undefined) {
2116
+ const expected = [...frozenProjects]
2117
+ .map(({ projectId, commit }) => ({ projectId, commit }))
2118
+ .sort((left, right) => left.projectId.localeCompare(right.projectId));
2119
+ const actual = workspace.entries
2120
+ .map(({ projectId, baseCommit }) => ({ projectId, commit: baseCommit }))
2121
+ .sort((left, right) => left.projectId.localeCompare(right.projectId));
2122
+ const matches = expected.length === actual.length
2123
+ && expected.every((frozen, index) => {
2124
+ const reviewEntry = actual[index];
2125
+ return reviewEntry?.projectId === frozen.projectId
2126
+ && reviewEntry.commit === frozen.commit;
2127
+ });
2128
+ if (!matches) {
2129
+ const provenance = round.scope === "task"
2130
+ ? "Task frozen project set"
2131
+ : "Candidate frozen commit";
2132
+ throw new StorageRecordError(`${label} ReviewRound does not use the ${provenance}: ${taskId}/${round.id}.`);
2133
+ }
2134
+ }
2135
+ return;
2136
+ }
2137
+ case "integration-attempt": {
2138
+ const attempt = aggregate.integrationAttempts[workspace.owner.integrationAttemptId];
2139
+ if (attempt === undefined) {
2140
+ throw new StorageRecordError(`${label} Integration Attempt not found: ${taskId}/${workspace.owner.integrationAttemptId}.`);
2141
+ }
2142
+ if (workspace.entries.length !== 1
2143
+ || workspace.entries[0].projectId !== attempt.projectId
2144
+ || workspace.entries[0].access !== "write") {
2145
+ throw new StorageRecordError(`${label} Integration Attempt scope is invalid: ${taskId}/${attempt.id}.`);
2146
+ }
2147
+ return;
2148
+ }
2149
+ case "execution-lane": {
2150
+ const owner = workspace.owner;
2151
+ const laneItem = owner.purpose === "execution"
2152
+ ? aggregate.workItems[owner.workItemId ?? ""]
2153
+ : undefined;
2154
+ const group = owner.purpose === "execution"
2155
+ ? (laneItem === undefined
2156
+ ? undefined
2157
+ : workItemExecutionGroupById(laneItem, owner.executionGroupId))
2158
+ : aggregate.reviewRounds[owner.reviewRoundId ?? ""]?.executionGroup;
2159
+ if (group === undefined || group.id !== owner.executionGroupId
2160
+ || !group.lanes.some(({ id }) => id === owner.executionLaneId)) {
2161
+ throw new StorageRecordError(`${label} Execution Lane lineage is invalid: ${taskId}/${owner.executionGroupId}/${owner.executionLaneId}.`);
2162
+ }
2163
+ requireVisibleTaskScope();
2164
+ const writable = workspace.entries
2165
+ .filter(({ access }) => access === "write")
2166
+ .map(({ projectId }) => projectId)
2167
+ .sort();
2168
+ const expectedWritable = owner.purpose === "execution"
2169
+ ? [...(aggregate.workItems[owner.workItemId ?? ""]?.writeProjectIds ?? [])].sort()
2170
+ : boundProjects;
2171
+ if (!isDeepStrictEqual(writable, expectedWritable)) {
2172
+ throw new StorageRecordError(`${label} Execution Lane write scope does not match: ${taskId}/${owner.executionLaneId}.`);
2173
+ }
2174
+ return;
2175
+ }
2176
+ }
2177
+ }
2178
+ function validIntegrationTransition(before, after) {
2179
+ if (before.id !== after.id
2180
+ || before.taskId !== after.taskId
2181
+ || before.projectId !== after.projectId
2182
+ || before.targetRef !== after.targetRef
2183
+ || before.expectedHead !== after.expectedHead
2184
+ || !isDeepStrictEqual(before.changeSetIds, after.changeSetIds)
2185
+ || !isDeepStrictEqual(before.checkCommands, after.checkCommands)
2186
+ || before.createdAt !== after.createdAt)
2187
+ return false;
2188
+ const allowed = {
2189
+ running: ["running", "blocked", "validating", "failed"],
2190
+ blocked: ["blocked", "validating", "failed"],
2191
+ validating: ["validating", "committed", "failed"],
2192
+ committed: ["committed"],
2193
+ failed: ["failed"]
2194
+ };
2195
+ return allowed[before.status].includes(after.status);
2196
+ }
2197
+ function assertAcyclicWorkItems(items) {
2198
+ const visiting = new Set();
2199
+ const visited = new Set();
2200
+ const visit = (id) => {
2201
+ if (visited.has(id))
2202
+ return;
2203
+ if (visiting.has(id))
2204
+ throw new StorageRecordError(`Work Item dependency cycle detected: ${id}.`);
2205
+ visiting.add(id);
2206
+ const item = items[id];
2207
+ if (item !== undefined) {
2208
+ for (const dependencyId of item.dependsOn)
2209
+ visit(dependencyId);
2210
+ }
2211
+ visiting.delete(id);
2212
+ visited.add(id);
2213
+ };
2214
+ for (const id of Object.keys(items))
2215
+ visit(id);
2216
+ }
2217
+ function mailboxReferenceExists(state, ref) {
2218
+ if ("taskId" in ref) {
2219
+ const aggregate = state.tasks[ref.taskId];
2220
+ if (aggregate === undefined)
2221
+ return false;
2222
+ switch (ref.type) {
2223
+ case "run": return aggregate.agentRuns[ref.id] !== undefined;
2224
+ case "work-item": return aggregate.workItems[ref.id] !== undefined;
2225
+ case "input": return aggregate.inputRequests[ref.id] !== undefined;
2226
+ case "message": return aggregate.messages[ref.id] !== undefined;
2227
+ }
2228
+ }
2229
+ switch (ref.type) {
2230
+ case "task": return state.tasks[ref.id] !== undefined;
2231
+ case "session":
2232
+ return [
2233
+ ...Object.values(state.globalRoleSessionSets),
2234
+ ...Object.values(state.tasks).flatMap((task) => Object.values(task.roleSessionSets))
2235
+ ].some((set) => Object.values(set.sessions).some((session) => session.nativeSessionId === ref.id));
2236
+ default: return false;
2237
+ }
2238
+ }
2239
+ function validWorkItemTransition(existing, candidate) {
2240
+ if (isDeepStrictEqual(existing, candidate))
2241
+ return true;
2242
+ if (existing.id !== candidate.id
2243
+ || existing.taskId !== candidate.taskId
2244
+ || existing.assignee !== candidate.assignee
2245
+ || existing.createdAt !== candidate.createdAt
2246
+ || !isDeepStrictEqual(existing.baseRefs, candidate.baseRefs)
2247
+ || candidate.revision !== existing.revision + 1
2248
+ || Date.parse(candidate.updatedAt) < Date.parse(existing.updatedAt))
2249
+ return false;
2250
+ if (!compatibleWorkItemExecutionGroups(existing, candidate))
2251
+ return false;
2252
+ if (existing.status !== candidate.status
2253
+ && (existing.title !== candidate.title
2254
+ || existing.objective !== candidate.objective
2255
+ || !isDeepStrictEqual(existing.acceptance, candidate.acceptance)
2256
+ || !isDeepStrictEqual(existing.dependsOn, candidate.dependsOn)
2257
+ || !isDeepStrictEqual(existing.writeProjectIds, candidate.writeProjectIds)))
2258
+ return false;
2259
+ const candidateProjects = new Set(candidate.writeProjectIds);
2260
+ if (existing.writeProjectIds.some((projectId) => !candidateProjects.has(projectId))) {
2261
+ return false;
2262
+ }
2263
+ const candidatesChanged = !isDeepStrictEqual(existing.candidates, candidate.candidates);
2264
+ const submittedCandidate = existing.status === "running"
2265
+ && candidate.status === "awaiting_acceptance"
2266
+ && candidate.candidates.length === existing.candidates.length + 1
2267
+ && isDeepStrictEqual(candidate.candidates.slice(0, existing.candidates.length), existing.candidates);
2268
+ if (candidatesChanged && !submittedCandidate) {
2269
+ return false;
2270
+ }
2271
+ const allowed = {
2272
+ pending: ["pending", "running", "retired"],
2273
+ running: [
2274
+ "running",
2275
+ "awaiting_acceptance",
2276
+ "completed",
2277
+ "failed",
2278
+ "retired"
2279
+ ],
2280
+ awaiting_acceptance: [
2281
+ "awaiting_acceptance",
2282
+ "completed",
2283
+ "failed",
2284
+ "retired"
2285
+ ],
2286
+ completed: ["completed"],
2287
+ failed: ["failed", "running", "retired"],
2288
+ retired: ["retired"]
2289
+ };
2290
+ return allowed[existing.status].includes(candidate.status);
2291
+ }
2292
+ function compatibleWorkItemExecutionGroups(existing, candidate) {
2293
+ if (candidate.executionGroups.length < existing.executionGroups.length)
2294
+ return false;
2295
+ for (const [index, historical] of existing.executionGroups.entries()) {
2296
+ const next = candidate.executionGroups[index];
2297
+ if (next === undefined || next.id !== historical.id)
2298
+ return false;
2299
+ const isCurrent = existing.currentExecutionGroupId === historical.id;
2300
+ const mutableCurrent = isCurrent && historical.resolution === undefined;
2301
+ if (!mutableCurrent && !isDeepStrictEqual(historical, next))
2302
+ return false;
2303
+ if (mutableCurrent && !compatibleExecutionGroups(historical, next))
2304
+ return false;
2305
+ }
2306
+ const appended = candidate.executionGroups.slice(existing.executionGroups.length);
2307
+ if (appended.length > 1)
2308
+ return false;
2309
+ if (appended.length === 1) {
2310
+ const priorCurrent = existing.currentExecutionGroupId === undefined
2311
+ ? undefined
2312
+ : workItemExecutionGroupById(existing, existing.currentExecutionGroupId);
2313
+ if (priorCurrent !== undefined && priorCurrent.resolution === undefined)
2314
+ return false;
2315
+ if (candidate.currentExecutionGroupId !== appended[0].id)
2316
+ return false;
2317
+ }
2318
+ else if (candidate.currentExecutionGroupId !== existing.currentExecutionGroupId) {
2319
+ const priorCurrent = existing.currentExecutionGroupId === undefined
2320
+ ? undefined
2321
+ : workItemExecutionGroupById(existing, existing.currentExecutionGroupId);
2322
+ const clearingResolvedRetry = existing.status === "failed"
2323
+ && candidate.status === "running"
2324
+ && candidate.currentExecutionGroupId === undefined
2325
+ && priorCurrent?.resolution !== undefined;
2326
+ if (!clearingResolvedRetry)
2327
+ return false;
2328
+ }
2329
+ if (candidate.currentExecutionGroupId !== undefined
2330
+ && workItemExecutionGroupById(candidate, candidate.currentExecutionGroupId) === undefined) {
2331
+ return false;
2332
+ }
2333
+ return true;
2334
+ }
2335
+ function assertWorkItemCandidateReferences(aggregate, item, candidate, label) {
2336
+ if (candidate.workItemRevision > item.revision) {
2337
+ throw new StorageRecordError(`${label} revision is invalid.`);
2338
+ }
2339
+ if ((candidate.executionGroupId === undefined) !== (candidate.executionLaneId === undefined)) {
2340
+ throw new StorageRecordError(`${label} execution lineage is incomplete.`);
2341
+ }
2342
+ if (candidate.executionGroupId !== undefined) {
2343
+ const group = workItemExecutionGroupById(item, candidate.executionGroupId);
2344
+ const lane = group?.lanes.find(({ id }) => id === candidate.executionLaneId);
2345
+ if (group === undefined
2346
+ || group.id !== candidate.executionGroupId
2347
+ || lane === undefined) {
2348
+ throw new StorageRecordError(`${label} execution lineage is invalid: candidate=${candidate.executionGroupId}/${candidate.executionLaneId}; `
2349
+ + `item=${group?.id ?? "none"}/${lane?.id ?? "none"}.`);
2350
+ }
2351
+ }
2352
+ if (candidate.workspace !== undefined) {
2353
+ if (candidate.workspace.owner.taskId !== item.taskId) {
2354
+ throw new StorageRecordError(`${label} workspace belongs to another Task.`);
2355
+ }
2356
+ if (candidate.workspace.owner.type === "work-item"
2357
+ && candidate.workspace.owner.workItemId !== item.id) {
2358
+ throw new StorageRecordError(`${label} workspace belongs to another Work Item.`);
2359
+ }
2360
+ if (candidate.workspace.owner.type !== "work-item") {
2361
+ throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
2362
+ }
2363
+ assertManagedWorkspaceReferences(aggregate, candidate.workspace, label, item);
2364
+ }
2365
+ if (candidate.source.type === "direct") {
2366
+ if (item.assignee !== undefined) {
2367
+ throw new StorageRecordError(`${label} cannot be direct for an assigned Work Item.`);
2368
+ }
2369
+ if (candidate.workspace !== undefined
2370
+ && (candidate.workspace.owner.type !== "work-item"
2371
+ || candidate.workspace.owner.taskId !== item.taskId
2372
+ || candidate.workspace.owner.workItemId !== item.id)) {
2373
+ throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
2374
+ }
2375
+ if (candidate.taskMainSnapshot !== undefined) {
2376
+ const expectedProjects = [...item.writeProjectIds].sort();
2377
+ const actualProjects = candidate.taskMainSnapshot.projects
2378
+ .map(({ projectId }) => projectId)
2379
+ .sort();
2380
+ if (!isDeepStrictEqual(actualProjects, expectedProjects)) {
2381
+ throw new StorageRecordError(`${label} Task-main snapshot scope is stale.`);
2382
+ }
2383
+ for (const project of candidate.taskMainSnapshot.projects) {
2384
+ const binding = aggregate.task.projectBindings.find(({ projectId }) => projectId === project.projectId);
2385
+ if (binding === undefined || binding.directory !== project.directory) {
2386
+ throw new StorageRecordError(`${label} Task-main snapshot Project is not bound: ${project.projectId}.`);
2387
+ }
2388
+ }
2389
+ }
2390
+ return;
2391
+ }
2392
+ const run = aggregate.agentRuns[candidate.source.runId];
2393
+ const resolvedGroupSummary = candidate.executionGroupId === undefined
2394
+ ? undefined
2395
+ : workItemExecutionGroupById(item, candidate.executionGroupId)?.resolution?.summary;
2396
+ if (run === undefined
2397
+ || run.workItemId !== item.id
2398
+ || run.purpose !== "execution"
2399
+ || run.status !== "yielded"
2400
+ || (run.summary !== candidate.summary && resolvedGroupSummary !== candidate.summary)) {
2401
+ throw new StorageRecordError(`${label} Run is invalid: ${candidate.source.runId}.`);
2402
+ }
2403
+ if (candidate.executionGroupId !== run.executionGroupId
2404
+ || candidate.executionLaneId !== run.executionLaneId) {
2405
+ throw new StorageRecordError(`${label} execution lineage does not match its source Run.`);
2406
+ }
2407
+ // A Gitless execution Run still carries its durable Task-owned empty view
2408
+ // for runtime fencing, while its Candidate intentionally has no Develop
2409
+ // workspace or Git snapshot. This is the only source/run workspace
2410
+ // mismatch permitted at the storage boundary.
2411
+ const gitlessRunWorkspace = run.workspace?.owner.type === "task"
2412
+ && run.workspace.owner.taskId === item.taskId
2413
+ && aggregate.task.projectBindings.length === 0
2414
+ && run.workspace.entries.length === 0
2415
+ && (() => {
2416
+ const durable = aggregate.managedWorkspaces[managedWorkspaceKey(run.workspace.owner)];
2417
+ return durable !== undefined && isDeepStrictEqual(durable, run.workspace);
2418
+ })();
2419
+ if (!gitlessRunWorkspace
2420
+ && (candidate.workspace === undefined) !== (run.workspace === undefined)) {
2421
+ throw new StorageRecordError(`${label} workspace does not match its source Run.`);
2422
+ }
2423
+ if (!gitlessRunWorkspace && candidate.workspace !== undefined && run.workspace !== undefined) {
2424
+ assertCandidateWorkspaceMatchesRun(candidate.workspace, run.workspace, label);
2425
+ }
2426
+ if (candidate.workspace !== undefined && (candidate.workspace.owner.type !== "work-item"
2427
+ || candidate.workspace.owner.taskId !== item.taskId
2428
+ || candidate.workspace.owner.workItemId !== item.id)) {
2429
+ throw new StorageRecordError(`${label} must use the WorkItem-owned Develop workspace.`);
2430
+ }
2431
+ }
2432
+ function assertAgentRunExecutionReferences(aggregate, run) {
2433
+ if ((run.executionGroupId === undefined) !== (run.executionLaneId === undefined)) {
2434
+ throw new StorageRecordError(`Agent Run execution lineage is incomplete: ${run.id}.`);
2435
+ }
2436
+ if (run.executionGroupId === undefined)
2437
+ return;
2438
+ const ownerGroup = run.purpose === "review"
2439
+ ? (run.reviewRoundId === undefined
2440
+ ? undefined
2441
+ : aggregate.reviewRounds[run.reviewRoundId]?.executionGroup)
2442
+ : (run.workItemId === undefined || run.executionGroupId === undefined
2443
+ ? undefined
2444
+ : (() => {
2445
+ const item = aggregate.workItems[run.workItemId];
2446
+ return item === undefined
2447
+ ? undefined
2448
+ : workItemExecutionGroupById(item, run.executionGroupId);
2449
+ })());
2450
+ if (ownerGroup === undefined) {
2451
+ throw new StorageRecordError(`Agent Run ExecutionGroup not found: ${run.id}.`);
2452
+ }
2453
+ validateExecutionGroup(ownerGroup);
2454
+ const lane = ownerGroup.lanes.find(({ id }) => id === run.executionLaneId);
2455
+ if (ownerGroup.id !== run.executionGroupId || lane === undefined) {
2456
+ throw new StorageRecordError(`Agent Run ExecutionLane does not match its owner: ${run.id}.`);
2457
+ }
2458
+ if (lane.roleName !== run.roleName) {
2459
+ throw new StorageRecordError(`Agent Run Role does not match its ExecutionLane: ${run.id}.`);
2460
+ }
2461
+ }
2462
+ function compatibleExecutionGroups(existing, candidate) {
2463
+ if (existing === undefined)
2464
+ return true;
2465
+ if (candidate === undefined)
2466
+ return false;
2467
+ return isExecutionGroupTransition(existing, candidate);
2468
+ }
2469
+ /** Candidate freezes Git commits at yield time, so timestamp/baseCommit fields
2470
+ * may differ from the Run's dispatch snapshot while workspace identity and
2471
+ * execution scope must remain exact. */
2472
+ function assertCandidateWorkspaceMatchesRun(candidate, run, label) {
2473
+ if (candidate.owner.type === "work-item" && run.owner.type === "execution-lane") {
2474
+ if (candidate.owner.taskId !== run.owner.taskId
2475
+ || run.owner.purpose !== "execution"
2476
+ || candidate.owner.workItemId !== run.owner.workItemId
2477
+ || candidate.entries.length !== run.entries.length) {
2478
+ throw new StorageRecordError(`${label} workspace lineage does not match its source Lane.`);
2479
+ }
2480
+ for (const source of run.entries) {
2481
+ const target = candidate.entries.find(({ projectId }) => projectId === source.projectId);
2482
+ if (target === undefined || target.directory !== source.directory || target.access !== source.access) {
2483
+ throw new StorageRecordError(`${label} workspace Project scope does not match its source Lane.`);
2484
+ }
2485
+ }
2486
+ return;
2487
+ }
2488
+ if (candidate.owner.type !== run.owner.type
2489
+ || candidate.owner.taskId !== run.owner.taskId
2490
+ || candidate.root !== run.root
2491
+ || candidate.entries.length !== run.entries.length) {
2492
+ throw new StorageRecordError(`${label} workspace does not match its source Run.`);
2493
+ }
2494
+ for (const source of run.entries) {
2495
+ const frozen = candidate.entries.find(({ projectId }) => projectId === source.projectId);
2496
+ if (frozen === undefined
2497
+ || frozen.directory !== source.directory
2498
+ || frozen.access !== source.access
2499
+ || frozen.path !== source.path
2500
+ || frozen.branch !== source.branch
2501
+ || frozen.baseRef !== source.baseRef) {
2502
+ throw new StorageRecordError(`${label} workspace scope does not match its source Run.`);
2503
+ }
2504
+ }
2505
+ }
2506
+ function validReviewRoundTransition(existing, candidate) {
2507
+ if (isDeepStrictEqual(existing, candidate))
2508
+ return true;
2509
+ if (existing.id !== candidate.id
2510
+ || existing.taskId !== candidate.taskId
2511
+ || existing.workItemId !== candidate.workItemId
2512
+ || existing.candidateId !== candidate.candidateId
2513
+ || existing.reviewerRoleName !== candidate.reviewerRoleName
2514
+ || existing.reviewBaseCommit !== candidate.reviewBaseCommit
2515
+ || (existing.scope ?? "work-item") !== (candidate.scope ?? "work-item")
2516
+ || !isDeepStrictEqual(existing.taskCandidate, candidate.taskCandidate)
2517
+ || !sameTaskFinalReviewContract(existing.taskFinalReviewContract, candidate.taskFinalReviewContract)
2518
+ || !compatibleExecutionGroups(existing.executionGroup, candidate.executionGroup)
2519
+ || existing.requestedBy !== candidate.requestedBy
2520
+ || existing.createdAt !== candidate.createdAt)
2521
+ return false;
2522
+ if (existing.status === "pending") {
2523
+ if (candidate.status === "pending") {
2524
+ return (existing.workspace === undefined
2525
+ && candidate.workspace !== undefined
2526
+ || existing.executionGroup === undefined
2527
+ && candidate.executionGroup !== undefined
2528
+ || existing.executionGroup !== undefined
2529
+ && candidate.executionGroup !== undefined
2530
+ && !isDeepStrictEqual(existing.executionGroup, candidate.executionGroup))
2531
+ && candidate.reviewerRunId === undefined
2532
+ && candidate.summary === undefined
2533
+ && candidate.checks === undefined
2534
+ && candidate.evidenceCommit === undefined
2535
+ && candidate.endedAt === undefined
2536
+ && candidate.workspaceDisposition === undefined;
2537
+ }
2538
+ return ["running", "failed"].includes(candidate.status)
2539
+ && (existing.workspace === undefined
2540
+ || isDeepStrictEqual(existing.workspace, candidate.workspace));
2541
+ }
2542
+ if (existing.status === "running") {
2543
+ if (candidate.status === "running") {
2544
+ return existing.reviewerRunId === candidate.reviewerRunId
2545
+ && isDeepStrictEqual(existing.workspace, candidate.workspace)
2546
+ && existing.executionGroup !== undefined
2547
+ && candidate.executionGroup !== undefined
2548
+ && !isDeepStrictEqual(existing.executionGroup, candidate.executionGroup)
2549
+ && candidate.summary === undefined
2550
+ && candidate.report === undefined
2551
+ && candidate.checks === undefined
2552
+ && candidate.evidenceCommit === undefined
2553
+ && candidate.endedAt === undefined
2554
+ && candidate.workspaceDisposition === undefined;
2555
+ }
2556
+ return ["completed", "failed"].includes(candidate.status)
2557
+ && existing.reviewerRunId === candidate.reviewerRunId
2558
+ && isDeepStrictEqual(existing.workspace, candidate.workspace);
2559
+ }
2560
+ if (existing.status === candidate.status
2561
+ && (existing.status === "completed" || existing.status === "failed")) {
2562
+ const { workspaceDisposition: _existingDisposition, ...existingResult } = existing;
2563
+ const { workspaceDisposition: _candidateDisposition, ...candidateResult } = candidate;
2564
+ return isDeepStrictEqual(existingResult, candidateResult)
2565
+ && existing.workspaceDisposition?.kind !== "removed"
2566
+ && candidate.workspaceDisposition !== undefined;
2567
+ }
2568
+ return false;
988
2569
  }
989
2570
  function synchronousResult(value) { if (typeof value === "object" && value !== null && "then" in value)
990
2571
  throw new StorageRecordError("FileTaskStore transactions must be synchronous."); return value; }
@@ -1008,6 +2589,21 @@ function acquireStorageLock(rootDir) {
1008
2589
  }
1009
2590
  }
1010
2591
  }
2592
+ /**
2593
+ * Run `execute` while holding the same storage write lock the store uses, without
2594
+ * the version-gated {@link FileTaskStore} constructor. The upgrade orchestrator
2595
+ * uses this to re-pin the committed revision under the lock against a source Home
2596
+ * whose schema is not the current version (so a store cannot be constructed yet).
2597
+ */
2598
+ export function withStorageWriteLock(rootDir, execute) {
2599
+ const release = acquireStorageLock(rootDir);
2600
+ try {
2601
+ return execute();
2602
+ }
2603
+ finally {
2604
+ release();
2605
+ }
2606
+ }
1011
2607
  function reclaimDeadLock(lock) {
1012
2608
  try {
1013
2609
  const age = Date.now() - statSync(lock).mtimeMs;