@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
@@ -1,256 +1,1753 @@
1
- import { rmdir } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
1
+ import { lstat, mkdir, readlink, readdir, rmdir, symlink, unlink } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
3
5
  import { updateRole } from "../role/role.js";
4
- import { createRoleWorkspace } from "../worktree/roleWorkspace.js";
5
- import { NodeGitWorkspace } from "./gitWorkspace.js";
6
+ import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
7
+ import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
8
+ import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
9
+ import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
10
+ const MAIN_WORKTREE = "main";
11
+ const LEADER_ROLE = "leader";
12
+ export class WorkspaceCleanupBlockedError extends Error {
13
+ reason;
14
+ resource;
15
+ retryable;
16
+ constructor(reason, resource, retryable, message) {
17
+ super(message);
18
+ this.reason = reason;
19
+ this.resource = resource;
20
+ this.retryable = retryable;
21
+ this.name = "WorkspaceCleanupBlockedError";
22
+ }
23
+ }
24
+ /** Existing ReviewRound workspace evidence must never be replaced during recovery. */
25
+ export class ReviewRoundWorkspaceEvidenceError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "ReviewRoundWorkspaceEvidenceError";
29
+ }
30
+ }
6
31
  /**
7
- * Prepares one deterministic worktree per Task Role. Git and the deterministic
8
- * path remain the physical truth; the compact RoleWorkspace records only the
9
- * identity needed to validate launches and safely reconcile archive cleanup.
32
+ * A Task owns one workspace root containing every bound Project. A WorkItem
33
+ * owns another root: writable Projects point at isolated worktrees while the
34
+ * remaining entries point at the Task main worktrees as read-only context.
10
35
  */
11
36
  export class FileTaskWorkspacePreparer {
37
+ home;
12
38
  store;
13
39
  git;
14
40
  now;
15
- worktreeRoot;
16
41
  constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date()) {
42
+ this.home = home;
17
43
  this.store = store;
18
44
  this.git = git;
19
45
  this.now = now;
20
- this.worktreeRoot = join(resolve(home), "worktrees");
21
46
  }
22
47
  async prepareTaskWorkspace(taskId) {
23
48
  const task = requireTask(this.store, taskId);
24
- if (task.repositoryId === undefined) {
25
- return {
26
- taskId,
27
- status: task.status === "draft" ? "draft" : "ready",
28
- ...(task.cwd === undefined ? {} : { path: task.cwd })
29
- };
49
+ if (!["draft", "active"].includes(task.status)) {
50
+ throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
30
51
  }
31
- const repository = this.store.getRepository(task.repositoryId);
32
- if (repository === null)
33
- throw new Error(`Repository not found: ${task.repositoryId}.`);
34
- if (task.status === "draft")
35
- return { taskId, status: "draft" };
36
- if (task.status === "archived")
37
- return this.#cleanupArchived(task, repository.path);
38
- const taskRoot = this.#taskRoot(task.id);
39
- if (task.cwd !== undefined && resolve(task.cwd) !== taskRoot) {
40
- throw new Error(`Task workspace root does not match its deterministic path: ${task.id}.`);
41
- }
42
- const baseRef = task.baseRef ?? repository.defaultBranch;
43
- // A Task base branch may be deleted after its first Role worktree is
44
- // created. The persisted starting commit remains a stable base for Roles
45
- // added later without introducing a separate ref ledger.
46
- const physicalBase = this.store.listRoleWorkspaces(task.id)[0]?.baseCommit ?? baseRef;
47
- const prepared = [];
48
- for (const role of this.store.listRoles(task.id)) {
49
- const workspace = await this.git.ensureWorktree({
50
- repositoryPath: repository.path,
51
- container: this.worktreeRoot,
52
- taskId: task.id,
53
- roleName: role.name,
54
- baseRef: physicalBase
52
+ if (task.projectBindings.length === 0) {
53
+ const existing = this.store.getTaskWorkspace(task.id);
54
+ const root = this.#taskWorkspaceRoot(task.id);
55
+ if (existing !== null && (existing.owner.type !== "task"
56
+ || existing.owner.taskId !== task.id
57
+ || existing.root !== root
58
+ || existing.entries.length !== 0)) {
59
+ throw new Error(`Gitless Task workspace ownership is invalid: ${task.id}.`);
60
+ }
61
+ // Gitless Tasks still need a durable runtime owner. The empty view is
62
+ // Task-specific, so launches cannot fall back to a global workspace or
63
+ // repository root while no Project is bound.
64
+ await ensureWorkspaceView(root, []);
65
+ const workspace = createManagedWorkspace({
66
+ owner: { type: "task", taskId: task.id },
67
+ root,
68
+ entries: []
69
+ }, this.now());
70
+ this.store.transaction((tx) => {
71
+ const latest = requireTask(tx, task.id);
72
+ if (!['draft', 'active'].includes(latest.status)
73
+ || latest.projectBindings.length !== 0) {
74
+ throw new Error(`Task changed while preparing its Gitless workspace: ${task.id}.`);
75
+ }
76
+ const current = tx.getTaskWorkspace(task.id);
77
+ // `createManagedWorkspace` stamps a fresh updatedAt on every call.
78
+ // Gitless preparation is idempotent, so compare only stable identity
79
+ // and retain the existing durable record verbatim.
80
+ if (current !== null && !sameManagedWorkspaceIdentity(current, workspace)) {
81
+ throw new Error(`Gitless Task workspace changed during preparation: ${task.id}.`);
82
+ }
83
+ const timestamp = this.now();
84
+ if (latest.cwd !== root) {
85
+ tx.saveTask({ ...latest, cwd: root, updatedAt: timestamp.toISOString() });
86
+ }
87
+ if (current === null)
88
+ tx.saveManagedWorkspace(workspace);
89
+ for (const role of tx.listRoles(task.id)) {
90
+ if (role.workspace !== root) {
91
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
92
+ tx.saveRole(task.id, updateRole(role, { workspace: root }, timestamp));
93
+ }
94
+ }
55
95
  });
56
- prepared.push({ roleName: role.name, ...workspace });
57
- }
58
- const ready = this.store.transaction((store) => {
59
- const latest = requireTask(store, task.id);
60
- if (latest.status !== "active")
61
- return false;
62
- if (latest.repositoryId !== repository.id) {
63
- throw new Error(`Task repository changed while preparing its workspaces: ${task.id}.`);
64
- }
65
- const now = this.now();
66
- for (const physical of prepared) {
67
- const role = store.getRole(task.id, physical.roleName);
68
- if (role === null)
96
+ return { taskId, status: "ready", path: root };
97
+ }
98
+ const existing = this.store.getTaskWorkspace(task.id);
99
+ if (existing !== null && existing.owner.type !== "task") {
100
+ throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
101
+ }
102
+ const root = this.#taskWorkspaceRoot(task.id);
103
+ const prepared = [];
104
+ const defaultProjects = remoteDefaultProjects(this.store, task.id);
105
+ const baselines = new Map();
106
+ try {
107
+ // Resolve every first-use baseline before creating any managed worktree.
108
+ // A later Project failure therefore cannot leave an earlier Project's
109
+ // workspace behind, and the Task can only be pinned after all evidence
110
+ // has been gathered successfully.
111
+ for (const binding of task.projectBindings) {
112
+ const project = requireProject(this.store, binding.projectId);
113
+ const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
114
+ if (previous !== undefined) {
115
+ baselines.set(project.id, {
116
+ baseRef: previous.baseCommit,
117
+ recordedBaseRef: previous.baseRef
118
+ });
69
119
  continue;
70
- const existing = store.getRoleWorkspace(task.id, role.name);
71
- const workspace = existing ?? createRoleWorkspace({
120
+ }
121
+ const useRemoteDefault = defaultProjects.has(project.id)
122
+ && project.remoteUrl !== undefined
123
+ && !looksLikeCommit(binding.baseRef);
124
+ if (useRemoteDefault) {
125
+ const resolver = this.git.resolveRemoteBaseline;
126
+ if (typeof resolver !== "function") {
127
+ throw new Error(`Git workspace cannot resolve the remote baseline for Project: ${project.id}.`);
128
+ }
129
+ const remote = await resolver.call(this.git, {
130
+ repositoryPath: project.path,
131
+ remoteUrl: project.remoteUrl,
132
+ // The binding captured the configured development ref at Task
133
+ // creation; use that snapshot even if the Project catalog was
134
+ // edited before this first workspace preparation.
135
+ developmentRef: binding.baseRef
136
+ });
137
+ baselines.set(project.id, {
138
+ baseRef: remote.commit,
139
+ recordedBaseRef: remote.commit,
140
+ expectedCommit: remote.commit,
141
+ pinTask: true
142
+ });
143
+ continue;
144
+ }
145
+ // Validate local and explicit refs in the same preflight phase. This
146
+ // preserves the fail-closed boundary before any worktree is created.
147
+ await this.git.inspect(project.path, binding.baseRef);
148
+ baselines.set(project.id, {
149
+ baseRef: binding.baseRef,
150
+ recordedBaseRef: binding.baseRef
151
+ });
152
+ }
153
+ for (const binding of task.projectBindings) {
154
+ const project = requireProject(this.store, binding.projectId);
155
+ const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
156
+ const baseline = baselines.get(project.id);
157
+ if (baseline === undefined) {
158
+ throw new Error(`Task Project baseline was not resolved: ${project.id}.`);
159
+ }
160
+ const physical = await this.git.ensureWorktree({
161
+ repositoryPath: project.path,
162
+ container: this.#projectContainer(project.name),
72
163
  taskId: task.id,
73
- roleName: role.name,
74
- repositoryId: repository.id,
75
- path: physical.path,
76
- branch: physical.branch,
77
- baseRef,
78
- baseCommit: physical.baseCommit
79
- }, now);
80
- assertWorkspaceIdentity(workspace, {
164
+ roleName: MAIN_WORKTREE,
165
+ baseRef: previous?.baseCommit ?? baseline.baseRef
166
+ });
167
+ if (baseline.expectedCommit !== undefined
168
+ && physical.baseCommit !== baseline.expectedCommit) {
169
+ throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
170
+ }
171
+ prepared.push({
172
+ project,
173
+ entry: {
174
+ projectId: project.id,
175
+ directory: binding.directory,
176
+ access: "write",
177
+ path: physical.path,
178
+ branch: physical.branch,
179
+ baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
180
+ baseCommit: physical.baseCommit
181
+ }
182
+ });
183
+ }
184
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
185
+ const workspace = createManagedWorkspace({
186
+ owner: { type: "task", taskId: task.id },
187
+ root,
188
+ entries: prepared.map(({ entry }) => entry)
189
+ }, this.now());
190
+ this.store.transaction((tx) => {
191
+ const latest = requireTask(tx, task.id);
192
+ if (!["draft", "active"].includes(latest.status)) {
193
+ throw new Error(`Task changed while preparing its workspace: ${task.id}.`);
194
+ }
195
+ if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
196
+ throw new Error(`Task Projects changed while preparing its workspace: ${task.id}.`);
197
+ }
198
+ const current = tx.getTaskWorkspace(task.id);
199
+ if (current !== null && current.owner.type !== "task") {
200
+ throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
201
+ }
202
+ const pinnedBindings = latest.projectBindings.map((binding) => {
203
+ const baseline = baselines.get(binding.projectId);
204
+ return baseline?.pinTask === true
205
+ ? { ...binding, baseRef: baseline.baseRef }
206
+ : binding;
207
+ });
208
+ const timestamp = this.now();
209
+ let persistedTask = isDeepStrictEqual(pinnedBindings, latest.projectBindings)
210
+ ? latest
211
+ : { ...latest, projectBindings: pinnedBindings, updatedAt: timestamp.toISOString() };
212
+ if (persistedTask.cwd !== root) {
213
+ persistedTask = { ...persistedTask, cwd: root, updatedAt: timestamp.toISOString() };
214
+ }
215
+ if (!isDeepStrictEqual(persistedTask, latest)) {
216
+ tx.saveTask(persistedTask);
217
+ }
218
+ tx.saveManagedWorkspace(preserveWorkspaceCreatedAt(workspace, current));
219
+ for (const role of tx.listRoles(task.id)) {
220
+ // The Role field is only a cwd/snapshot hint. Preserve the hint for
221
+ // an active WorkItem assignment; the durable owner is the WorkItem,
222
+ // not this Role record. Other Roles use Task main.
223
+ const assignedItem = tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
224
+ && !["completed", "failed", "retired"]
225
+ .includes(candidate.status)));
226
+ const assignedWorkspace = assignedItem === undefined
227
+ ? null
228
+ : tx.getWorkItemWorkspace(task.id, assignedItem.id);
229
+ const target = assignedWorkspace?.root ?? root;
230
+ if (role.workspace !== target) {
231
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
232
+ tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
233
+ }
234
+ }
235
+ });
236
+ return { taskId, status: "ready", path: root };
237
+ }
238
+ catch (error) {
239
+ await this.#discardUnadoptedEntries(task, prepared, MAIN_WORKTREE);
240
+ throw error;
241
+ }
242
+ }
243
+ async snapshotCandidateWorkspace(workspace) {
244
+ if (workspace.owner.type === "review-round") {
245
+ throw new Error("ReviewRound workspace cannot become a WorkItem Candidate source.");
246
+ }
247
+ if (workspace.owner.type !== "work-item") {
248
+ throw new Error("Only a WorkItem workspace can become a Candidate source.");
249
+ }
250
+ const projects = [];
251
+ for (const entry of workspace.entries) {
252
+ if (!await this.git.isClean(entry.path)) {
253
+ throw new Error(`Candidate Project workspace must be clean and committed before review: ${entry.projectId}.`);
254
+ }
255
+ const branch = await this.git.headRef(entry.path);
256
+ if (branch !== entry.branch) {
257
+ throw new Error(`Candidate Project workspace left its managed branch: ${entry.projectId}/${branch}.`);
258
+ }
259
+ projects.push({
260
+ projectId: entry.projectId,
261
+ commit: (await this.git.inspect(entry.path, "HEAD")).baseCommit
262
+ });
263
+ }
264
+ return createCandidateGitSnapshot(workspace, projects);
265
+ }
266
+ /** Freeze every writable Project head from the exact managed Lane workspace. */
267
+ async snapshotExecutionLaneWorkspace(workspace) {
268
+ if (workspace.owner.type !== "execution-lane"
269
+ && workspace.owner.type !== "work-item"
270
+ && workspace.owner.type !== "review-round") {
271
+ throw new Error("Only a managed execution workspace can freeze Lane output.");
272
+ }
273
+ const stored = this.store.getManagedWorkspace(workspace.owner);
274
+ if (stored === null || !isDeepStrictEqual(stored, workspace)) {
275
+ throw new Error("Execution Lane managed workspace is not the durable owner.");
276
+ }
277
+ const writable = workspace.entries.filter(({ access }) => access === "write");
278
+ // Read-only and Gitless Lanes still have a normal terminal lifecycle, but
279
+ // they have no Git output boundary to freeze. Keep the result absent
280
+ // instead of inventing a snapshot from context-only worktrees.
281
+ if (writable.length === 0)
282
+ return undefined;
283
+ const projects = [];
284
+ for (const entry of writable) {
285
+ if (!await this.git.isClean(entry.path)) {
286
+ throw new Error(`Execution Lane Project workspace is dirty: ${entry.projectId}.`);
287
+ }
288
+ const branch = await this.git.headRef(entry.path);
289
+ if (branch !== entry.branch) {
290
+ throw new Error(`Execution Lane Project workspace left its managed branch: ${entry.projectId}/${branch}.`);
291
+ }
292
+ projects.push({
293
+ projectId: entry.projectId,
294
+ headCommit: (await this.git.inspect(entry.path, "HEAD")).baseCommit,
295
+ branch
296
+ });
297
+ }
298
+ return { schemaVersion: 1, projects };
299
+ }
300
+ async snapshotDirectTaskMain(workspace, projectIds) {
301
+ if (workspace.owner.type !== "task") {
302
+ throw new Error("Only Task main can become a direct Candidate source.");
303
+ }
304
+ const selected = new Set(projectIds);
305
+ const entries = workspace.entries.filter(({ projectId }) => selected.has(projectId));
306
+ if (entries.length !== selected.size) {
307
+ throw new Error("Direct Candidate Project scope does not match Task main.");
308
+ }
309
+ const projects = [];
310
+ for (const entry of entries) {
311
+ if (entry.access !== "write") {
312
+ throw new Error(`Direct Candidate Project is not writable: ${entry.projectId}.`);
313
+ }
314
+ if (!await this.git.isClean(entry.path)) {
315
+ throw new Error(`Direct Candidate Task main must be clean and committed: ${entry.projectId}.`);
316
+ }
317
+ const branch = await this.git.headRef(entry.path);
318
+ if (branch !== entry.branch) {
319
+ throw new Error(`Direct Candidate Task main left its managed branch: ${entry.projectId}/${branch}.`);
320
+ }
321
+ const headCommit = (await this.git.inspect(entry.path, "HEAD")).baseCommit;
322
+ if (!await this.git.isAncestor(entry.path, entry.baseCommit, headCommit)) {
323
+ throw new Error(`Direct Candidate Task main does not descend from its recorded base: ${entry.projectId}.`);
324
+ }
325
+ projects.push({ projectId: entry.projectId, headCommit });
326
+ }
327
+ return createDirectTaskMainSnapshot(workspace, projectIds, projects);
328
+ }
329
+ async prepareWorkItemWorkspace(taskId, workItemId) {
330
+ const item = requireWorkItem(this.store, taskId, workItemId);
331
+ const task = requireTask(this.store, item.taskId);
332
+ assertWorkItemWorkspaceEligible(this.store, task, item);
333
+ await this.prepareTaskWorkspace(task.id);
334
+ const main = this.store.getTaskWorkspace(task.id);
335
+ if (main === null || main.owner.type !== "task") {
336
+ throw new Error(`Task main workspace is not ready: ${task.id}.`);
337
+ }
338
+ const existing = this.store.getWorkItemWorkspace(task.id, item.id);
339
+ if (existing !== null && (existing.owner.type !== "work-item"
340
+ || existing.owner.workItemId !== item.id)) {
341
+ throw new Error(`WorkItem workspace owner is invalid: ${task.id}/${item.id}.`);
342
+ }
343
+ if (item.assignee !== undefined) {
344
+ assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
345
+ }
346
+ const writeProjects = new Set(item.writeProjectIds);
347
+ const boundProjects = new Set(task.projectBindings.map(({ projectId }) => projectId));
348
+ for (const baseRef of item.baseRefs ?? []) {
349
+ if (!boundProjects.has(baseRef.projectId)) {
350
+ throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
351
+ }
352
+ if (!writeProjects.has(baseRef.projectId)) {
353
+ throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
354
+ }
355
+ }
356
+ const root = this.#workItemWorkspaceRoot(task.id, item.id);
357
+ const prepared = [];
358
+ try {
359
+ for (const binding of task.projectBindings) {
360
+ const project = requireProject(this.store, binding.projectId);
361
+ const mainEntry = requireWorkspaceEntry(main, project.id);
362
+ if (!writeProjects.has(project.id)) {
363
+ prepared.push({
364
+ project,
365
+ entry: { ...mainEntry, access: "read" }
366
+ });
367
+ continue;
368
+ }
369
+ const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
370
+ const head = await this.git.inspect(mainEntry.path, "HEAD");
371
+ const requestedBaseRef = item.baseRefs?.find(({ projectId }) => (projectId === project.id))?.baseRef;
372
+ const baseRef = previous?.access === "write"
373
+ ? previous.baseCommit
374
+ : requestedBaseRef ?? head.baseCommit;
375
+ const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
376
+ ? null
377
+ : await this.git.inspect(project.path, requestedBaseRef);
378
+ const physical = await this.git.ensureWorktree({
379
+ repositoryPath: project.path,
380
+ container: this.#projectContainer(project.name),
81
381
  taskId: task.id,
82
- roleName: role.name,
83
- repositoryId: repository.id,
84
- path: physical.path,
85
- branch: physical.branch,
382
+ roleName: item.id,
86
383
  baseRef
87
384
  });
88
- if (role.workspace !== workspace.path) {
89
- store.saveRole(task.id, updateRole(role, { workspace: workspace.path }, now));
90
- }
91
- if (existing === null)
92
- store.saveRoleWorkspace(task.id, workspace);
93
- }
94
- const roles = store.listRoles(task.id);
95
- const allReady = roles.length > 0 && roles.every((role) => {
96
- const workspace = store.getRoleWorkspace(task.id, role.name);
97
- return workspace !== null
98
- && workspace.repositoryId === repository.id
99
- && workspace.path === role.workspace;
385
+ const entry = {
386
+ projectId: project.id,
387
+ directory: binding.directory,
388
+ access: "write",
389
+ path: physical.path,
390
+ branch: physical.branch,
391
+ baseRef: previous?.access === "write" ? previous.baseRef : baseRef,
392
+ // The recorded base is the immutable capture boundary. An existing
393
+ // worktree reports its current HEAD from ensureWorktree(), which
394
+ // may already contain committed Worker changes and must never
395
+ // replace that boundary during scope expansion or reconciliation.
396
+ baseCommit: previous?.access === "write"
397
+ ? previous.baseCommit
398
+ : physical.baseCommit
399
+ };
400
+ // Track the physical entry before any postcondition check. If a
401
+ // freshly attached deterministic branch is rejected below, the
402
+ // catch-path must remove its unadopted worktree even though no
403
+ // durable ManagedWorkspace has been written yet.
404
+ prepared.push({ project, entry });
405
+ if (previous?.access === "write" && (physical.path !== previous.path
406
+ || physical.branch !== previous.branch)) {
407
+ throw new Error(`Existing WorkItem Project workspace identity changed: ${item.id}/${project.id}.`);
408
+ }
409
+ if (previous?.access === "write") {
410
+ if (requestedBaseRef === undefined && previous.baseRef !== previous.baseCommit) {
411
+ throw new Error(`Existing WorkItem Project base ref record changed: ${item.id}/${project.id}.`);
412
+ }
413
+ if (requestedBaseRef !== undefined && requestedBaseRef !== previous.baseRef) {
414
+ throw new Error(`Existing WorkItem Project base ref changed: ${item.id}/${project.id}.`);
415
+ }
416
+ if (!await this.git.isAncestor(project.path, previous.baseCommit, physical.baseCommit)) {
417
+ throw new Error(`Existing WorkItem Project HEAD does not descend from its frozen base: `
418
+ + `${item.id}/${project.id}.`);
419
+ }
420
+ }
421
+ else if (requestedBase !== null && physical.baseCommit !== requestedBase.baseCommit) {
422
+ throw new Error(`WorkItem Project workspace did not start at its requested base ref: `
423
+ + `${item.id}/${project.id}.`);
424
+ }
425
+ }
426
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
427
+ const workspace = createManagedWorkspace({
428
+ owner: { type: "work-item", taskId: task.id, workItemId: item.id },
429
+ root,
430
+ entries: prepared.map(({ entry }) => entry)
431
+ }, this.now());
432
+ return this.store.transaction((tx) => {
433
+ const latestTask = requireTask(tx, task.id);
434
+ const latestItem = tx.getWorkItem(task.id, item.id);
435
+ if (latestTask.status !== "active" || latestItem === null) {
436
+ throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
437
+ }
438
+ if (latestItem.revision !== item.revision
439
+ || !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
440
+ throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
441
+ }
442
+ const activeDevelopRun = tx.listAgentRuns(task.id)
443
+ .find((run) => run.status === "active" && run.workItemId === item.id);
444
+ if (activeDevelopRun !== undefined) {
445
+ throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
446
+ }
447
+ if (latestItem.assignee !== undefined
448
+ && tx.getActiveAgentRun(task.id, latestItem.assignee) !== null) {
449
+ throw new Error(`Role has an active Run: ${task.id}/${latestItem.assignee}.`);
450
+ }
451
+ const current = tx.getWorkItemWorkspace(task.id, item.id);
452
+ if (current !== null && (current.owner.type !== "work-item"
453
+ || current.owner.workItemId !== item.id)) {
454
+ throw new Error(`WorkItem workspace changed: ${task.id}/${item.id}.`);
455
+ }
456
+ const timestamp = this.now();
457
+ const stored = preserveWorkspaceCreatedAt(workspace, current);
458
+ tx.saveManagedWorkspace(stored);
459
+ if (latestItem.assignee !== undefined) {
460
+ const latestRole = tx.getRole(task.id, latestItem.assignee);
461
+ if (latestRole !== null && latestRole.workspace !== root) {
462
+ retireWorkspaceBoundSession(tx, task.id, latestItem.assignee, timestamp);
463
+ tx.saveRole(task.id, updateRole(latestRole, { workspace: root }, timestamp));
464
+ }
465
+ }
466
+ return stored;
100
467
  });
101
- if (allReady && latest.cwd !== taskRoot) {
102
- store.saveTask({ ...latest, cwd: taskRoot, updatedAt: now.toISOString() });
468
+ }
469
+ catch (error) {
470
+ await this.#discardUnadoptedEntries(task, prepared, item.id);
471
+ throw error;
472
+ }
473
+ }
474
+ /**
475
+ * Prepare/reuse a durable physical workspace for an ExecutionGroup Lane.
476
+ * Git identity, ownership, and cleanup are delegated to this preparer; the
477
+ * command layer only consumes the resulting managed record.
478
+ */
479
+ async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint) {
480
+ const task = requireTask(this.store, taskId);
481
+ const lineage = executionLaneLineage(this.store, task, executionGroupId, executionLaneId, hint);
482
+ const source = lineage.purpose === "execution"
483
+ ? this.store.getWorkItemWorkspace(taskId, lineage.workItemId)
484
+ : this.store.getReviewRoundWorkspace(taskId, lineage.reviewRoundId);
485
+ if (source === null) {
486
+ throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
487
+ }
488
+ const owner = lineage.purpose === "execution"
489
+ ? {
490
+ type: "execution-lane",
491
+ taskId,
492
+ executionGroupId,
493
+ executionLaneId,
494
+ purpose: "execution",
495
+ workItemId: lineage.workItemId
103
496
  }
104
- else if (!allReady && latest.cwd !== undefined) {
105
- const { cwd: _cwd, ...withoutWorkspace } = latest;
106
- store.saveTask({ ...withoutWorkspace, updatedAt: now.toISOString() });
497
+ : {
498
+ type: "execution-lane",
499
+ taskId,
500
+ executionGroupId,
501
+ executionLaneId,
502
+ purpose: "review",
503
+ reviewRoundId: lineage.reviewRoundId
504
+ };
505
+ const existing = this.store.getManagedWorkspace(owner);
506
+ if (existing !== null) {
507
+ if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
508
+ throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
107
509
  }
108
- return allReady;
109
- });
110
- if (ready)
111
- return { taskId, status: "ready", path: taskRoot };
112
- // Archive may win while Git is creating worktrees. Re-read state and use
113
- // the same dirty-preserving archive policy before any scheduler delivery.
114
- const latest = requireTask(this.store, task.id);
115
- if (latest.status === "archived")
116
- return this.#cleanupArchived(latest, repository.path);
117
- return { taskId, status: latest.status === "draft" ? "draft" : "pending" };
118
- }
119
- async reconcileTaskWorkspaces() {
120
- return [
121
- ...await this.prepareActiveTaskWorkspaces(),
122
- ...await this.cleanupArchivedTaskWorkspaces()
123
- ];
124
- }
125
- async prepareActiveTaskWorkspaces() {
126
- const results = [];
127
- for (const task of this.store.listTasks()) {
128
- if (task.repositoryId === undefined || task.status !== "active")
510
+ await ensureWorkspaceView(existing.root, existing.entries);
511
+ for (const entry of existing.entries.filter(({ access }) => access === "write")) {
512
+ const project = requireProject(this.store, entry.projectId);
513
+ const physical = await this.git.ensureWorktree({
514
+ repositoryPath: project.path,
515
+ container: this.#projectContainer(project.name),
516
+ taskId,
517
+ roleName: managedWorktreeName(owner),
518
+ baseRef: entry.baseCommit
519
+ });
520
+ if (physical.path !== entry.path || physical.branch !== entry.branch) {
521
+ throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
522
+ }
523
+ }
524
+ return existing;
525
+ }
526
+ const item = lineage.purpose === "execution"
527
+ ? this.store.getWorkItem(taskId, lineage.workItemId)
528
+ : null;
529
+ const writable = new Set(lineage.purpose === "execution"
530
+ ? item?.writeProjectIds ?? []
531
+ : task.projectBindings.map(({ projectId }) => projectId));
532
+ const prepared = [];
533
+ try {
534
+ for (const binding of task.projectBindings) {
535
+ const project = requireProject(this.store, binding.projectId);
536
+ const sourceEntry = requireWorkspaceEntry(source, project.id);
537
+ if (!writable.has(project.id)) {
538
+ prepared.push({ project, entry: { ...sourceEntry, access: "read" } });
539
+ continue;
540
+ }
541
+ const physical = await this.git.ensureWorktree({
542
+ repositoryPath: project.path,
543
+ container: this.#projectContainer(project.name),
544
+ taskId,
545
+ roleName: managedWorktreeName(owner),
546
+ baseRef: sourceEntry.baseCommit
547
+ });
548
+ prepared.push({
549
+ project,
550
+ entry: {
551
+ ...sourceEntry,
552
+ access: "write",
553
+ path: physical.path,
554
+ branch: physical.branch,
555
+ baseRef: sourceEntry.baseCommit,
556
+ baseCommit: sourceEntry.baseCommit
557
+ }
558
+ });
559
+ }
560
+ const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
561
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
562
+ const workspace = createManagedWorkspace({
563
+ owner,
564
+ root,
565
+ entries: prepared.map(({ entry }) => entry)
566
+ }, this.now());
567
+ const durableLane = this.store.listWorkItems(taskId).some((item) => (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId))) || this.store.listReviewRounds(taskId).some((round) => (round.executionGroup?.id === executionGroupId
568
+ && round.executionGroup.lanes.some(({ id }) => id === executionLaneId)));
569
+ if (durableLane) {
570
+ return this.store.transaction((tx) => {
571
+ const current = tx.getManagedWorkspace(owner);
572
+ if (current !== null && !isDeepStrictEqual(current, workspace)) {
573
+ throw new Error(`Execution Lane workspace changed before adoption: ${executionLaneId}.`);
574
+ }
575
+ tx.saveManagedWorkspace(current ?? workspace);
576
+ return current ?? workspace;
577
+ });
578
+ }
579
+ return workspace;
580
+ }
581
+ catch (error) {
582
+ await this.#discardUnadoptedEntries(task, prepared, managedWorktreeName(owner), true);
583
+ throw error;
584
+ }
585
+ }
586
+ /**
587
+ * Compensate a command preflight when its later aggregate transaction does
588
+ * not adopt the prepared Lane. Existing durable owners are never removed.
589
+ */
590
+ async discardUnadoptedExecutionLaneWorkspaces(workspaces) {
591
+ if (workspaces === undefined)
592
+ return;
593
+ for (const workspace of workspaces.values()) {
594
+ if (this.store.getManagedWorkspace(workspace.owner) !== null)
129
595
  continue;
130
- try {
131
- results.push(await this.prepareTaskWorkspace(task.id));
596
+ const result = await this.discardUnadoptedExecutionLaneWorkspace(workspace);
597
+ if (result === "dirty") {
598
+ throw new Error(`Unadopted Execution Lane workspace is dirty and was retained: ${workspace.root}.`);
132
599
  }
133
- catch (error) {
134
- results.push(failedPreparation(task.id, error));
600
+ }
601
+ }
602
+ /** Merge every selected lane's committed output into the WorkItem owner. */
603
+ async materializeExecutionGroupCandidate(taskId, workItemId, executionGroupId, selectedLaneIds) {
604
+ const item = requireWorkItem(this.store, taskId, workItemId);
605
+ const task = requireTask(this.store, taskId);
606
+ const group = workItemExecutionGroupById(item, executionGroupId);
607
+ if (group === undefined || group.id !== executionGroupId) {
608
+ throw new Error(`ExecutionGroup is not attached to Work Item: ${executionGroupId}.`);
609
+ }
610
+ const workspace = this.store.getWorkItemWorkspace(taskId, workItemId);
611
+ const selected = (selectedLaneIds.length === 0
612
+ ? group.lanes.filter((lane) => lane.status === "yielded" || lane.status === "completed")
613
+ : group.lanes.filter((lane) => selectedLaneIds.includes(lane.id)));
614
+ if (selected.some((lane) => !["yielded", "completed"].includes(lane.status))) {
615
+ throw new Error(`ExecutionGroup has a selected Lane without terminal output: ${executionGroupId}.`);
616
+ }
617
+ if (selected.length === 0)
618
+ throw new Error(`ExecutionGroup has no selected Lane outputs: ${executionGroupId}.`);
619
+ // A Gitless Task has no WorkItem workspace to materialize. Its selected
620
+ // Lane reports still form a valid non-Git Candidate; no fake workspace or
621
+ // commit snapshot is introduced.
622
+ if (workspace === null) {
623
+ if (task.projectBindings.length === 0
624
+ && selected.every((lane) => lane.result?.gitSnapshot === undefined)) {
625
+ return { restoreTargets: [] };
626
+ }
627
+ throw new Error(`Work Item workspace is not ready for Candidate materialization: ${workItemId}.`);
628
+ }
629
+ if (workspace.owner.type !== "work-item") {
630
+ throw new Error(`Work Item workspace owner is invalid for Candidate materialization: ${workItemId}.`);
631
+ }
632
+ const pendingMerges = new Map();
633
+ const restoreTargets = [];
634
+ const writableProjectIds = workspace.entries
635
+ .filter(({ access }) => access === "write")
636
+ .map(({ projectId }) => projectId);
637
+ if (writableProjectIds.length === 0) {
638
+ for (const lane of selected) {
639
+ if (lane.result?.gitSnapshot !== undefined) {
640
+ throw new Error(`Read-only Lane must not carry a Git snapshot: ${lane.id}.`);
641
+ }
642
+ }
643
+ return {
644
+ workspace,
645
+ // A read-only WorkItem has no Lane Git output, but its frozen
646
+ // WorkItem workspace is still the legitimate Candidate review
647
+ // context. Keep that Candidate snapshot separate from Lane output.
648
+ snapshot: await this.snapshotCandidateWorkspace(workspace),
649
+ restoreTargets: []
650
+ };
651
+ }
652
+ for (const lane of selected) {
653
+ const laneSnapshot = lane.result?.gitSnapshot;
654
+ if (laneSnapshot === undefined) {
655
+ throw new Error(`Selected Lane has no frozen Git snapshot: ${lane.id}.`);
656
+ }
657
+ const snapshotProjectIds = laneSnapshot.projects.map(({ projectId }) => projectId).sort();
658
+ if (!isDeepStrictEqual(snapshotProjectIds, [...writableProjectIds].sort())) {
659
+ throw new Error(`Selected Lane Git snapshot Project scope changed: ${lane.id}.`);
660
+ }
661
+ const laneWorkspace = this.store.getManagedWorkspace({
662
+ type: "execution-lane",
663
+ taskId,
664
+ executionGroupId,
665
+ executionLaneId: lane.id,
666
+ purpose: "execution",
667
+ workItemId
668
+ });
669
+ if (laneWorkspace === null) {
670
+ // fixed(1) retains the WorkItem owner as its single normal-path workspace.
671
+ if (group.lanes.length === 1) {
672
+ for (const target of workspace.entries.filter(({ access }) => access === "write")) {
673
+ const frozen = laneSnapshot.projects.find(({ projectId }) => projectId === target.projectId);
674
+ if (frozen === undefined)
675
+ throw new Error(`Selected Lane snapshot omits Project: ${lane.id}/${target.projectId}.`);
676
+ if (await this.git.headRef(target.path) !== frozen.branch || !await this.git.isClean(target.path)) {
677
+ throw new Error(`Single-Lane workspace is not clean on its managed branch: ${lane.id}/${target.projectId}.`);
678
+ }
679
+ const targetHead = (await this.git.inspect(target.path, "HEAD")).baseCommit;
680
+ if (targetHead !== frozen.headCommit) {
681
+ throw new Error(`Single-Lane workspace changed after yield: ${lane.id}/${target.projectId}.`);
682
+ }
683
+ }
684
+ continue;
685
+ }
686
+ throw new Error(`Selected Execution Lane workspace is not adopted: ${lane.id}.`);
687
+ }
688
+ for (const target of workspace.entries.filter(({ access }) => access === "write")) {
689
+ const source = laneWorkspace.entries.find(({ projectId }) => projectId === target.projectId);
690
+ if (source === undefined)
691
+ throw new Error(`Selected Lane omits Project: ${lane.id}/${target.projectId}.`);
692
+ const frozen = laneSnapshot.projects.find(({ projectId }) => projectId === target.projectId);
693
+ if (frozen === undefined)
694
+ throw new Error(`Selected Lane snapshot omits Project: ${lane.id}/${target.projectId}.`);
695
+ if (await this.git.headRef(source.path) !== frozen.branch || frozen.branch !== source.branch) {
696
+ throw new Error(`Selected Lane left its managed branch: ${lane.id}/${target.projectId}.`);
697
+ }
698
+ if (!await this.git.isClean(source.path)) {
699
+ throw new Error(`Selected Lane has uncommitted output: ${lane.id}/${target.projectId}.`);
700
+ }
701
+ const sourceHead = (await this.git.inspect(source.path, "HEAD")).baseCommit;
702
+ if (sourceHead !== frozen.headCommit) {
703
+ throw new Error(`Selected Lane advanced after yield: ${lane.id}/${target.projectId}.`);
704
+ }
705
+ const targetHead = (await this.git.inspect(target.path, "HEAD")).baseCommit;
706
+ if (sourceHead !== targetHead && !await this.git.isAncestor(target.path, sourceHead, targetHead)) {
707
+ const pending = pendingMerges.get(target.path) ?? [];
708
+ pending.push(frozen.headCommit);
709
+ pendingMerges.set(target.path, pending);
710
+ if (!restoreTargets.some(({ path }) => path === target.path)) {
711
+ restoreTargets.push({ path: target.path, head: targetHead });
712
+ }
713
+ }
714
+ }
715
+ }
716
+ try {
717
+ for (const [targetPath, sourceRefs] of pendingMerges) {
718
+ await this.git.mergeWorktree({ targetPath, sourceRefs });
719
+ const target = restoreTargets.find(({ path }) => path === targetPath);
720
+ if (target !== undefined) {
721
+ target.mergedHead = (await this.git.inspect(targetPath, "HEAD")).baseCommit;
722
+ }
135
723
  }
724
+ return {
725
+ workspace,
726
+ snapshot: await this.snapshotCandidateWorkspace(workspace),
727
+ restoreTargets: restoreTargets
728
+ .filter((target) => (target.mergedHead !== undefined && target.mergedHead !== target.head))
729
+ };
730
+ }
731
+ catch (error) {
732
+ for (const target of [...restoreTargets].reverse()) {
733
+ if (target.mergedHead === undefined)
734
+ continue;
735
+ try {
736
+ await this.git.resetWorktree({
737
+ targetPath: target.path,
738
+ expectedHead: target.mergedHead,
739
+ restoreHead: target.head
740
+ });
741
+ }
742
+ catch (restoreError) {
743
+ throw new Error(`Candidate materialization failed and Git compensation could not restore ${target.path}.`, { cause: restoreError });
744
+ }
745
+ }
746
+ throw error;
136
747
  }
137
- return results;
138
748
  }
139
- async cleanupArchivedTaskWorkspaces() {
140
- const results = [];
141
- for (const task of this.store.listTasks()) {
142
- if (task.repositoryId === undefined || task.status !== "archived")
749
+ async restoreExecutionGroupCandidateMaterialization(materialization) {
750
+ for (const target of [...materialization.restoreTargets].reverse()) {
751
+ const current = (await this.git.inspect(target.path, "HEAD")).baseCommit;
752
+ if (current === target.head)
143
753
  continue;
144
- try {
145
- results.push(await this.prepareTaskWorkspace(task.id));
754
+ if (current !== target.mergedHead) {
755
+ throw new Error(`Candidate materialization compensation found unexpected Git head: ${target.path}.`);
146
756
  }
147
- catch (error) {
148
- results.push(failedPreparation(task.id, error));
757
+ await this.git.resetWorktree({
758
+ targetPath: target.path,
759
+ expectedHead: target.mergedHead,
760
+ restoreHead: target.head
761
+ });
762
+ }
763
+ }
764
+ async cleanupExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
765
+ const task = requireTask(this.store, taskId);
766
+ const lineage = executionLaneLineage(this.store, task, executionGroupId, executionLaneId);
767
+ const item = lineage.purpose === "execution"
768
+ ? this.store.getWorkItem(taskId, lineage.workItemId)
769
+ : null;
770
+ const group = lineage.purpose === "execution"
771
+ ? (item === null ? undefined : workItemExecutionGroupById(item, executionGroupId))
772
+ : this.store.getReviewRound(taskId, lineage.reviewRoundId)?.executionGroup;
773
+ const lane = group?.lanes.find(({ id }) => id === executionLaneId);
774
+ if (group === undefined
775
+ || lane === undefined || !["completed", "failed", "yielded"].includes(lane.status)) {
776
+ throw new Error(`Execution Lane is not terminally resolved: ${taskId}/${executionLaneId}.`);
777
+ }
778
+ const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
779
+ && owner.executionGroupId === executionGroupId
780
+ && owner.executionLaneId === executionLaneId));
781
+ if (workspace === undefined)
782
+ return "missing";
783
+ const state = await this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
784
+ if (state === "dirty")
785
+ return "dirty";
786
+ let removed = false;
787
+ for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
788
+ const project = requireProject(this.store, entry.projectId);
789
+ const result = await this.git.removeWorktree({
790
+ repositoryPath: project.path,
791
+ container: this.#projectContainer(project.name),
792
+ taskId,
793
+ roleName: managedWorktreeName(workspace.owner),
794
+ deleteBranch: true
795
+ });
796
+ if (result === "dirty")
797
+ return "dirty";
798
+ removed ||= result === "removed";
799
+ }
800
+ await removeWorkspaceView(workspace.root);
801
+ this.store.removeManagedWorkspace(workspace.owner);
802
+ return removed ? "removed" : "missing";
803
+ }
804
+ /** Remove a physical lane that was prepared but never adopted into a Group. */
805
+ async discardUnadoptedExecutionLaneWorkspace(workspace) {
806
+ if (workspace.owner.type !== "execution-lane") {
807
+ throw new Error("Only an execution-lane workspace can be discarded here.");
808
+ }
809
+ if (this.store.getManagedWorkspace(workspace.owner) !== null)
810
+ return "missing";
811
+ const state = await this.#inspectEntries(workspace.owner.taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
812
+ if (state === "dirty")
813
+ return "dirty";
814
+ let removed = false;
815
+ for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
816
+ const project = requireProject(this.store, entry.projectId);
817
+ const result = await this.git.removeWorktree({
818
+ repositoryPath: project.path,
819
+ container: this.#projectContainer(project.name),
820
+ taskId: workspace.owner.taskId,
821
+ roleName: managedWorktreeName(workspace.owner),
822
+ deleteBranch: true
823
+ });
824
+ if (result === "dirty")
825
+ return "dirty";
826
+ removed ||= result === "removed";
827
+ }
828
+ await removeWorkspaceView(workspace.root);
829
+ return removed ? "removed" : "missing";
830
+ }
831
+ async cleanupExecutionLaneWorkspacesForWorkItem(taskId, workItemId) {
832
+ let result = "missing";
833
+ const item = this.store.getWorkItem(taskId, workItemId);
834
+ for (const group of item?.executionGroups ?? [])
835
+ for (const lane of group.lanes) {
836
+ const owner = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane" && owner.purpose === "execution"
837
+ && owner.workItemId === workItemId && owner.executionLaneId === lane.id));
838
+ if (owner !== undefined) {
839
+ if (owner.owner.type !== "execution-lane")
840
+ continue;
841
+ const cleaned = await this.cleanupExecutionLaneWorkspace(taskId, owner.owner.executionGroupId, lane.id);
842
+ if (cleaned === "dirty")
843
+ return cleaned;
844
+ if (cleaned === "removed")
845
+ result = cleaned;
846
+ }
847
+ }
848
+ return result;
849
+ }
850
+ async cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId) {
851
+ let result = "missing";
852
+ const round = this.store.getReviewRound(taskId, reviewRoundId);
853
+ for (const lane of round?.executionGroup?.lanes ?? []) {
854
+ const owner = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane" && owner.purpose === "review"
855
+ && owner.reviewRoundId === reviewRoundId && owner.executionLaneId === lane.id));
856
+ if (owner !== undefined) {
857
+ if (owner.owner.type !== "execution-lane")
858
+ continue;
859
+ const cleaned = await this.cleanupExecutionLaneWorkspace(taskId, owner.owner.executionGroupId, lane.id);
860
+ if (cleaned === "dirty")
861
+ return cleaned;
862
+ if (cleaned === "removed")
863
+ result = cleaned;
149
864
  }
150
865
  }
151
- return results;
866
+ return result;
152
867
  }
153
- async #cleanupArchived(task, repositoryPath) {
154
- const removals = new Map();
155
- const failures = [];
156
- for (const role of this.store.listRoles(task.id)) {
157
- try {
158
- const removal = await this.git.removeWorktree({
159
- repositoryPath,
160
- container: this.worktreeRoot,
868
+ async inspectWorkItemWorkspace(taskId, workItemId) {
869
+ const item = requireWorkItem(this.store, taskId, workItemId);
870
+ const workspace = this.store.getWorkItemWorkspace(item.taskId, item.id);
871
+ if (workspace === null)
872
+ return "missing";
873
+ assertWorkItemOwnsWorkspace(item, workspace);
874
+ return this.#inspectEntries(item.taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
875
+ }
876
+ /** Prepare a fresh, reviewer-writable workspace from the candidate's
877
+ * frozen Develop snapshot. This record is owned by the ReviewRound; it is
878
+ * never used as a ChangeSet capture source. */
879
+ async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
880
+ const task = requireTask(this.store, taskId);
881
+ const round = this.store.getReviewRound(task.id, reviewRoundId);
882
+ if (round === null)
883
+ throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
884
+ if (round.status !== "pending") {
885
+ throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
886
+ }
887
+ const item = requireWorkItem(this.store, task.id, round.workItemId);
888
+ const candidate = item.candidates.find(({ id }) => id === round.candidateId);
889
+ if (candidate === undefined) {
890
+ throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
891
+ }
892
+ const taskScope = (round.scope ?? "work-item") === "task";
893
+ // A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
894
+ // ReviewRound intentionally uses the latest committed Integration heads
895
+ // instead, while retaining the WorkItem/Candidate anchor for storage and
896
+ // lifecycle compatibility.
897
+ const develop = candidate.workspace;
898
+ if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
899
+ throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
900
+ }
901
+ if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
902
+ throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
903
+ }
904
+ const reviewer = this.store.getRole(task.id, round.reviewerRoleName);
905
+ if (reviewer === null) {
906
+ throw new Error(`Reviewer Role not found: ${task.id}/${round.reviewerRoleName}.`);
907
+ }
908
+ const snapshotCommits = new Map((taskScope
909
+ ? round.taskCandidate?.projects ?? []
910
+ : candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
911
+ const frozenEntries = taskScope
912
+ ? task.projectBindings.map((binding) => {
913
+ const commit = snapshotCommits.get(binding.projectId);
914
+ if (commit === undefined) {
915
+ throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
916
+ }
917
+ const project = requireProject(this.store, binding.projectId);
918
+ const identity = worktreeIdentity(task.id, round.id);
919
+ return {
920
+ projectId: binding.projectId,
921
+ directory: binding.directory,
922
+ access: "write",
923
+ path: join(this.#projectContainer(project.name), identity.directory),
924
+ branch: identity.branch,
925
+ baseRef: commit,
926
+ baseCommit: commit
927
+ };
928
+ })
929
+ : develop.entries.map((entry) => {
930
+ const commit = snapshotCommits.get(entry.projectId);
931
+ if (commit === undefined) {
932
+ throw new Error(`Candidate snapshot Project is missing: ${entry.projectId}.`);
933
+ }
934
+ return { ...entry, baseRef: commit, baseCommit: commit };
935
+ });
936
+ if (taskScope && snapshotCommits.size !== task.projectBindings.length) {
937
+ throw new Error(`Task Review candidate Project scope changed: ${round.id}.`);
938
+ }
939
+ const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
940
+ const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
941
+ const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
942
+ const retained = new Map();
943
+ const missing = new Set();
944
+ const adopted = existing?.root === reviewRoot;
945
+ if (existing !== null) {
946
+ if (!adopted) {
947
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace root changed: ${round.id}.`);
948
+ }
949
+ if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, existing)) {
950
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
951
+ }
952
+ if (existing.entries.length !== expectedEntries.size) {
953
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
954
+ }
955
+ for (const entry of existing.entries) {
956
+ const source = expectedEntries.get(entry.projectId);
957
+ if (source === undefined) {
958
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
959
+ }
960
+ if (!sameCommit(entry.baseCommit, source.baseCommit)) {
961
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
962
+ + `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
963
+ }
964
+ if (entry.directory !== source.directory
965
+ || entry.access !== "write"
966
+ || !sameCommit(entry.baseRef, source.baseCommit)) {
967
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
968
+ }
969
+ const project = requireProject(this.store, entry.projectId);
970
+ const identity = worktreeIdentity(task.id, round.id);
971
+ const expectedPath = join(this.#projectContainer(project.name), identity.directory);
972
+ if (entry.path !== expectedPath || entry.branch !== identity.branch) {
973
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
974
+ }
975
+ let physical;
976
+ try {
977
+ physical = await this.git.inspect(entry.path, "HEAD");
978
+ }
979
+ catch (error) {
980
+ try {
981
+ await lstat(entry.path);
982
+ }
983
+ catch (probeError) {
984
+ if (isMissingPath(probeError)) {
985
+ missing.add(entry.projectId);
986
+ continue;
987
+ }
988
+ }
989
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace cannot be inspected for ${round.id}/${entry.projectId}: `
990
+ + `${error instanceof Error ? error.message : String(error)}`);
991
+ }
992
+ if (physical.root !== entry.path) {
993
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed path mismatch for ${round.id}/${entry.projectId}.`);
994
+ }
995
+ const projectRoot = await this.git.inspect(project.path, "HEAD");
996
+ if (physical.gitDirectory !== projectRoot.gitDirectory) {
997
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed Project mismatch for ${round.id}/${entry.projectId}.`);
998
+ }
999
+ let branch;
1000
+ try {
1001
+ branch = await this.git.headRef(entry.path);
1002
+ }
1003
+ catch (error) {
1004
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace branch cannot be verified for `
1005
+ + `${round.id}/${entry.projectId}: `
1006
+ + `${error instanceof Error ? error.message : String(error)}`);
1007
+ }
1008
+ if (branch !== identity.branch) {
1009
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
1010
+ + `expected ${identity.branch}, physical ${branch}.`);
1011
+ }
1012
+ let descendsFromBase;
1013
+ try {
1014
+ descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
1015
+ }
1016
+ catch (error) {
1017
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
1018
+ + `${round.id}/${entry.projectId}: `
1019
+ + `${error instanceof Error ? error.message : String(error)}`);
1020
+ }
1021
+ if (!descendsFromBase) {
1022
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
1023
+ + `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
1024
+ + `physical HEAD ${physical.baseCommit}.`);
1025
+ }
1026
+ retained.set(entry.projectId, { project, entry });
1027
+ }
1028
+ if (adopted && missing.size === 0) {
1029
+ try {
1030
+ await ensureWorkspaceView(reviewRoot, existing.entries);
1031
+ }
1032
+ catch (error) {
1033
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1034
+ + `${error instanceof Error ? error.message : String(error)}`);
1035
+ }
1036
+ if (round.workspace === undefined) {
1037
+ this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
1038
+ }
1039
+ return existing;
1040
+ }
1041
+ // The durable owner record can outlive a controller crash before the
1042
+ // physical review worktrees were adopted. Recreate only missing
1043
+ // physical entries; existing reviewer diagnostics remain attached to
1044
+ // their frozen Candidate provenance.
1045
+ }
1046
+ const prepared = [];
1047
+ try {
1048
+ const sources = adopted
1049
+ ? frozenEntries.filter((source) => missing.has(source.projectId))
1050
+ : frozenEntries;
1051
+ for (const source of sources) {
1052
+ const project = requireProject(this.store, source.projectId);
1053
+ const physical = await this.git.ensureWorktree({
1054
+ repositoryPath: project.path,
1055
+ container: this.#projectContainer(project.name),
161
1056
  taskId: task.id,
162
- roleName: role.name
1057
+ roleName: round.id,
1058
+ baseRef: source.baseCommit
163
1059
  });
164
- removals.set(role.name, removal);
165
- if (removal !== "dirty") {
166
- this.#recordArchivedRoleCleanup(task.id, repositoryPath, role.name);
1060
+ const entry = {
1061
+ ...source,
1062
+ access: "write",
1063
+ path: physical.path,
1064
+ branch: physical.branch,
1065
+ baseRef: source.baseCommit,
1066
+ baseCommit: source.baseCommit
1067
+ };
1068
+ prepared.push({ project, entry });
1069
+ if (!sameCommit(physical.baseCommit, source.baseCommit)) {
1070
+ if (existing === null) {
1071
+ throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
1072
+ + `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
1073
+ }
1074
+ let descendsFromBase;
1075
+ try {
1076
+ descendsFromBase = await this.git.isAncestor(project.path, source.baseCommit, physical.baseCommit);
1077
+ }
1078
+ catch (error) {
1079
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
1080
+ + `${round.id}/${source.projectId}: `
1081
+ + `${error instanceof Error ? error.message : String(error)}`);
1082
+ }
1083
+ if (!descendsFromBase) {
1084
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
1085
+ + `${round.id}/${source.projectId}: expected ancestor ${source.baseCommit}, `
1086
+ + `physical HEAD ${physical.baseCommit}.`);
1087
+ }
167
1088
  }
168
1089
  }
1090
+ const preparedByProject = new Map(prepared.map(({ project, entry }) => [project.id, entry]));
1091
+ const entries = adopted
1092
+ ? frozenEntries.map((source) => {
1093
+ const retainedEntry = retained.get(source.projectId)?.entry;
1094
+ if (retainedEntry !== undefined)
1095
+ return retainedEntry;
1096
+ const preparedEntry = preparedByProject.get(source.projectId);
1097
+ if (preparedEntry === undefined) {
1098
+ throw new Error(`ReviewRound workspace Project could not be reconstructed: `
1099
+ + `${round.id}/${source.projectId}.`);
1100
+ }
1101
+ return preparedEntry;
1102
+ })
1103
+ : prepared.map(({ entry }) => entry);
1104
+ try {
1105
+ await ensureWorkspaceView(reviewRoot, entries);
1106
+ }
169
1107
  catch (error) {
170
- failures.push(`${role.name}: ${error instanceof Error ? error.message : String(error)}`);
1108
+ if (existing !== null) {
1109
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1110
+ + `${error instanceof Error ? error.message : String(error)}`);
1111
+ }
1112
+ throw error;
171
1113
  }
1114
+ const stored = existing ?? createManagedWorkspace({
1115
+ owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
1116
+ root: reviewRoot,
1117
+ entries
1118
+ }, this.now());
1119
+ return this.store.transaction((tx) => {
1120
+ const currentRound = tx.getReviewRound(task.id, round.id);
1121
+ const currentItem = tx.getWorkItem(task.id, item.id);
1122
+ if (currentRound === null || currentRound.status !== "pending"
1123
+ || currentItem === null
1124
+ || !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
1125
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
1126
+ }
1127
+ if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
1128
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
1129
+ }
1130
+ const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
1131
+ if (existing !== null
1132
+ ? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
1133
+ : currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
1134
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
1135
+ }
1136
+ const latestReviewer = tx.getRole(task.id, reviewer.name);
1137
+ if (latestReviewer === null) {
1138
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
1139
+ }
1140
+ if (currentRound.workspace !== undefined
1141
+ && !sameManagedWorkspace(currentRound.workspace, stored)) {
1142
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
1143
+ }
1144
+ const timestamp = this.now();
1145
+ if (currentWorkspace === null)
1146
+ tx.saveManagedWorkspace(stored);
1147
+ if (currentRound.workspace === undefined) {
1148
+ tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
1149
+ }
1150
+ if (latestReviewer.workspace !== stored.root) {
1151
+ retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
1152
+ tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
1153
+ }
1154
+ return stored;
1155
+ });
172
1156
  }
173
- const dirty = [...removals.values()].some((removal) => removal === "dirty");
174
- if (failures.length > 0) {
175
- return {
1157
+ catch (error) {
1158
+ await this.#discardUnadoptedEntries(task, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1159
+ throw error;
1160
+ }
1161
+ }
1162
+ async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
1163
+ const round = this.store.getReviewRound(taskId, reviewRoundId);
1164
+ if (round === null)
1165
+ throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
1166
+ const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
1167
+ if (workspace === null)
1168
+ return "missing";
1169
+ assertReviewRoundOwnsWorkspace(round.id, workspace);
1170
+ if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, workspace)) {
1171
+ throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1172
+ }
1173
+ return this.#inspectEntries(taskId, round.id, workspace.entries);
1174
+ }
1175
+ async snapshotReviewRoundResult(taskId, reviewRoundId) {
1176
+ const round = this.store.getReviewRound(taskId, reviewRoundId);
1177
+ if (round === null)
1178
+ throw new Error(`ReviewRound not found: ${taskId}/${reviewRoundId}.`);
1179
+ const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
1180
+ if (round.status !== "running" || workspace === null) {
1181
+ throw new Error(`ReviewRound is not running in a managed workspace: ${reviewRoundId}.`);
1182
+ }
1183
+ assertReviewRoundOwnsWorkspace(round.id, workspace);
1184
+ if (round.workspace === undefined || !isDeepStrictEqual(round.workspace, workspace)) {
1185
+ throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1186
+ }
1187
+ return this.#snapshotReviewWorkspaceEntries(round.id, workspace);
1188
+ }
1189
+ /**
1190
+ * Snapshot the exact workspace recorded on one Reviewer Run. Panel Lanes
1191
+ * own separate durable workspaces; the ReviewRound's shared workspace is
1192
+ * not a valid substitute for a Lane's branch/head evidence.
1193
+ */
1194
+ async snapshotReviewRunResult(taskId, run) {
1195
+ if (run.taskId !== taskId || run.purpose !== "review" || run.reviewRoundId === undefined) {
1196
+ throw new Error(`Run is not an exact ReviewRound Run: ${run.id}.`);
1197
+ }
1198
+ if (run.workspace === undefined) {
1199
+ throw new Error(`Review Run has no managed workspace: ${run.id}.`);
1200
+ }
1201
+ const round = this.store.getReviewRound(taskId, run.reviewRoundId);
1202
+ if (round === null || round.status !== "running") {
1203
+ throw new Error(`ReviewRound is not running for Review Run: ${run.id}.`);
1204
+ }
1205
+ const stored = this.store.getManagedWorkspace(run.workspace.owner);
1206
+ if (stored === null || !isDeepStrictEqual(stored, run.workspace)) {
1207
+ throw new Error(`Review Run workspace is not the durable owner: ${run.id}.`);
1208
+ }
1209
+ if (run.workspace.owner.type === "review-round") {
1210
+ if (run.workspace.owner.taskId !== taskId
1211
+ || run.workspace.owner.reviewRoundId !== round.id
1212
+ || round.workspace === undefined
1213
+ || !isDeepStrictEqual(round.workspace, run.workspace)) {
1214
+ throw new Error(`Review Run workspace owner does not match its ReviewRound: ${run.id}.`);
1215
+ }
1216
+ return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
1217
+ }
1218
+ if (run.workspace.owner.type !== "execution-lane"
1219
+ || run.workspace.owner.taskId !== taskId
1220
+ || run.workspace.owner.purpose !== "review"
1221
+ || run.workspace.owner.reviewRoundId !== round.id
1222
+ || run.workspace.owner.executionGroupId !== run.executionGroupId
1223
+ || run.workspace.owner.executionLaneId !== run.executionLaneId) {
1224
+ throw new Error(`Review Run workspace owner does not match its Lane lineage: ${run.id}.`);
1225
+ }
1226
+ const lane = round.executionGroup?.lanes.find(({ id }) => id === run.executionLaneId);
1227
+ const writableProjectIds = run.workspace.entries
1228
+ .filter(({ access }) => access === "write")
1229
+ .map(({ projectId }) => projectId)
1230
+ .sort();
1231
+ if (lane === undefined
1232
+ || lane.runId !== run.id
1233
+ || lane.roleName !== run.roleName
1234
+ || lane.reviewRoundId !== round.id
1235
+ || lane.workspace?.root !== run.workspace.root
1236
+ || !isDeepStrictEqual([...lane.workspace.writableProjectIds].sort(), writableProjectIds)
1237
+ || run.workspace.entries.length === 0
1238
+ || run.workspace.entries.some(({ access }) => access !== "write")) {
1239
+ throw new Error(`Review Run Lane workspace lineage is not exact: ${run.id}.`);
1240
+ }
1241
+ return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
1242
+ }
1243
+ async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
1244
+ requireTask(this.store, taskId);
1245
+ const workspace = this.store.listManagedWorkspaces(taskId).find(({ owner }) => (owner.type === "execution-lane"
1246
+ && owner.executionGroupId === executionGroupId
1247
+ && owner.executionLaneId === executionLaneId));
1248
+ if (workspace === undefined)
1249
+ return "missing";
1250
+ return this.#inspectEntries(taskId, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1251
+ }
1252
+ async cleanupReviewRoundWorkspace(taskId, reviewRoundId) {
1253
+ const task = requireTask(this.store, taskId);
1254
+ const round = this.store.getReviewRound(task.id, reviewRoundId);
1255
+ if (round === null)
1256
+ throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
1257
+ if (round.status !== "completed" && round.status !== "failed") {
1258
+ throw new Error(`ReviewRound must be terminal before cleanup: ${round.id}.`);
1259
+ }
1260
+ if (round.workspaceDisposition?.kind === "removed")
1261
+ return "missing";
1262
+ const workspace = this.store.getReviewRoundWorkspace(task.id, round.id);
1263
+ if (workspace === null || round.workspace === undefined) {
1264
+ throw new Error(`ReviewRound has no managed workspace: ${round.id}.`);
1265
+ }
1266
+ assertReviewRoundOwnsWorkspace(round.id, workspace);
1267
+ if (!isDeepStrictEqual(round.workspace, workspace)) {
1268
+ throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1269
+ }
1270
+ assertWorkspaceSessionsRetirable(this.store, task.id, round.reviewerRoleName, this.now());
1271
+ for (const lane of round.executionGroup?.lanes ?? []) {
1272
+ assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
1273
+ }
1274
+ if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), workspace.entries) === "dirty")
1275
+ return "dirty";
1276
+ let removed = false;
1277
+ for (const entry of workspace.entries) {
1278
+ const project = requireProject(this.store, entry.projectId);
1279
+ const result = await this.git.removeWorktree({
1280
+ repositoryPath: project.path,
1281
+ container: this.#projectContainer(project.name),
176
1282
  taskId: task.id,
1283
+ roleName: managedWorktreeName(workspace.owner),
1284
+ deleteBranch: true
1285
+ });
1286
+ if (result === "dirty") {
1287
+ throw new Error(`Review workspace changed after cleanup preflight: ${round.id}.`);
1288
+ }
1289
+ removed ||= result === "removed";
1290
+ }
1291
+ await removeWorkspaceView(workspace.root);
1292
+ this.store.transaction((tx) => {
1293
+ const currentRound = tx.getReviewRound(task.id, round.id);
1294
+ const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
1295
+ if (currentRound === null || currentRound.status !== round.status
1296
+ || currentRound.workspace === undefined
1297
+ || !isDeepStrictEqual(currentRound.workspace, workspace)
1298
+ || currentWorkspace === null
1299
+ || !sameManagedWorkspace(currentWorkspace, workspace)) {
1300
+ throw new Error(`ReviewRound changed before cleanup was recorded: ${round.id}.`);
1301
+ }
1302
+ tx.removeManagedWorkspace(workspace.owner);
1303
+ tx.saveReviewRound(task.id, recordReviewWorkspaceDisposition(currentRound, "removed", this.now()));
1304
+ const reviewer = tx.getRole(task.id, round.reviewerRoleName);
1305
+ if (reviewer !== null && reviewer.workspace === workspace.root) {
1306
+ const main = tx.getTaskWorkspace(task.id);
1307
+ const target = main?.root ?? this.#fallbackWorkspace();
1308
+ const timestamp = this.now();
1309
+ retireWorkspaceBoundSession(tx, task.id, reviewer.name, timestamp);
1310
+ tx.saveRole(task.id, updateRole(reviewer, { workspace: target }, timestamp));
1311
+ }
1312
+ });
1313
+ return removed ? "removed" : "missing";
1314
+ }
1315
+ async cleanupWorkItemWorkspace(taskId, workItemId, disposition) {
1316
+ const item = requireWorkItem(this.store, taskId, workItemId);
1317
+ const task = requireTask(this.store, item.taskId);
1318
+ if (!["completed", "failed", "retired"].includes(item.status)) {
1319
+ throw new Error(`Work item must be terminal before cleanup: ${item.id}.`);
1320
+ }
1321
+ if (item.workspaceDisposition !== undefined && item.workspaceDisposition !== disposition) {
1322
+ throw new Error(`Work item workspace is already recorded as ${item.workspaceDisposition}.`);
1323
+ }
1324
+ const workspace = this.store.getWorkItemWorkspace(task.id, item.id);
1325
+ if (workspace === null) {
1326
+ if (item.workspaceDisposition === disposition)
1327
+ return "missing";
1328
+ throw new Error(`WorkItem has no managed isolated worktree workspace: ${item.id}.`);
1329
+ }
1330
+ assertWorkItemOwnsWorkspace(item, workspace);
1331
+ if (item.assignee !== undefined) {
1332
+ assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
1333
+ }
1334
+ for (const group of item.executionGroups)
1335
+ for (const lane of group.lanes) {
1336
+ assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
1337
+ }
1338
+ const writable = workspace.entries.filter(({ access }) => access === "write");
1339
+ if (await this.#inspectEntries(task.id, managedWorktreeName(workspace.owner), writable) === "dirty")
1340
+ return "dirty";
1341
+ let removed = false;
1342
+ for (const entry of writable) {
1343
+ const project = requireProject(this.store, entry.projectId);
1344
+ const result = await this.git.removeWorktree({
1345
+ repositoryPath: project.path,
1346
+ container: this.#projectContainer(project.name),
1347
+ taskId: task.id,
1348
+ roleName: managedWorktreeName(workspace.owner),
1349
+ deleteBranch: true
1350
+ });
1351
+ if (result === "dirty") {
1352
+ throw new Error(`WorkItem workspace changed after cleanup preflight: ${item.id}.`);
1353
+ }
1354
+ removed ||= result === "removed";
1355
+ }
1356
+ await removeWorkspaceView(workspace.root);
1357
+ try {
1358
+ this.#recordWorkspaceRemoval(task, workspace, this.#fallbackWorkspace(), {
1359
+ workItemId: item.id,
1360
+ disposition
1361
+ });
1362
+ }
1363
+ catch (error) {
1364
+ if (!removed)
1365
+ throw error;
1366
+ throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}`);
1367
+ }
1368
+ return removed ? "removed" : "missing";
1369
+ }
1370
+ async inspectTaskMainWorkspace(taskId) {
1371
+ const task = requireTask(this.store, taskId);
1372
+ const main = this.store.getTaskWorkspace(task.id);
1373
+ if (main === null)
1374
+ return "missing";
1375
+ if (main.owner.type !== "task") {
1376
+ throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
1377
+ }
1378
+ return this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries);
1379
+ }
1380
+ async cleanupTaskForArchive(taskId) {
1381
+ const task = requireTask(this.store, taskId);
1382
+ assertTaskArchiveState(task, task);
1383
+ const isolated = this.store.listManagedWorkspaces(task.id)
1384
+ .filter(({ owner }) => owner.type !== "task");
1385
+ if (isolated.length > 0) {
1386
+ return {
1387
+ taskId,
177
1388
  status: "failed",
178
- path: this.#taskRoot(task.id),
179
- error: `Role worktree cleanup failed (${failures.join("; ")})`
1389
+ ...(task.cwd === undefined ? {} : { path: task.cwd }),
1390
+ error: `Managed workspaces require explicit cleanup: ${isolated.map(({ owner }) => managedWorkspaceKey(owner)).join(", ")}.`
180
1391
  };
181
1392
  }
182
- this.#recordArchivedTaskCleanup(task.id, dirty);
183
- if (!dirty)
184
- await removeEmptyDirectory(this.#taskRoot(task.id));
185
- return dirty
186
- ? { taskId: task.id, status: "archived-dirty", path: this.#taskRoot(task.id) }
187
- : { taskId: task.id, status: "archived-clean" };
188
- }
189
- #recordArchivedRoleCleanup(taskId, repositoryPath, roleName) {
190
- this.store.transaction((store) => {
191
- const latest = requireTask(store, taskId);
192
- if (latest.status !== "archived")
193
- return;
194
- const now = this.now();
195
- const role = store.getRole(taskId, roleName);
196
- if (role === null)
197
- return;
198
- if (role.workspace === repositoryPath
199
- && store.getRoleWorkspace(taskId, role.name) === null)
1393
+ const main = this.store.getTaskWorkspace(task.id);
1394
+ if (main !== null) {
1395
+ assertTaskArchiveState(requireTask(this.store, task.id), task);
1396
+ if (await this.#inspectEntries(task.id, MAIN_WORKTREE, main.entries) === "dirty") {
1397
+ return {
1398
+ taskId,
1399
+ status: "retained-dirty",
1400
+ ...(task.cwd === undefined ? {} : { path: task.cwd })
1401
+ };
1402
+ }
1403
+ for (const entry of main.entries) {
1404
+ assertTaskArchiveState(requireTask(this.store, task.id), task);
1405
+ const project = requireProject(this.store, entry.projectId);
1406
+ const result = await this.git.removeWorktree({
1407
+ repositoryPath: project.path,
1408
+ container: this.#projectContainer(project.name),
1409
+ taskId: task.id,
1410
+ roleName: MAIN_WORKTREE
1411
+ });
1412
+ if (result === "dirty") {
1413
+ throw new Error(`Task workspace changed after cleanup preflight: ${task.id}.`);
1414
+ }
1415
+ }
1416
+ assertTaskArchiveState(requireTask(this.store, task.id), task);
1417
+ await removeWorkspaceView(main.root);
1418
+ assertTaskArchiveState(requireTask(this.store, task.id), task);
1419
+ this.#recordWorkspaceRemoval(task, main, this.#fallbackWorkspace());
1420
+ }
1421
+ this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
1422
+ return { taskId, status: "removed" };
1423
+ }
1424
+ async #inspectEntries(taskId, roleName, entries) {
1425
+ if (entries.length === 0)
1426
+ return "missing";
1427
+ let found = false;
1428
+ for (const entry of entries) {
1429
+ const project = requireProject(this.store, entry.projectId);
1430
+ const state = await this.git.inspectWorktree({
1431
+ repositoryPath: project.path,
1432
+ container: this.#projectContainer(project.name),
1433
+ taskId,
1434
+ roleName
1435
+ });
1436
+ if (state === "dirty")
1437
+ return "dirty";
1438
+ found ||= state === "clean";
1439
+ }
1440
+ return found ? "clean" : "missing";
1441
+ }
1442
+ async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace) {
1443
+ const changed = [];
1444
+ for (const entry of workspace.entries) {
1445
+ if (await this.git.headRef(entry.path) !== entry.branch) {
1446
+ throw new Error(`Review Project workspace left its managed branch: ${reviewRoundId}/${entry.projectId}.`);
1447
+ }
1448
+ const head = (await this.git.inspect(entry.path, "HEAD")).baseCommit;
1449
+ const project = requireProject(this.store, entry.projectId);
1450
+ if (!await this.git.isAncestor(project.path, entry.baseCommit, head)) {
1451
+ throw new Error(`Review workspace HEAD does not descend from its frozen base for `
1452
+ + `${reviewRoundId}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
1453
+ + `physical HEAD ${head}.`);
1454
+ }
1455
+ if (head !== entry.baseCommit)
1456
+ changed.push(head);
1457
+ }
1458
+ if (changed.length > 1) {
1459
+ throw new Error(`Review workspace has diagnostic commits in multiple Projects; preserve it for Leader routing: ${reviewRoundId}.`);
1460
+ }
1461
+ return changed.length === 0 ? {} : { evidenceCommit: changed[0] };
1462
+ }
1463
+ #projectContainer(projectName) {
1464
+ return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
1465
+ }
1466
+ #taskWorkspaceRoot(taskId) {
1467
+ return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "main");
1468
+ }
1469
+ #workItemWorkspaceRoot(taskId, workItemId) {
1470
+ return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
1471
+ }
1472
+ #reviewRoundWorkspaceRoot(taskId, reviewRoundId) {
1473
+ return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "reviews", safePathSegment(reviewRoundId));
1474
+ }
1475
+ #executionLaneWorkspaceRoot(taskId, groupId, laneId) {
1476
+ return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
1477
+ }
1478
+ #fallbackWorkspace() {
1479
+ return this.store.getConfig().defaultWorkspace ?? process.cwd();
1480
+ }
1481
+ async #discardUnadoptedEntries(task, prepared, roleName, deleteBranch = false, adoptedPaths = new Set(this.store.listManagedWorkspaces(task.id)
1482
+ .flatMap((workspace) => workspace.entries.map(({ path }) => path)))) {
1483
+ for (const { project, entry } of prepared.filter(({ entry }) => entry.access === "write" && !adoptedPaths.has(entry.path))) {
1484
+ const removal = await this.git.removeWorktree({
1485
+ repositoryPath: project.path,
1486
+ container: this.#projectContainer(project.name),
1487
+ taskId: task.id,
1488
+ roleName,
1489
+ ...(deleteBranch ? { deleteBranch: true } : {})
1490
+ });
1491
+ if (removal === "dirty") {
1492
+ throw new Error(`Unadopted managed worktree is dirty and was retained at ${entry.path}; inspect it and retry.`);
1493
+ }
1494
+ }
1495
+ }
1496
+ #recordWorkspaceRemoval(task, workspace, fallback, workItem) {
1497
+ this.store.transaction((tx) => {
1498
+ if (workspace.owner.type === "task") {
1499
+ assertTaskArchiveState(requireTask(tx, task.id), task);
1500
+ }
1501
+ const current = tx.listManagedWorkspaces(task.id)
1502
+ .find((entry) => managedWorkspaceKey(entry.owner) === managedWorkspaceKey(workspace.owner))
1503
+ ?? null;
1504
+ if (current === null || !sameManagedWorkspace(current, workspace)) {
1505
+ if (workItem === undefined) {
1506
+ throw new Error(`Managed workspace changed before cleanup was recorded: ${task.id}/${managedWorkspaceKey(workspace.owner)}.`);
1507
+ }
1508
+ recordWorkspaceDisposition(tx, task.id, workItem, this.now());
200
1509
  return;
201
- if (role.workspace !== repositoryPath) {
202
- store.saveRole(taskId, updateRole(role, { workspace: repositoryPath }, now));
203
1510
  }
204
- store.removeRoleWorkspace(taskId, role.name);
1511
+ const roleName = workspace.owner.type === "work-item"
1512
+ ? tx.getWorkItem(task.id, workspace.owner.workItemId)?.assignee
1513
+ : workspace.owner.type === "task" ? LEADER_ROLE : undefined;
1514
+ const role = roleName === undefined ? null : tx.getRole(task.id, roleName);
1515
+ tx.removeManagedWorkspace(workspace.owner);
1516
+ const main = tx.getTaskWorkspace(task.id);
1517
+ const target = workspace.owner.type === "task" ? fallback : main?.root ?? fallback;
1518
+ if (role !== null && role.workspace !== target) {
1519
+ const timestamp = this.now();
1520
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
1521
+ tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
1522
+ }
1523
+ if (workItem !== undefined) {
1524
+ recordWorkspaceDisposition(tx, task.id, workItem, this.now());
1525
+ }
205
1526
  });
206
1527
  }
207
- #recordArchivedTaskCleanup(taskId, dirty) {
208
- this.store.transaction((store) => {
209
- const latest = requireTask(store, taskId);
210
- if (latest.status !== "archived")
211
- return;
212
- if (!dirty && latest.cwd !== undefined) {
213
- const { cwd: _cwd, ...withoutWorkspace } = latest;
214
- store.saveTask({ ...withoutWorkspace, updatedAt: this.now().toISOString() });
1528
+ #clearTaskWorkspace(task, fallback) {
1529
+ this.store.transaction((tx) => {
1530
+ const latest = requireTask(tx, task.id);
1531
+ assertTaskArchiveState(latest, task);
1532
+ for (const role of tx.listRoles(task.id)) {
1533
+ if (role.workspace !== fallback) {
1534
+ const timestamp = this.now();
1535
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
1536
+ tx.saveRole(task.id, updateRole(role, { workspace: fallback }, timestamp));
1537
+ }
1538
+ }
1539
+ if (latest.cwd !== undefined) {
1540
+ const { cwd: _cwd, ...withoutCwd } = latest;
1541
+ tx.saveTask({ ...withoutCwd, updatedAt: this.now().toISOString() });
215
1542
  }
216
1543
  });
217
1544
  }
218
- #taskRoot(taskId) {
219
- return join(this.worktreeRoot, taskId);
1545
+ }
1546
+ function requireTask(store, taskId) {
1547
+ const task = store.getTask(taskId);
1548
+ if (task === null)
1549
+ throw new Error(`Task not found: ${taskId}.`);
1550
+ return task;
1551
+ }
1552
+ function assertTaskArchiveState(current, expected) {
1553
+ if ((current.status !== "completed" && current.status !== "retired")
1554
+ || !isDeepStrictEqual(current, expected)) {
1555
+ throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
1556
+ }
1557
+ }
1558
+ function requireProject(store, projectId) {
1559
+ const project = store.getProject(projectId);
1560
+ if (project === null)
1561
+ throw new Error(`Project not found: ${projectId}.`);
1562
+ return project;
1563
+ }
1564
+ function requireWorkItem(store, taskId, workItemId) {
1565
+ const item = store.getWorkItem(taskId, workItemId);
1566
+ if (item === null)
1567
+ throw new Error(`Work item not found: ${taskId}/${workItemId}.`);
1568
+ return item;
1569
+ }
1570
+ function requireWorkspaceEntry(workspace, projectId) {
1571
+ const entry = workspace.entries.find((candidate) => candidate.projectId === projectId);
1572
+ if (entry === undefined)
1573
+ throw new Error(`Workspace Project not found: ${projectId}.`);
1574
+ return entry;
1575
+ }
1576
+ function executionLaneLineage(store, task, executionGroupId, executionLaneId, hint) {
1577
+ if (hint?.purpose === "execution" && hint.workItemId !== undefined) {
1578
+ return { purpose: "execution", workItemId: hint.workItemId, reviewRoundId: "" };
1579
+ }
1580
+ if (hint?.purpose === "review" && hint.reviewRoundId !== undefined) {
1581
+ const round = store.getReviewRound(task.id, hint.reviewRoundId);
1582
+ if (round === null)
1583
+ throw new Error(`ReviewRound not found: ${hint.reviewRoundId}.`);
1584
+ return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
1585
+ }
1586
+ for (const item of store.listWorkItems(task.id)) {
1587
+ if (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId)) {
1588
+ return { purpose: "execution", workItemId: item.id, reviewRoundId: "" };
1589
+ }
1590
+ }
1591
+ for (const round of store.listReviewRounds(task.id)) {
1592
+ if (round.executionGroup?.id === executionGroupId
1593
+ && round.executionGroup.lanes.some(({ id }) => id === executionLaneId)) {
1594
+ return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
1595
+ }
1596
+ }
1597
+ throw new Error(`Execution Lane lineage not found: ${task.id}/${executionGroupId}/${executionLaneId}.`);
1598
+ }
1599
+ function assertWorkItemWorkspaceEligible(store, task, item) {
1600
+ if (task.status !== "active")
1601
+ throw new Error(`Task is not active: ${task.id}.`);
1602
+ if (["completed", "failed", "retired"].includes(item.status)) {
1603
+ throw new Error(`Work item is already terminal: ${item.id}.`);
1604
+ }
1605
+ const activeDevelopRun = store.listAgentRuns(task.id)
1606
+ .find((run) => run.status === "active" && run.workItemId === item.id);
1607
+ if (activeDevelopRun !== undefined) {
1608
+ throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
1609
+ }
1610
+ if (item.assignee !== undefined && store.getActiveAgentRun(task.id, item.assignee) !== null) {
1611
+ throw new Error(`Role has an active Run: ${task.id}/${item.assignee}.`);
220
1612
  }
221
1613
  }
222
- function failedPreparation(taskId, error) {
223
- return {
224
- taskId,
225
- status: "failed",
226
- error: error instanceof Error ? error.message : String(error)
227
- };
1614
+ function assertWorkItemOwnsWorkspace(item, workspace) {
1615
+ if (workspace.owner.type !== "work-item" || workspace.owner.workItemId !== item.id) {
1616
+ throw new Error(`WorkItem does not own the managed workspace: ${item.id}.`);
1617
+ }
1618
+ }
1619
+ function assertReviewRoundOwnsWorkspace(reviewRoundId, workspace) {
1620
+ if (workspace.owner.type !== "review-round"
1621
+ || workspace.owner.reviewRoundId !== reviewRoundId) {
1622
+ throw new Error(`ReviewRound does not own the Role workspace: ${reviewRoundId}.`);
1623
+ }
228
1624
  }
229
- function assertWorkspaceIdentity(workspace, expected) {
230
- for (const key of [
231
- "taskId", "roleName", "repositoryId", "path", "branch", "baseRef"
232
- ]) {
233
- if (workspace[key] !== expected[key]) {
234
- throw new Error(`RoleWorkspace ${key} does not match its deterministic identity: ${expected.taskId}/${expected.roleName}.`);
1625
+ function preserveWorkspaceCreatedAt(workspace, previous) {
1626
+ return previous === null
1627
+ ? workspace
1628
+ : { ...workspace, createdAt: previous.createdAt };
1629
+ }
1630
+ function assertWorkspaceSessionsRetirable(store, taskId, roleName, now) {
1631
+ if (store.getActiveAgentRun(taskId, roleName) !== null) {
1632
+ throw new Error(`Role has an active Run: ${taskId}/${roleName}.`);
1633
+ }
1634
+ const sessions = store.getTaskRoleSessionSet(taskId, roleName);
1635
+ if (sessions !== null)
1636
+ retireTaskRoleSessionsForWorkspace(sessions, now);
1637
+ }
1638
+ function retireWorkspaceBoundSession(store, taskId, roleName, now) {
1639
+ if (store.getActiveAgentRun(taskId, roleName) !== null) {
1640
+ throw new Error(`Role has an active Run: ${taskId}/${roleName}.`);
1641
+ }
1642
+ const sessions = store.getTaskRoleSessionSet(taskId, roleName);
1643
+ if (sessions !== null) {
1644
+ store.saveTaskRoleSessionSet(retireTaskRoleSessionsForWorkspace(sessions, now));
1645
+ }
1646
+ }
1647
+ function sameManagedWorkspace(left, right) {
1648
+ return isDeepStrictEqual(left, right);
1649
+ }
1650
+ function sameManagedWorkspaceIdentity(left, right) {
1651
+ return isDeepStrictEqual({ owner: left.owner, root: left.root, entries: left.entries }, { owner: right.owner, root: right.root, entries: right.entries });
1652
+ }
1653
+ function isMissingPath(error) {
1654
+ return typeof error === "object" && error !== null && "code" in error
1655
+ && error.code === "ENOENT";
1656
+ }
1657
+ function recordWorkspaceDisposition(store, taskId, workItem, now) {
1658
+ const item = store.getWorkItem(taskId, workItem.workItemId);
1659
+ if (item === null)
1660
+ throw new Error(`Work item not found: ${workItem.workItemId}.`);
1661
+ store.saveWorkItem(taskId, recordWorkItemWorkspaceDisposition(item, workItem.disposition, now));
1662
+ }
1663
+ async function ensureWorkspaceView(root, entries) {
1664
+ await mkdir(root, { recursive: true });
1665
+ const expected = new Map(entries.map((entry) => [entry.directory, entry.path]));
1666
+ for (const current of await readdir(root, { withFileTypes: true })) {
1667
+ const target = expected.get(current.name);
1668
+ if (!current.isSymbolicLink()) {
1669
+ throw new Error(`Managed workspace contains an unexpected entry: ${join(root, current.name)}.`);
1670
+ }
1671
+ const path = join(root, current.name);
1672
+ const linked = resolve(root, await readlink(path));
1673
+ if (target === undefined) {
1674
+ await unlink(path);
1675
+ continue;
235
1676
  }
1677
+ if (linked !== target) {
1678
+ await unlink(path);
1679
+ await symlink(target, path, "dir");
1680
+ }
1681
+ expected.delete(current.name);
1682
+ }
1683
+ for (const [directory, target] of expected) {
1684
+ await symlink(target, join(root, directory), "dir");
236
1685
  }
237
1686
  }
238
- async function removeEmptyDirectory(path) {
1687
+ async function removeWorkspaceView(root) {
1688
+ let entries;
239
1689
  try {
240
- await rmdir(path);
1690
+ entries = await readdir(root, { withFileTypes: true });
241
1691
  }
242
1692
  catch (error) {
243
- if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY"))
244
- throw error;
1693
+ if (errorCode(error) === "ENOENT")
1694
+ return;
1695
+ throw error;
245
1696
  }
1697
+ for (const entry of entries) {
1698
+ if (!entry.isSymbolicLink()) {
1699
+ throw new Error(`Managed workspace contains an unexpected entry: ${join(root, entry.name)}.`);
1700
+ }
1701
+ await unlink(join(root, entry.name));
1702
+ }
1703
+ await rmdir(root);
246
1704
  }
247
- function isErrno(value, code) {
248
- return typeof value === "object" && value !== null && "code" in value
249
- && value.code === code;
1705
+ function safePathSegment(value) {
1706
+ const normalized = value.trim();
1707
+ if (normalized.length === 0 || [".", ".."].includes(normalized) || /[\/\\\0]/.test(normalized)) {
1708
+ throw new Error("Identity is invalid for managed workspace layout.");
1709
+ }
1710
+ return normalized;
250
1711
  }
251
- function requireTask(store, taskId) {
252
- const task = store.getTask(taskId);
253
- if (task === null)
254
- throw new Error(`Task not found: ${taskId}.`);
255
- return task;
1712
+ function sameCommit(left, right) {
1713
+ return left.toLowerCase() === right.toLowerCase();
1714
+ }
1715
+ function remoteDefaultProjects(store, taskId) {
1716
+ const projectIds = new Set();
1717
+ for (const event of store.listEvents(taskId)) {
1718
+ if (event.type === "task.created") {
1719
+ for (const projectId of (event.payload.defaultProjectIds ?? "").split(",")) {
1720
+ const normalized = projectId.trim();
1721
+ if (normalized.length > 0)
1722
+ projectIds.add(normalized);
1723
+ }
1724
+ }
1725
+ }
1726
+ return projectIds;
1727
+ }
1728
+ function looksLikeCommit(value) {
1729
+ return /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/iu.test(value);
1730
+ }
1731
+ export function resolveWorktreeRoot(home, workspace) {
1732
+ return join(resolveWorkspaceRoot(home, workspace), "worktree");
1733
+ }
1734
+ export function resolveTaskRoot(home, workspace) {
1735
+ return join(resolveWorkspaceRoot(home, workspace), "tasks");
1736
+ }
1737
+ function resolveWorkspaceRoot(home, workspace) {
1738
+ if (workspace === undefined) {
1739
+ throw new Error("Project workspace is not configured; run yui setup.");
1740
+ }
1741
+ const homeRoot = resolve(home);
1742
+ const workspaceRoot = resolve(workspace);
1743
+ const fromHome = relative(homeRoot, workspaceRoot);
1744
+ if (fromHome === "" || (!fromHome.startsWith("..") && !isAbsolute(fromHome))) {
1745
+ throw new Error("Project workspace must be outside YUI_HOME.");
1746
+ }
1747
+ return workspaceRoot;
1748
+ }
1749
+ function errorCode(error) {
1750
+ return typeof error === "object" && error !== null && "code" in error
1751
+ ? String(error.code)
1752
+ : undefined;
256
1753
  }