@zq-silk/yui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -3,14 +3,26 @@ import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { retireTaskRoleSessionsForWorkspace } from "../executor/agentExecutor.js";
5
5
  import { updateRole } from "../role/role.js";
6
+ import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
6
7
  import { attachReviewRoundWorkspace, recordReviewWorkspaceDisposition } from "../review/reviewRound.js";
8
+ import { StorageConflictError } from "../storage/taskStore.js";
7
9
  import { bindTaskWorkspaceIdentity } from "../task/task.js";
8
10
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
9
11
  import { createManagedWorkspace, managedWorkspaceKey, managedWorktreeName } from "../worktree/managedWorkspace.js";
12
+ import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
10
13
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
11
- import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity } from "./taskWorkspaceIdentity.js";
14
+ import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
15
+ import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity, TASK_WORKSPACE_TOKEN_PATTERN, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
16
+ import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
12
17
  const MAIN_WORKTREE = "main";
13
18
  const LEADER_ROLE = "leader";
19
+ /**
20
+ * Bound for prepare attempts after a lost identity race or a storage
21
+ * revision conflict. Each conflict already discarded the attempt's refs, so
22
+ * retrying converges with the committed state; the bound stops a persistent
23
+ * competitor from pinning this caller forever.
24
+ */
25
+ const TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES = 3;
14
26
  export class WorkspaceCleanupBlockedError extends Error {
15
27
  reason;
16
28
  resource;
@@ -46,7 +58,85 @@ export class FileTaskWorkspacePreparer {
46
58
  this.git = git;
47
59
  this.now = now;
48
60
  }
61
+ #resourceRegistrarValue;
62
+ #resourceRegistrar() {
63
+ return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
64
+ }
65
+ #registerWorkspace(workspace) {
66
+ this.#resourceRegistrar().registerManagedWorkspace(workspace);
67
+ }
49
68
  async prepareTaskWorkspace(taskId) {
69
+ // The per-Project maintenance fence makes prepare mutually exclusive with
70
+ // migrate/rebuild/archive: a concurrent migration must not switch the
71
+ // Project catalog to the Home-managed repo while prepare is creating
72
+ // worktrees from the old external checkout. The fence is acquired on
73
+ // every conflict retry so the locked set always covers the Task's
74
+ // current bindings. Gitless Tasks hold no fence. The Controller's
75
+ // check-then-call probe is kept for scheduling deferral; the fence here
76
+ // makes the prepare itself safe regardless.
77
+ for (let attempt = 0;; attempt += 1) {
78
+ try {
79
+ const task = requireTask(this.store, taskId);
80
+ const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
81
+ try {
82
+ return await this.#prepareTaskWorkspaceLocked(current.id);
83
+ }
84
+ finally {
85
+ release();
86
+ }
87
+ }
88
+ catch (error) {
89
+ if (error instanceof StorageConflictError
90
+ && attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
91
+ continue;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * Acquire the per-Project maintenance fences for every Project bound to a
99
+ * Task, then re-read the Task under those fences and prove its binding set
100
+ * is still the one the fences cover. Gitless Tasks hold no fence.
101
+ *
102
+ * The returned Task is the fresh under-lock snapshot; callers MUST use it
103
+ * (not the pre-lock snapshot) for every Project read and Git effect. A
104
+ * binding-set change rides the StorageConflictError retry channel so the
105
+ * caller re-reads the Task and re-acquires the correct fence set. The
106
+ * caller owns `release` and MUST call it on every exit path.
107
+ */
108
+ #acquireTaskProjectMaintenanceLocks(task) {
109
+ const projectIds = task.projectBindings.map(({ projectId }) => projectId);
110
+ const release = projectIds.length === 0
111
+ ? () => { }
112
+ : acquireProjectMaintenanceLocks(this.home, projectIds);
113
+ try {
114
+ const current = requireTask(this.store, task.id);
115
+ const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
116
+ const lockedIds = [...projectIds].sort();
117
+ if (currentIds.length !== lockedIds.length
118
+ || currentIds.some((id, index) => id !== lockedIds[index])) {
119
+ throw new StorageConflictError(`Task Project bindings changed while its workspace fence was acquired: ${task.id}.`);
120
+ }
121
+ return { release, current };
122
+ }
123
+ catch (error) {
124
+ release();
125
+ throw error;
126
+ }
127
+ }
128
+ /**
129
+ * Acquire the per-Project maintenance fences for a Task's bindings and
130
+ * return the under-lock Task snapshot plus the release handle. Used by the
131
+ * dispatch preflight to hold ONE fence across new-Lane preparation and the
132
+ * command's adoption transaction, so a migrate cannot switch the Project
133
+ * catalog in that gap. The caller owns `release` and must call it on every
134
+ * exit path.
135
+ */
136
+ acquireTaskProjectMaintenanceLocks(taskId) {
137
+ return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
138
+ }
139
+ async #prepareTaskWorkspaceLocked(taskId) {
50
140
  const task = requireTask(this.store, taskId);
51
141
  if (!["draft", "active"].includes(task.status)) {
52
142
  throw new Error(`Task is not open for workspace preparation: ${task.id}.`);
@@ -69,6 +159,7 @@ export class FileTaskWorkspacePreparer {
69
159
  root,
70
160
  entries: []
71
161
  }, this.now());
162
+ this.#registerWorkspace(workspace);
72
163
  this.store.transaction((tx) => {
73
164
  const latest = requireTask(tx, task.id);
74
165
  if (!['draft', 'active'].includes(latest.status)
@@ -198,6 +289,7 @@ export class FileTaskWorkspacePreparer {
198
289
  root,
199
290
  entries: prepared.map(({ entry }) => entry)
200
291
  }, this.now());
292
+ this.#registerWorkspace(workspace);
201
293
  this.store.transaction((tx) => {
202
294
  const latest = requireTask(tx, task.id);
203
295
  if (!["draft", "active"].includes(latest.status)) {
@@ -206,10 +298,36 @@ export class FileTaskWorkspacePreparer {
206
298
  if (!isDeepStrictEqual(latest.projectBindings, task.projectBindings)) {
207
299
  throw new Error(`Task Projects changed while preparing its workspace: ${task.id}.`);
208
300
  }
301
+ // Revalidate the Project catalog path after acquiring the fence: a
302
+ // concurrent migration must not leave a persisted worktree whose Git
303
+ // common dir is the old external checkout. A changed path rides the
304
+ // StorageConflictError retry channel so the retry re-reads the catalog
305
+ // and prepares against the current (Home-managed) repository.
306
+ for (const { project } of prepared) {
307
+ const latestProject = requireProject(tx, project.id);
308
+ if (latestProject.path !== project.path) {
309
+ throw new StorageConflictError(`Project path changed while preparing its workspace: ${project.id}.`);
310
+ }
311
+ }
209
312
  const current = tx.getTaskWorkspace(task.id);
210
313
  if (current !== null && current.owner.type !== "task") {
211
314
  throw new Error(`Task main workspace ownership is invalid: ${task.id}.`);
212
315
  }
316
+ // Single-writer CAS, mirroring the rebuild guard: this attempt only
317
+ // commits while the Task is still unbound (or carries the identity it
318
+ // started with). A concurrent prepare or rebuild that bound a
319
+ // different identity wins; this attempt discards its refs and retries
320
+ // through the StorageConflictError channel.
321
+ if (workspaceIdentity !== undefined
322
+ && latest.workspaceIdentity !== undefined
323
+ && !isDeepStrictEqual(validateTaskWorkspaceIdentity(latest.workspaceIdentity), workspaceIdentity)) {
324
+ throw new StorageConflictError(`Task workspace identity changed while preparing its workspace: ${task.id}.`);
325
+ }
326
+ if (current === null
327
+ ? existing !== null
328
+ : existing === null || !sameManagedWorkspace(current, existing)) {
329
+ throw new StorageConflictError(`Task workspace changed while preparing its workspace: ${task.id}.`);
330
+ }
213
331
  const pinnedBindings = latest.projectBindings.map((binding) => {
214
332
  const baseline = baselines.get(binding.projectId);
215
333
  return baseline?.pinTask === true
@@ -237,15 +355,31 @@ export class FileTaskWorkspacePreparer {
237
355
  // The Role field is only a cwd/snapshot hint. Preserve the hint for
238
356
  // an active WorkItem assignment; the durable owner is the WorkItem,
239
357
  // not this Role record. Other Roles use Task main.
240
- const assignedItem = tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
241
- && !["completed", "failed", "retired"]
242
- .includes(candidate.status)));
358
+ // Prefer the active Run's exact WorkItem for this Role; fall back
359
+ // to the first queued WorkItem only when no active Run owns the Role.
360
+ const activeRoleRun = tx.getActiveAgentRun(task.id, role.name);
361
+ const activeRunItem = activeRoleRun !== null
362
+ && activeRoleRun.purpose === "execution"
363
+ && activeRoleRun.workItemId !== undefined
364
+ ? tx.getWorkItem(task.id, activeRoleRun.workItemId)
365
+ : null;
366
+ const assignedItem = activeRunItem !== null
367
+ && activeRunItem.assignee === role.name
368
+ && !["completed", "failed", "retired"].includes(activeRunItem.status)
369
+ ? activeRunItem
370
+ : tx.listWorkItems(task.id).find((candidate) => (candidate.assignee === role.name
371
+ && !["completed", "failed", "retired"]
372
+ .includes(candidate.status)));
243
373
  const assignedWorkspace = assignedItem === undefined
244
374
  ? null
245
375
  : tx.getWorkItemWorkspace(task.id, assignedItem.id);
246
376
  const target = assignedWorkspace?.root ?? root;
247
377
  if (role.workspace !== target) {
248
- retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
378
+ if (assignedItem === undefined
379
+ || assignedWorkspace === null
380
+ || !canCorrectActiveWorkItemRoleWorkspaceHint(tx, task.id, role, assignedItem, assignedWorkspace)) {
381
+ retireWorkspaceBoundSession(tx, task.id, role.name, timestamp);
382
+ }
249
383
  tx.saveRole(task.id, updateRole(role, { workspace: target }, timestamp));
250
384
  }
251
385
  }
@@ -253,10 +387,40 @@ export class FileTaskWorkspacePreparer {
253
387
  return { taskId, status: "ready", path: root };
254
388
  }
255
389
  catch (error) {
256
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE);
390
+ // A failed or conflicted preparation owns no durable record: drop the
391
+ // branches too, so a retry mints a clean identity without half-created
392
+ // refs behind. Adopted (already catalogued) worktrees are never touched.
393
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, MAIN_WORKTREE, true);
257
394
  throw error;
258
395
  }
259
396
  }
397
+ /**
398
+ * The conflict-retry loop for a Task-main prepare, run with the caller's
399
+ * fences already held. A lost identity/workspace CAS retries safely under
400
+ * the same fence set; a binding-set change makes that fence set stale, so
401
+ * the conflict is rethrown and only the caller (which can re-acquire) may
402
+ * retry it.
403
+ */
404
+ async #prepareTaskWorkspaceWithRetries(taskId, lockedProjectIds) {
405
+ for (let attempt = 0;; attempt += 1) {
406
+ try {
407
+ return await this.#prepareTaskWorkspaceLocked(taskId);
408
+ }
409
+ catch (error) {
410
+ if (!(error instanceof StorageConflictError)
411
+ || attempt >= TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
412
+ throw error;
413
+ }
414
+ const current = requireTask(this.store, taskId);
415
+ const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
416
+ const locked = [...lockedProjectIds].sort();
417
+ if (currentIds.length !== locked.length
418
+ || currentIds.some((id, index) => id !== locked[index])) {
419
+ throw error;
420
+ }
421
+ }
422
+ }
423
+ }
260
424
  /**
261
425
  * Mint the Task's durable workspace identity with create-not-exists
262
426
  * semantics: the candidate main branch must not already exist in any bound
@@ -376,148 +540,160 @@ export class FileTaskWorkspacePreparer {
376
540
  const item = requireWorkItem(this.store, taskId, workItemId);
377
541
  const task = requireTask(this.store, item.taskId);
378
542
  assertWorkItemWorkspaceEligible(this.store, task, item);
379
- await this.prepareTaskWorkspace(task.id);
380
- // prepareTaskWorkspace may have just minted and persisted the workspace
381
- // identity; derive the segment from the persisted Task.
382
- const taskSegment = this.#taskSegment(requireTask(this.store, task.id));
383
- const main = this.store.getTaskWorkspace(task.id);
384
- if (main === null || main.owner.type !== "task") {
385
- throw new Error(`Task main workspace is not ready: ${task.id}.`);
386
- }
387
- const existing = this.store.getWorkItemWorkspace(task.id, item.id);
388
- if (existing !== null && (existing.owner.type !== "work-item"
389
- || existing.owner.workItemId !== item.id)) {
390
- throw new Error(`WorkItem workspace owner is invalid: ${task.id}/${item.id}.`);
391
- }
392
- if (item.assignee !== undefined) {
393
- assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
394
- }
395
- const writeProjects = new Set(item.writeProjectIds);
396
- const boundProjects = new Set(task.projectBindings.map(({ projectId }) => projectId));
397
- for (const baseRef of item.baseRefs ?? []) {
398
- if (!boundProjects.has(baseRef.projectId)) {
399
- throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
400
- }
401
- if (!writeProjects.has(baseRef.projectId)) {
402
- throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
403
- }
404
- }
405
- const root = this.#workItemWorkspaceRoot(task.id, item.id);
406
- const prepared = [];
543
+ // Hold the per-Project maintenance fence across the ensure-main-prepared
544
+ // and the WorkItem worktree creation, so a concurrent migrate/rebuild
545
+ // cannot switch the Project catalog between the two. The under-lock Task
546
+ // snapshot drives every Project read and Git effect below.
547
+ const { release, current: lockedTask } = this.#acquireTaskProjectMaintenanceLocks(task);
407
548
  try {
408
- for (const binding of task.projectBindings) {
409
- const project = requireProject(this.store, binding.projectId);
410
- const mainEntry = requireWorkspaceEntry(main, project.id);
411
- if (!writeProjects.has(project.id)) {
412
- prepared.push({
413
- project,
414
- entry: { ...mainEntry, access: "read" }
415
- });
416
- continue;
417
- }
418
- const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
419
- const head = await this.git.inspect(mainEntry.path, "HEAD");
420
- const requestedBaseRef = item.baseRefs?.find(({ projectId }) => (projectId === project.id))?.baseRef;
421
- const baseRef = previous?.access === "write"
422
- ? previous.baseCommit
423
- : requestedBaseRef ?? head.baseCommit;
424
- const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
425
- ? null
426
- : await this.git.inspect(project.path, requestedBaseRef);
427
- const physical = await this.git.ensureWorktree({
428
- repositoryPath: project.path,
429
- container: this.#projectContainer(project.name),
430
- taskSegment,
431
- roleName: item.id,
432
- baseRef
433
- });
434
- const entry = {
435
- projectId: project.id,
436
- directory: binding.directory,
437
- access: "write",
438
- path: physical.path,
439
- branch: physical.branch,
440
- baseRef: previous?.access === "write" ? previous.baseRef : baseRef,
441
- // The recorded base is the immutable capture boundary. An existing
442
- // worktree reports its current HEAD from ensureWorktree(), which
443
- // may already contain committed Worker changes and must never
444
- // replace that boundary during scope expansion or reconciliation.
445
- baseCommit: previous?.access === "write"
549
+ const lockedProjectIds = lockedTask.projectBindings.map(({ projectId }) => projectId);
550
+ await this.#prepareTaskWorkspaceWithRetries(lockedTask.id, lockedProjectIds);
551
+ // prepareTaskWorkspace may have just minted and persisted the workspace
552
+ // identity; derive the segment from the persisted Task.
553
+ const taskSegment = this.#taskSegment(requireTask(this.store, lockedTask.id));
554
+ const main = this.store.getTaskWorkspace(lockedTask.id);
555
+ if (main === null || main.owner.type !== "task") {
556
+ throw new Error(`Task main workspace is not ready: ${lockedTask.id}.`);
557
+ }
558
+ const existing = this.store.getWorkItemWorkspace(lockedTask.id, item.id);
559
+ if (existing !== null && (existing.owner.type !== "work-item"
560
+ || existing.owner.workItemId !== item.id)) {
561
+ throw new Error(`WorkItem workspace owner is invalid: ${lockedTask.id}/${item.id}.`);
562
+ }
563
+ if (item.assignee !== undefined) {
564
+ assertWorkspaceSessionsRetirable(this.store, lockedTask.id, item.assignee, this.now());
565
+ }
566
+ const writeProjects = new Set(item.writeProjectIds);
567
+ const boundProjects = new Set(lockedTask.projectBindings.map(({ projectId }) => projectId));
568
+ for (const baseRef of item.baseRefs ?? []) {
569
+ if (!boundProjects.has(baseRef.projectId)) {
570
+ throw new Error(`WorkItem base-ref Project is not bound to its Task: ${item.id}/${baseRef.projectId}.`);
571
+ }
572
+ if (!writeProjects.has(baseRef.projectId)) {
573
+ throw new Error(`WorkItem base-ref Project is not writable: ${item.id}/${baseRef.projectId}.`);
574
+ }
575
+ }
576
+ const root = this.#workItemWorkspaceRoot(lockedTask.id, item.id);
577
+ const prepared = [];
578
+ try {
579
+ for (const binding of lockedTask.projectBindings) {
580
+ const project = requireProject(this.store, binding.projectId);
581
+ const mainEntry = requireWorkspaceEntry(main, project.id);
582
+ if (!writeProjects.has(project.id)) {
583
+ prepared.push({
584
+ project,
585
+ entry: { ...mainEntry, access: "read" }
586
+ });
587
+ continue;
588
+ }
589
+ const previous = existing?.entries.find(({ projectId }) => projectId === project.id);
590
+ const head = await this.git.inspect(mainEntry.path, "HEAD");
591
+ const requestedBaseRef = item.baseRefs?.find(({ projectId }) => (projectId === project.id))?.baseRef;
592
+ const baseRef = previous?.access === "write"
446
593
  ? previous.baseCommit
447
- : physical.baseCommit
448
- };
449
- // Track the physical entry before any postcondition check. If a
450
- // freshly attached deterministic branch is rejected below, the
451
- // catch-path must remove its unadopted worktree even though no
452
- // durable ManagedWorkspace has been written yet.
453
- prepared.push({ project, entry });
454
- if (previous?.access === "write" && (physical.path !== previous.path
455
- || physical.branch !== previous.branch)) {
456
- throw new Error(`Existing WorkItem Project workspace identity changed: ${item.id}/${project.id}.`);
457
- }
458
- if (previous?.access === "write") {
459
- if (requestedBaseRef === undefined && previous.baseRef !== previous.baseCommit) {
460
- throw new Error(`Existing WorkItem Project base ref record changed: ${item.id}/${project.id}.`);
594
+ : requestedBaseRef ?? head.baseCommit;
595
+ const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
596
+ ? null
597
+ : await this.git.inspect(project.path, requestedBaseRef);
598
+ const physical = await this.git.ensureWorktree({
599
+ repositoryPath: project.path,
600
+ container: this.#projectContainer(project.name),
601
+ taskSegment,
602
+ roleName: item.id,
603
+ baseRef
604
+ });
605
+ const entry = {
606
+ projectId: project.id,
607
+ directory: binding.directory,
608
+ access: "write",
609
+ path: physical.path,
610
+ branch: physical.branch,
611
+ baseRef: previous?.access === "write" ? previous.baseRef : baseRef,
612
+ // The recorded base is the immutable capture boundary. An existing
613
+ // worktree reports its current HEAD from ensureWorktree(), which
614
+ // may already contain committed Worker changes and must never
615
+ // replace that boundary during scope expansion or reconciliation.
616
+ baseCommit: previous?.access === "write"
617
+ ? previous.baseCommit
618
+ : physical.baseCommit
619
+ };
620
+ // Track the physical entry before any postcondition check. If a
621
+ // freshly attached deterministic branch is rejected below, the
622
+ // catch-path must remove its unadopted worktree even though no
623
+ // durable ManagedWorkspace has been written yet.
624
+ prepared.push({ project, entry });
625
+ if (previous?.access === "write" && (physical.path !== previous.path
626
+ || physical.branch !== previous.branch)) {
627
+ throw new Error(`Existing WorkItem Project workspace identity changed: ${item.id}/${project.id}.`);
461
628
  }
462
- if (requestedBaseRef !== undefined && requestedBaseRef !== previous.baseRef) {
463
- throw new Error(`Existing WorkItem Project base ref changed: ${item.id}/${project.id}.`);
629
+ if (previous?.access === "write") {
630
+ if (requestedBaseRef === undefined && previous.baseRef !== previous.baseCommit) {
631
+ throw new Error(`Existing WorkItem Project base ref record changed: ${item.id}/${project.id}.`);
632
+ }
633
+ if (requestedBaseRef !== undefined && requestedBaseRef !== previous.baseRef) {
634
+ throw new Error(`Existing WorkItem Project base ref changed: ${item.id}/${project.id}.`);
635
+ }
636
+ if (!await this.git.isAncestor(project.path, previous.baseCommit, physical.baseCommit)) {
637
+ throw new Error(`Existing WorkItem Project HEAD does not descend from its frozen base: `
638
+ + `${item.id}/${project.id}.`);
639
+ }
464
640
  }
465
- if (!await this.git.isAncestor(project.path, previous.baseCommit, physical.baseCommit)) {
466
- throw new Error(`Existing WorkItem Project HEAD does not descend from its frozen base: `
641
+ else if (requestedBase !== null && physical.baseCommit !== requestedBase.baseCommit) {
642
+ throw new Error(`WorkItem Project workspace did not start at its requested base ref: `
467
643
  + `${item.id}/${project.id}.`);
468
644
  }
469
645
  }
470
- else if (requestedBase !== null && physical.baseCommit !== requestedBase.baseCommit) {
471
- throw new Error(`WorkItem Project workspace did not start at its requested base ref: `
472
- + `${item.id}/${project.id}.`);
473
- }
474
- }
475
- await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
476
- const workspace = createManagedWorkspace({
477
- owner: { type: "work-item", taskId: task.id, workItemId: item.id },
478
- root,
479
- entries: prepared.map(({ entry }) => entry)
480
- }, this.now());
481
- return this.store.transaction((tx) => {
482
- const latestTask = requireTask(tx, task.id);
483
- const latestItem = tx.getWorkItem(task.id, item.id);
484
- if (latestTask.status !== "active" || latestItem === null) {
485
- throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
486
- }
487
- if (latestItem.revision !== item.revision
488
- || !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
489
- throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
490
- }
491
- const activeDevelopRun = tx.listAgentRuns(task.id)
492
- .find((run) => run.status === "active" && run.workItemId === item.id);
493
- if (activeDevelopRun !== undefined) {
494
- throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
495
- }
496
- if (latestItem.assignee !== undefined
497
- && tx.getActiveAgentRun(task.id, latestItem.assignee) !== null) {
498
- throw new Error(`Role has an active Run: ${task.id}/${latestItem.assignee}.`);
499
- }
500
- const current = tx.getWorkItemWorkspace(task.id, item.id);
501
- if (current !== null && (current.owner.type !== "work-item"
502
- || current.owner.workItemId !== item.id)) {
503
- throw new Error(`WorkItem workspace changed: ${task.id}/${item.id}.`);
504
- }
505
- const timestamp = this.now();
506
- const stored = preserveWorkspaceCreatedAt(workspace, current);
507
- tx.saveManagedWorkspace(stored);
508
- if (latestItem.assignee !== undefined) {
509
- const latestRole = tx.getRole(task.id, latestItem.assignee);
510
- if (latestRole !== null && latestRole.workspace !== root) {
511
- retireWorkspaceBoundSession(tx, task.id, latestItem.assignee, timestamp);
512
- tx.saveRole(task.id, updateRole(latestRole, { workspace: root }, timestamp));
646
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
647
+ const workspace = createManagedWorkspace({
648
+ owner: { type: "work-item", taskId: lockedTask.id, workItemId: item.id },
649
+ root,
650
+ entries: prepared.map(({ entry }) => entry)
651
+ }, this.now());
652
+ this.#registerWorkspace(workspace);
653
+ return this.store.transaction((tx) => {
654
+ const latestTask = requireTask(tx, lockedTask.id);
655
+ const latestItem = tx.getWorkItem(lockedTask.id, item.id);
656
+ if (latestTask.status !== "active" || latestItem === null) {
657
+ throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
513
658
  }
514
- }
515
- return stored;
516
- });
659
+ if (latestItem.revision !== item.revision
660
+ || !isDeepStrictEqual(latestItem.writeProjectIds, item.writeProjectIds)) {
661
+ throw new Error(`Work item changed while preparing its workspace: ${item.id}.`);
662
+ }
663
+ const activeDevelopRun = tx.listAgentRuns(lockedTask.id)
664
+ .find((run) => run.status === "active" && run.workItemId === item.id);
665
+ if (activeDevelopRun !== undefined) {
666
+ throw new Error(`Work Item already has an active Develop Run: ${activeDevelopRun.id}.`);
667
+ }
668
+ if (latestItem.assignee !== undefined
669
+ && tx.getActiveAgentRun(lockedTask.id, latestItem.assignee) !== null) {
670
+ throw new Error(`Role has an active Run: ${lockedTask.id}/${latestItem.assignee}.`);
671
+ }
672
+ const existingWorkspace = tx.getWorkItemWorkspace(lockedTask.id, item.id);
673
+ if (existingWorkspace !== null && (existingWorkspace.owner.type !== "work-item"
674
+ || existingWorkspace.owner.workItemId !== item.id)) {
675
+ throw new Error(`WorkItem workspace changed: ${lockedTask.id}/${item.id}.`);
676
+ }
677
+ const timestamp = this.now();
678
+ const stored = preserveWorkspaceCreatedAt(workspace, existingWorkspace);
679
+ tx.saveManagedWorkspace(stored);
680
+ if (latestItem.assignee !== undefined) {
681
+ const latestRole = tx.getRole(lockedTask.id, latestItem.assignee);
682
+ if (latestRole !== null && latestRole.workspace !== root) {
683
+ retireWorkspaceBoundSession(tx, lockedTask.id, latestItem.assignee, timestamp);
684
+ tx.saveRole(lockedTask.id, updateRole(latestRole, { workspace: root }, timestamp));
685
+ }
686
+ }
687
+ return stored;
688
+ });
689
+ }
690
+ catch (error) {
691
+ await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, item.id);
692
+ throw error;
693
+ }
517
694
  }
518
- catch (error) {
519
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, item.id);
520
- throw error;
695
+ finally {
696
+ release();
521
697
  }
522
698
  }
523
699
  /**
@@ -525,112 +701,128 @@ export class FileTaskWorkspacePreparer {
525
701
  * Git identity, ownership, and cleanup are delegated to this preparer; the
526
702
  * command layer only consumes the resulting managed record.
527
703
  */
528
- async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint) {
704
+ async prepareExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId, hint, heldFence) {
529
705
  const task = requireTask(this.store, taskId);
530
- const lineage = executionLaneLineage(this.store, task, executionGroupId, executionLaneId, hint);
531
- const source = lineage.purpose === "execution"
532
- ? this.store.getWorkItemWorkspace(taskId, lineage.workItemId)
533
- : this.store.getReviewRoundWorkspace(taskId, lineage.reviewRoundId);
534
- if (source === null) {
535
- throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
536
- }
537
- const taskSegment = this.#taskSegment(task);
538
- const owner = lineage.purpose === "execution"
539
- ? {
540
- type: "execution-lane",
541
- taskId,
542
- executionGroupId,
543
- executionLaneId,
544
- purpose: "execution",
545
- workItemId: lineage.workItemId
546
- }
547
- : {
548
- type: "execution-lane",
549
- taskId,
550
- executionGroupId,
551
- executionLaneId,
552
- purpose: "review",
553
- reviewRoundId: lineage.reviewRoundId
554
- };
555
- const existing = this.store.getManagedWorkspace(owner);
556
- if (existing !== null) {
557
- if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
558
- throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
559
- }
560
- await ensureWorkspaceView(existing.root, existing.entries);
561
- for (const entry of existing.entries.filter(({ access }) => access === "write")) {
562
- const project = requireProject(this.store, entry.projectId);
563
- const physical = await this.git.ensureWorktree({
564
- repositoryPath: project.path,
565
- container: this.#projectContainer(project.name),
566
- taskSegment,
567
- roleName: managedWorktreeName(owner),
568
- baseRef: entry.baseCommit
569
- });
570
- if (physical.path !== entry.path || physical.branch !== entry.branch) {
571
- throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
572
- }
573
- }
574
- return existing;
575
- }
576
- const item = lineage.purpose === "execution"
577
- ? this.store.getWorkItem(taskId, lineage.workItemId)
578
- : null;
579
- const writable = new Set(lineage.purpose === "execution"
580
- ? item?.writeProjectIds ?? []
581
- : task.projectBindings.map(({ projectId }) => projectId));
582
- const prepared = [];
706
+ // Hold the per-Project maintenance fence across Lane worktree creation
707
+ // (both the reuse and the fresh-mint paths), so a concurrent
708
+ // migrate/rebuild cannot switch the Project catalog mid-prepare. A
709
+ // dispatch preflight that prepares a whole new Group passes the fence it
710
+ // already holds (one locked boundary across preparation and adoption);
711
+ // otherwise the fence is acquired here.
712
+ const { release, current: lockedTask } = heldFence === undefined
713
+ ? this.#acquireTaskProjectMaintenanceLocks(task)
714
+ : { release: () => { }, current: heldFence.current };
583
715
  try {
584
- for (const binding of task.projectBindings) {
585
- const project = requireProject(this.store, binding.projectId);
586
- const sourceEntry = requireWorkspaceEntry(source, project.id);
587
- if (!writable.has(project.id)) {
588
- prepared.push({ project, entry: { ...sourceEntry, access: "read" } });
589
- continue;
716
+ const lineage = executionLaneLineage(this.store, lockedTask, executionGroupId, executionLaneId, hint);
717
+ const source = lineage.purpose === "execution"
718
+ ? this.store.getWorkItemWorkspace(taskId, lineage.workItemId)
719
+ : this.store.getReviewRoundWorkspace(taskId, lineage.reviewRoundId);
720
+ if (source === null) {
721
+ throw new Error(`Execution Lane source workspace is not ready: ${executionLaneId}.`);
722
+ }
723
+ const taskSegment = this.#taskSegment(lockedTask);
724
+ const owner = lineage.purpose === "execution"
725
+ ? {
726
+ type: "execution-lane",
727
+ taskId,
728
+ executionGroupId,
729
+ executionLaneId,
730
+ purpose: "execution",
731
+ workItemId: lineage.workItemId
590
732
  }
591
- const physical = await this.git.ensureWorktree({
592
- repositoryPath: project.path,
593
- container: this.#projectContainer(project.name),
594
- taskSegment,
595
- roleName: managedWorktreeName(owner),
596
- baseRef: sourceEntry.baseCommit
597
- });
598
- prepared.push({
599
- project,
600
- entry: {
601
- ...sourceEntry,
602
- access: "write",
603
- path: physical.path,
604
- branch: physical.branch,
605
- baseRef: sourceEntry.baseCommit,
606
- baseCommit: sourceEntry.baseCommit
733
+ : {
734
+ type: "execution-lane",
735
+ taskId,
736
+ executionGroupId,
737
+ executionLaneId,
738
+ purpose: "review",
739
+ reviewRoundId: lineage.reviewRoundId
740
+ };
741
+ const existing = this.store.getManagedWorkspace(owner);
742
+ if (existing !== null) {
743
+ if (existing.owner.type !== "execution-lane" || existing.root !== this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId)) {
744
+ throw new Error(`Execution Lane managed workspace identity changed: ${taskId}/${executionLaneId}.`);
745
+ }
746
+ await ensureWorkspaceView(existing.root, existing.entries);
747
+ this.#registerWorkspace(existing);
748
+ for (const entry of existing.entries.filter(({ access }) => access === "write")) {
749
+ const project = requireProject(this.store, entry.projectId);
750
+ const physical = await this.git.ensureWorktree({
751
+ repositoryPath: project.path,
752
+ container: this.#projectContainer(project.name),
753
+ taskSegment,
754
+ roleName: managedWorktreeName(owner),
755
+ baseRef: entry.baseCommit
756
+ });
757
+ if (physical.path !== entry.path || physical.branch !== entry.branch) {
758
+ throw new Error(`Execution Lane physical identity changed: ${taskId}/${executionLaneId}/${entry.projectId}.`);
607
759
  }
608
- });
760
+ }
761
+ return existing;
609
762
  }
610
- const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
611
- await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
612
- const workspace = createManagedWorkspace({
613
- owner,
614
- root,
615
- entries: prepared.map(({ entry }) => entry)
616
- }, this.now());
617
- 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
618
- && round.executionGroup.lanes.some(({ id }) => id === executionLaneId)));
619
- if (durableLane) {
620
- return this.store.transaction((tx) => {
621
- const current = tx.getManagedWorkspace(owner);
622
- if (current !== null && !isDeepStrictEqual(current, workspace)) {
623
- throw new Error(`Execution Lane workspace changed before adoption: ${executionLaneId}.`);
763
+ const item = lineage.purpose === "execution"
764
+ ? this.store.getWorkItem(taskId, lineage.workItemId)
765
+ : null;
766
+ const writable = new Set(lineage.purpose === "execution"
767
+ ? item?.writeProjectIds ?? []
768
+ : lockedTask.projectBindings.map(({ projectId }) => projectId));
769
+ const prepared = [];
770
+ try {
771
+ for (const binding of lockedTask.projectBindings) {
772
+ const project = requireProject(this.store, binding.projectId);
773
+ const sourceEntry = requireWorkspaceEntry(source, project.id);
774
+ if (!writable.has(project.id)) {
775
+ prepared.push({ project, entry: { ...sourceEntry, access: "read" } });
776
+ continue;
624
777
  }
625
- tx.saveManagedWorkspace(current ?? workspace);
626
- return current ?? workspace;
627
- });
778
+ const physical = await this.git.ensureWorktree({
779
+ repositoryPath: project.path,
780
+ container: this.#projectContainer(project.name),
781
+ taskSegment,
782
+ roleName: managedWorktreeName(owner),
783
+ baseRef: sourceEntry.baseCommit
784
+ });
785
+ prepared.push({
786
+ project,
787
+ entry: {
788
+ ...sourceEntry,
789
+ access: "write",
790
+ path: physical.path,
791
+ branch: physical.branch,
792
+ baseRef: sourceEntry.baseCommit,
793
+ baseCommit: sourceEntry.baseCommit
794
+ }
795
+ });
796
+ }
797
+ const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
798
+ await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
799
+ const workspace = createManagedWorkspace({
800
+ owner,
801
+ root,
802
+ entries: prepared.map(({ entry }) => entry)
803
+ }, this.now());
804
+ this.#registerWorkspace(workspace);
805
+ 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
806
+ && round.executionGroup.lanes.some(({ id }) => id === executionLaneId)));
807
+ if (durableLane) {
808
+ return this.store.transaction((tx) => {
809
+ const current = tx.getManagedWorkspace(owner);
810
+ if (current !== null && !isDeepStrictEqual(current, workspace)) {
811
+ throw new Error(`Execution Lane workspace changed before adoption: ${executionLaneId}.`);
812
+ }
813
+ tx.saveManagedWorkspace(current ?? workspace);
814
+ return current ?? workspace;
815
+ });
816
+ }
817
+ return workspace;
818
+ }
819
+ catch (error) {
820
+ await this.#discardUnadoptedEntries(lockedTask, taskSegment, prepared, managedWorktreeName(owner), true);
821
+ throw error;
628
822
  }
629
- return workspace;
630
823
  }
631
- catch (error) {
632
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, managedWorktreeName(owner), true);
633
- throw error;
824
+ finally {
825
+ release();
634
826
  }
635
827
  }
636
828
  /**
@@ -848,6 +1040,7 @@ export class FileTaskWorkspacePreparer {
848
1040
  removed ||= result === "removed";
849
1041
  }
850
1042
  await removeWorkspaceView(workspace.root);
1043
+ this.#resourceRegistrar().markWorkspaceDeleted(workspace);
851
1044
  this.store.removeManagedWorkspace(workspace.owner);
852
1045
  return removed ? "removed" : "missing";
853
1046
  }
@@ -860,11 +1053,33 @@ export class FileTaskWorkspacePreparer {
860
1053
  return "missing";
861
1054
  const task = requireTask(this.store, workspace.owner.taskId);
862
1055
  const taskSegment = this.#taskSegment(task);
863
- const state = await this.#inspectEntries(taskSegment, managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1056
+ const writable = workspace.entries.filter(({ access }) => access === "write");
1057
+ let state;
1058
+ try {
1059
+ state = await this.#inspectEntries(taskSegment, managedWorktreeName(workspace.owner), writable);
1060
+ }
1061
+ catch (error) {
1062
+ // A `project migrate` between preparation and compensation switches the
1063
+ // catalog, so the worktree's common-dir no longer matches the Project's
1064
+ // current repository. Fall back to removing the stranded worktree
1065
+ // through its own Git identity.
1066
+ if (!(error instanceof Error && error.message.includes("belongs to another project"))) {
1067
+ throw error;
1068
+ }
1069
+ let removed = false;
1070
+ for (const entry of writable) {
1071
+ const result = await this.git.removeStrandedWorktree(entry.path);
1072
+ if (result === "dirty")
1073
+ return "dirty";
1074
+ removed ||= result === "removed";
1075
+ }
1076
+ await removeWorkspaceView(workspace.root);
1077
+ return removed ? "removed" : "missing";
1078
+ }
864
1079
  if (state === "dirty")
865
1080
  return "dirty";
866
1081
  let removed = false;
867
- for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
1082
+ for (const entry of writable) {
868
1083
  const project = requireProject(this.store, entry.projectId);
869
1084
  const result = await this.git.removeWorktree({
870
1085
  repositoryPath: project.path,
@@ -929,287 +1144,295 @@ export class FileTaskWorkspacePreparer {
929
1144
  * frozen Develop snapshot. This record is owned by the ReviewRound; it is
930
1145
  * never used as a ChangeSet capture source. */
931
1146
  async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
932
- const task = requireTask(this.store, taskId);
933
- const taskSegment = this.#taskSegment(task);
934
- const round = this.store.getReviewRound(task.id, reviewRoundId);
935
- if (round === null)
936
- throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
937
- if (round.status !== "pending") {
938
- throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
939
- }
940
- const item = requireWorkItem(this.store, task.id, round.workItemId);
941
- const candidate = item.candidates.find(({ id }) => id === round.candidateId);
942
- if (candidate === undefined) {
943
- throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
944
- }
945
- const taskScope = (round.scope ?? "work-item") === "task";
946
- // A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
947
- // ReviewRound intentionally uses the latest committed Integration heads
948
- // instead, while retaining the WorkItem/Candidate anchor for storage and
949
- // lifecycle compatibility.
950
- const develop = candidate.workspace;
951
- if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
952
- throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
953
- }
954
- if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
955
- throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
956
- }
957
- const reviewer = this.store.getRole(task.id, round.reviewerRoleName);
958
- if (reviewer === null) {
959
- throw new Error(`Reviewer Role not found: ${task.id}/${round.reviewerRoleName}.`);
960
- }
961
- const snapshotCommits = new Map((taskScope
962
- ? round.taskCandidate?.projects ?? []
963
- : candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
964
- const frozenEntries = taskScope
965
- ? task.projectBindings.map((binding) => {
966
- const commit = snapshotCommits.get(binding.projectId);
967
- if (commit === undefined) {
968
- throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
969
- }
970
- const project = requireProject(this.store, binding.projectId);
971
- const identity = worktreeIdentity(taskSegment, round.id);
972
- return {
973
- projectId: binding.projectId,
974
- directory: binding.directory,
975
- access: "write",
976
- path: join(this.#projectContainer(project.name), identity.directory),
977
- branch: identity.branch,
978
- baseRef: commit,
979
- baseCommit: commit
980
- };
981
- })
982
- : develop.entries.map((entry) => {
983
- const commit = snapshotCommits.get(entry.projectId);
984
- if (commit === undefined) {
985
- throw new Error(`Candidate snapshot Project is missing: ${entry.projectId}.`);
986
- }
987
- return { ...entry, baseRef: commit, baseCommit: commit };
988
- });
989
- if (taskScope && snapshotCommits.size !== task.projectBindings.length) {
990
- throw new Error(`Task Review candidate Project scope changed: ${round.id}.`);
991
- }
992
- const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
993
- const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
994
- const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
995
- const retained = new Map();
996
- const missing = new Set();
997
- const adopted = existing?.root === reviewRoot;
998
- if (existing !== null) {
999
- if (!adopted) {
1000
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace root changed: ${round.id}.`);
1001
- }
1002
- if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, existing)) {
1003
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
1004
- }
1005
- if (existing.entries.length !== expectedEntries.size) {
1006
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
1007
- }
1008
- for (const entry of existing.entries) {
1009
- const source = expectedEntries.get(entry.projectId);
1010
- if (source === undefined) {
1011
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
1012
- }
1013
- if (!sameCommit(entry.baseCommit, source.baseCommit)) {
1014
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
1015
- + `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
1016
- }
1017
- if (entry.directory !== source.directory
1018
- || entry.access !== "write"
1019
- || !sameCommit(entry.baseRef, source.baseCommit)) {
1020
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
1021
- }
1022
- const project = requireProject(this.store, entry.projectId);
1023
- const identity = worktreeIdentity(taskSegment, round.id);
1024
- const expectedPath = join(this.#projectContainer(project.name), identity.directory);
1025
- if (entry.path !== expectedPath || entry.branch !== identity.branch) {
1026
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
1147
+ const taskSnapshot = requireTask(this.store, taskId);
1148
+ const { release, current: task } = this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
1149
+ try {
1150
+ const taskSegment = this.#taskSegment(task);
1151
+ const round = this.store.getReviewRound(task.id, reviewRoundId);
1152
+ if (round === null)
1153
+ throw new Error(`ReviewRound not found: ${task.id}/${reviewRoundId}.`);
1154
+ if (round.status !== "pending") {
1155
+ throw new Error(`ReviewRound workspace can only prepare while pending: ${round.id}.`);
1156
+ }
1157
+ const item = requireWorkItem(this.store, task.id, round.workItemId);
1158
+ const candidate = item.candidates.find(({ id }) => id === round.candidateId);
1159
+ if (candidate === undefined) {
1160
+ throw new Error(`ReviewRound Candidate not found: ${round.candidateId}.`);
1161
+ }
1162
+ const taskScope = (round.scope ?? "work-item") === "task";
1163
+ // A WorkItem ReviewRound is an immutable snapshot of Develop. A Task
1164
+ // ReviewRound intentionally uses the latest committed Integration heads
1165
+ // instead, while retaining the WorkItem/Candidate anchor for storage and
1166
+ // lifecycle compatibility.
1167
+ const develop = candidate.workspace;
1168
+ if (!taskScope && (develop === undefined || candidate.gitSnapshot === undefined)) {
1169
+ throw new Error(`Candidate has no frozen managed Git snapshot: ${candidate.id}.`);
1170
+ }
1171
+ if (!taskScope && candidate.gitSnapshot.reviewBaseCommit !== round.reviewBaseCommit) {
1172
+ throw new Error(`ReviewRound base no longer matches its Candidate: ${round.id}.`);
1173
+ }
1174
+ const reviewer = this.store.getRole(task.id, round.reviewerRoleName);
1175
+ if (reviewer === null) {
1176
+ throw new Error(`Reviewer Role not found: ${task.id}/${round.reviewerRoleName}.`);
1177
+ }
1178
+ const snapshotCommits = new Map((taskScope
1179
+ ? round.taskCandidate?.projects ?? []
1180
+ : candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
1181
+ const frozenEntries = taskScope
1182
+ ? task.projectBindings.map((binding) => {
1183
+ const commit = snapshotCommits.get(binding.projectId);
1184
+ if (commit === undefined) {
1185
+ throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
1186
+ }
1187
+ const project = requireProject(this.store, binding.projectId);
1188
+ const identity = worktreeIdentity(taskSegment, round.id);
1189
+ return {
1190
+ projectId: binding.projectId,
1191
+ directory: binding.directory,
1192
+ access: "write",
1193
+ path: join(this.#projectContainer(project.name), identity.directory),
1194
+ branch: identity.branch,
1195
+ baseRef: commit,
1196
+ baseCommit: commit
1197
+ };
1198
+ })
1199
+ : develop.entries.map((entry) => {
1200
+ const commit = snapshotCommits.get(entry.projectId);
1201
+ if (commit === undefined) {
1202
+ throw new Error(`Candidate snapshot Project is missing: ${entry.projectId}.`);
1203
+ }
1204
+ return { ...entry, baseRef: commit, baseCommit: commit };
1205
+ });
1206
+ if (taskScope && snapshotCommits.size !== task.projectBindings.length) {
1207
+ throw new Error(`Task Review candidate Project scope changed: ${round.id}.`);
1208
+ }
1209
+ const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
1210
+ const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
1211
+ const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round.id);
1212
+ const retained = new Map();
1213
+ const missing = new Set();
1214
+ const adopted = existing?.root === reviewRoot;
1215
+ if (existing !== null) {
1216
+ if (!adopted) {
1217
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace root changed: ${round.id}.`);
1218
+ }
1219
+ if (round.workspace !== undefined && !isDeepStrictEqual(round.workspace, existing)) {
1220
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
1027
1221
  }
1028
- let physical;
1029
- try {
1030
- physical = await this.git.inspect(entry.path, "HEAD");
1222
+ if (existing.entries.length !== expectedEntries.size) {
1223
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}.`);
1031
1224
  }
1032
- catch (error) {
1225
+ for (const entry of existing.entries) {
1226
+ const source = expectedEntries.get(entry.projectId);
1227
+ if (source === undefined) {
1228
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace Project scope changed: ${round.id}/${entry.projectId}.`);
1229
+ }
1230
+ if (!sameCommit(entry.baseCommit, source.baseCommit)) {
1231
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace baseCommit record mismatch for ${round.id}/${entry.projectId}: `
1232
+ + `expected ${source.baseCommit}, recorded ${entry.baseCommit}.`);
1233
+ }
1234
+ if (entry.directory !== source.directory
1235
+ || entry.access !== "write"
1236
+ || !sameCommit(entry.baseRef, source.baseCommit)) {
1237
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace metadata changed for ${round.id}/${entry.projectId}.`);
1238
+ }
1239
+ const project = requireProject(this.store, entry.projectId);
1240
+ const identity = worktreeIdentity(taskSegment, round.id);
1241
+ const expectedPath = join(this.#projectContainer(project.name), identity.directory);
1242
+ if (entry.path !== expectedPath || entry.branch !== identity.branch) {
1243
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
1244
+ }
1245
+ let physical;
1033
1246
  try {
1034
- await lstat(entry.path);
1247
+ physical = await this.git.inspect(entry.path, "HEAD");
1035
1248
  }
1036
- catch (probeError) {
1037
- if (isMissingPath(probeError)) {
1038
- missing.add(entry.projectId);
1039
- continue;
1249
+ catch (error) {
1250
+ try {
1251
+ await lstat(entry.path);
1040
1252
  }
1253
+ catch (probeError) {
1254
+ if (isMissingPath(probeError)) {
1255
+ missing.add(entry.projectId);
1256
+ continue;
1257
+ }
1258
+ }
1259
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace cannot be inspected for ${round.id}/${entry.projectId}: `
1260
+ + `${error instanceof Error ? error.message : String(error)}`);
1041
1261
  }
1042
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace cannot be inspected for ${round.id}/${entry.projectId}: `
1043
- + `${error instanceof Error ? error.message : String(error)}`);
1044
- }
1045
- if (physical.root !== entry.path) {
1046
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed path mismatch for ${round.id}/${entry.projectId}.`);
1047
- }
1048
- const projectRoot = await this.git.inspect(project.path, "HEAD");
1049
- if (physical.gitDirectory !== projectRoot.gitDirectory) {
1050
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed Project mismatch for ${round.id}/${entry.projectId}.`);
1051
- }
1052
- let branch;
1053
- try {
1054
- branch = await this.git.headRef(entry.path);
1055
- }
1056
- catch (error) {
1057
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace branch cannot be verified for `
1058
- + `${round.id}/${entry.projectId}: `
1059
- + `${error instanceof Error ? error.message : String(error)}`);
1060
- }
1061
- if (branch !== identity.branch) {
1062
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
1063
- + `expected ${identity.branch}, physical ${branch}.`);
1064
- }
1065
- let descendsFromBase;
1066
- try {
1067
- descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
1068
- }
1069
- catch (error) {
1070
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
1071
- + `${round.id}/${entry.projectId}: `
1072
- + `${error instanceof Error ? error.message : String(error)}`);
1073
- }
1074
- if (!descendsFromBase) {
1075
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
1076
- + `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
1077
- + `physical HEAD ${physical.baseCommit}.`);
1078
- }
1079
- retained.set(entry.projectId, { project, entry });
1080
- }
1081
- if (adopted && missing.size === 0) {
1082
- try {
1083
- await ensureWorkspaceView(reviewRoot, existing.entries);
1084
- }
1085
- catch (error) {
1086
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1087
- + `${error instanceof Error ? error.message : String(error)}`);
1088
- }
1089
- if (round.workspace === undefined) {
1090
- this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
1091
- }
1092
- return existing;
1093
- }
1094
- // The durable owner record can outlive a controller crash before the
1095
- // physical review worktrees were adopted. Recreate only missing
1096
- // physical entries; existing reviewer diagnostics remain attached to
1097
- // their frozen Candidate provenance.
1098
- }
1099
- const prepared = [];
1100
- try {
1101
- const sources = adopted
1102
- ? frozenEntries.filter((source) => missing.has(source.projectId))
1103
- : frozenEntries;
1104
- for (const source of sources) {
1105
- const project = requireProject(this.store, source.projectId);
1106
- const physical = await this.git.ensureWorktree({
1107
- repositoryPath: project.path,
1108
- container: this.#projectContainer(project.name),
1109
- taskSegment,
1110
- roleName: round.id,
1111
- baseRef: source.baseCommit
1112
- });
1113
- const entry = {
1114
- ...source,
1115
- access: "write",
1116
- path: physical.path,
1117
- branch: physical.branch,
1118
- baseRef: source.baseCommit,
1119
- baseCommit: source.baseCommit
1120
- };
1121
- prepared.push({ project, entry });
1122
- if (!sameCommit(physical.baseCommit, source.baseCommit)) {
1123
- if (existing === null) {
1124
- throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
1125
- + `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
1262
+ if (physical.root !== entry.path) {
1263
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed path mismatch for ${round.id}/${entry.projectId}.`);
1264
+ }
1265
+ const projectRoot = await this.git.inspect(project.path, "HEAD");
1266
+ if (physical.gitDirectory !== projectRoot.gitDirectory) {
1267
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed Project mismatch for ${round.id}/${entry.projectId}.`);
1268
+ }
1269
+ let branch;
1270
+ try {
1271
+ branch = await this.git.headRef(entry.path);
1272
+ }
1273
+ catch (error) {
1274
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace branch cannot be verified for `
1275
+ + `${round.id}/${entry.projectId}: `
1276
+ + `${error instanceof Error ? error.message : String(error)}`);
1277
+ }
1278
+ if (branch !== identity.branch) {
1279
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed branch mismatch for ${round.id}/${entry.projectId}: `
1280
+ + `expected ${identity.branch}, physical ${branch}.`);
1126
1281
  }
1127
1282
  let descendsFromBase;
1128
1283
  try {
1129
- descendsFromBase = await this.git.isAncestor(project.path, source.baseCommit, physical.baseCommit);
1284
+ descendsFromBase = await this.git.isAncestor(project.path, entry.baseCommit, physical.baseCommit);
1130
1285
  }
1131
1286
  catch (error) {
1132
1287
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
1133
- + `${round.id}/${source.projectId}: `
1288
+ + `${round.id}/${entry.projectId}: `
1134
1289
  + `${error instanceof Error ? error.message : String(error)}`);
1135
1290
  }
1136
1291
  if (!descendsFromBase) {
1137
1292
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
1138
- + `${round.id}/${source.projectId}: expected ancestor ${source.baseCommit}, `
1293
+ + `${round.id}/${entry.projectId}: expected ancestor ${entry.baseCommit}, `
1139
1294
  + `physical HEAD ${physical.baseCommit}.`);
1140
1295
  }
1296
+ retained.set(entry.projectId, { project, entry });
1141
1297
  }
1142
- }
1143
- const preparedByProject = new Map(prepared.map(({ project, entry }) => [project.id, entry]));
1144
- const entries = adopted
1145
- ? frozenEntries.map((source) => {
1146
- const retainedEntry = retained.get(source.projectId)?.entry;
1147
- if (retainedEntry !== undefined)
1148
- return retainedEntry;
1149
- const preparedEntry = preparedByProject.get(source.projectId);
1150
- if (preparedEntry === undefined) {
1151
- throw new Error(`ReviewRound workspace Project could not be reconstructed: `
1152
- + `${round.id}/${source.projectId}.`);
1298
+ if (adopted && missing.size === 0) {
1299
+ try {
1300
+ await ensureWorkspaceView(reviewRoot, existing.entries);
1301
+ this.#registerWorkspace(existing);
1153
1302
  }
1154
- return preparedEntry;
1155
- })
1156
- : prepared.map(({ entry }) => entry);
1303
+ catch (error) {
1304
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1305
+ + `${error instanceof Error ? error.message : String(error)}`);
1306
+ }
1307
+ if (round.workspace === undefined) {
1308
+ this.store.saveReviewRound(task.id, attachReviewRoundWorkspace(round, existing));
1309
+ }
1310
+ return existing;
1311
+ }
1312
+ // The durable owner record can outlive a controller crash before the
1313
+ // physical review worktrees were adopted. Recreate only missing
1314
+ // physical entries; existing reviewer diagnostics remain attached to
1315
+ // their frozen Candidate provenance.
1316
+ }
1317
+ const prepared = [];
1157
1318
  try {
1158
- await ensureWorkspaceView(reviewRoot, entries);
1319
+ const sources = adopted
1320
+ ? frozenEntries.filter((source) => missing.has(source.projectId))
1321
+ : frozenEntries;
1322
+ for (const source of sources) {
1323
+ const project = requireProject(this.store, source.projectId);
1324
+ const physical = await this.git.ensureWorktree({
1325
+ repositoryPath: project.path,
1326
+ container: this.#projectContainer(project.name),
1327
+ taskSegment,
1328
+ roleName: round.id,
1329
+ baseRef: source.baseCommit
1330
+ });
1331
+ const entry = {
1332
+ ...source,
1333
+ access: "write",
1334
+ path: physical.path,
1335
+ branch: physical.branch,
1336
+ baseRef: source.baseCommit,
1337
+ baseCommit: source.baseCommit
1338
+ };
1339
+ prepared.push({ project, entry });
1340
+ if (!sameCommit(physical.baseCommit, source.baseCommit)) {
1341
+ if (existing === null) {
1342
+ throw new Error(`ReviewRound workspace baseCommit mismatch for ${round.id}/${source.projectId}: `
1343
+ + `expected ${source.baseCommit}, physical HEAD ${physical.baseCommit}.`);
1344
+ }
1345
+ let descendsFromBase;
1346
+ try {
1347
+ descendsFromBase = await this.git.isAncestor(project.path, source.baseCommit, physical.baseCommit);
1348
+ }
1349
+ catch (error) {
1350
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace ancestry cannot be verified for `
1351
+ + `${round.id}/${source.projectId}: `
1352
+ + `${error instanceof Error ? error.message : String(error)}`);
1353
+ }
1354
+ if (!descendsFromBase) {
1355
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace HEAD does not descend from its frozen base for `
1356
+ + `${round.id}/${source.projectId}: expected ancestor ${source.baseCommit}, `
1357
+ + `physical HEAD ${physical.baseCommit}.`);
1358
+ }
1359
+ }
1360
+ }
1361
+ const preparedByProject = new Map(prepared.map(({ project, entry }) => [project.id, entry]));
1362
+ const entries = adopted
1363
+ ? frozenEntries.map((source) => {
1364
+ const retainedEntry = retained.get(source.projectId)?.entry;
1365
+ if (retainedEntry !== undefined)
1366
+ return retainedEntry;
1367
+ const preparedEntry = preparedByProject.get(source.projectId);
1368
+ if (preparedEntry === undefined) {
1369
+ throw new Error(`ReviewRound workspace Project could not be reconstructed: `
1370
+ + `${round.id}/${source.projectId}.`);
1371
+ }
1372
+ return preparedEntry;
1373
+ })
1374
+ : prepared.map(({ entry }) => entry);
1375
+ try {
1376
+ await ensureWorkspaceView(reviewRoot, entries);
1377
+ }
1378
+ catch (error) {
1379
+ if (existing !== null) {
1380
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1381
+ + `${error instanceof Error ? error.message : String(error)}`);
1382
+ }
1383
+ throw error;
1384
+ }
1385
+ const stored = existing ?? createManagedWorkspace({
1386
+ owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
1387
+ root: reviewRoot,
1388
+ entries
1389
+ }, this.now());
1390
+ this.#registerWorkspace(stored);
1391
+ return this.store.transaction((tx) => {
1392
+ const currentRound = tx.getReviewRound(task.id, round.id);
1393
+ const currentItem = tx.getWorkItem(task.id, item.id);
1394
+ if (currentRound === null || currentRound.status !== "pending"
1395
+ || currentItem === null
1396
+ || !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
1397
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
1398
+ }
1399
+ if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
1400
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
1401
+ }
1402
+ const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
1403
+ if (existing !== null
1404
+ ? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
1405
+ : currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
1406
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
1407
+ }
1408
+ const latestReviewer = tx.getRole(task.id, reviewer.name);
1409
+ if (latestReviewer === null) {
1410
+ throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
1411
+ }
1412
+ if (currentRound.workspace !== undefined
1413
+ && !sameManagedWorkspace(currentRound.workspace, stored)) {
1414
+ throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
1415
+ }
1416
+ const timestamp = this.now();
1417
+ if (currentWorkspace === null)
1418
+ tx.saveManagedWorkspace(stored);
1419
+ if (currentRound.workspace === undefined) {
1420
+ tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
1421
+ }
1422
+ if (latestReviewer.workspace !== stored.root) {
1423
+ retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
1424
+ tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
1425
+ }
1426
+ return stored;
1427
+ });
1159
1428
  }
1160
1429
  catch (error) {
1161
- if (existing !== null) {
1162
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace view cannot be reused for ${round.id}: `
1163
- + `${error instanceof Error ? error.message : String(error)}`);
1164
- }
1430
+ await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1165
1431
  throw error;
1166
1432
  }
1167
- const stored = existing ?? createManagedWorkspace({
1168
- owner: { type: "review-round", taskId: task.id, reviewRoundId: round.id },
1169
- root: reviewRoot,
1170
- entries
1171
- }, this.now());
1172
- return this.store.transaction((tx) => {
1173
- const currentRound = tx.getReviewRound(task.id, round.id);
1174
- const currentItem = tx.getWorkItem(task.id, item.id);
1175
- if (currentRound === null || currentRound.status !== "pending"
1176
- || currentItem === null
1177
- || !isDeepStrictEqual(currentItem.candidates.find(({ id }) => id === candidate.id), candidate)) {
1178
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound changed while preparing its workspace: ${round.id}.`);
1179
- }
1180
- if (tx.getActiveAgentRun(task.id, reviewer.name) !== null) {
1181
- throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role has an active Run: ${task.id}/${reviewer.name}.`);
1182
- }
1183
- const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
1184
- if (existing !== null
1185
- ? currentWorkspace === null || !sameManagedWorkspace(currentWorkspace, existing)
1186
- : currentWorkspace !== null && !sameManagedWorkspace(currentWorkspace, stored)) {
1187
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace changed: ${task.id}/${round.id}.`);
1188
- }
1189
- const latestReviewer = tx.getRole(task.id, reviewer.name);
1190
- if (latestReviewer === null) {
1191
- throw new ReviewRoundWorkspaceEvidenceError(`Reviewer Role not found: ${task.id}/${reviewer.name}.`);
1192
- }
1193
- if (currentRound.workspace !== undefined
1194
- && !sameManagedWorkspace(currentRound.workspace, stored)) {
1195
- throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace record diverged: ${round.id}.`);
1196
- }
1197
- const timestamp = this.now();
1198
- if (currentWorkspace === null)
1199
- tx.saveManagedWorkspace(stored);
1200
- if (currentRound.workspace === undefined) {
1201
- tx.saveReviewRound(task.id, attachReviewRoundWorkspace(currentRound, stored));
1202
- }
1203
- if (latestReviewer.workspace !== stored.root) {
1204
- retireWorkspaceBoundSession(tx, task.id, latestReviewer.name, timestamp);
1205
- tx.saveRole(task.id, updateRole(latestReviewer, { workspace: stored.root }, timestamp));
1206
- }
1207
- return stored;
1208
- });
1209
1433
  }
1210
- catch (error) {
1211
- await this.#discardUnadoptedEntries(task, taskSegment, prepared, round.id, existing === null, new Set([...retained.values()].map(({ entry }) => entry.path)));
1212
- throw error;
1434
+ finally {
1435
+ release();
1213
1436
  }
1214
1437
  }
1215
1438
  async inspectReviewRoundWorkspace(taskId, reviewRoundId) {
@@ -1238,7 +1461,7 @@ export class FileTaskWorkspacePreparer {
1238
1461
  if (round.workspace === undefined || !isDeepStrictEqual(round.workspace, workspace)) {
1239
1462
  throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1240
1463
  }
1241
- return this.#snapshotReviewWorkspaceEntries(round.id, workspace);
1464
+ return this.#snapshotReviewWorkspaceEntries(round.id, workspace, round.reviewBaseCommit);
1242
1465
  }
1243
1466
  /**
1244
1467
  * Snapshot the exact workspace recorded on one Reviewer Run. Panel Lanes
@@ -1267,7 +1490,7 @@ export class FileTaskWorkspacePreparer {
1267
1490
  || !isDeepStrictEqual(round.workspace, run.workspace)) {
1268
1491
  throw new Error(`Review Run workspace owner does not match its ReviewRound: ${run.id}.`);
1269
1492
  }
1270
- return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
1493
+ return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace, round.reviewBaseCommit);
1271
1494
  }
1272
1495
  if (run.workspace.owner.type !== "execution-lane"
1273
1496
  || run.workspace.owner.taskId !== taskId
@@ -1292,7 +1515,7 @@ export class FileTaskWorkspacePreparer {
1292
1515
  || run.workspace.entries.some(({ access }) => access !== "write")) {
1293
1516
  throw new Error(`Review Run Lane workspace lineage is not exact: ${run.id}.`);
1294
1517
  }
1295
- return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace);
1518
+ return this.#snapshotReviewWorkspaceEntries(round.id, run.workspace, round.reviewBaseCommit);
1296
1519
  }
1297
1520
  async inspectExecutionLaneWorkspace(taskId, executionGroupId, executionLaneId) {
1298
1521
  const task = requireTask(this.store, taskId);
@@ -1343,6 +1566,7 @@ export class FileTaskWorkspacePreparer {
1343
1566
  removed ||= result === "removed";
1344
1567
  }
1345
1568
  await removeWorkspaceView(workspace.root);
1569
+ this.#resourceRegistrar().markWorkspaceDeleted(workspace);
1346
1570
  this.store.transaction((tx) => {
1347
1571
  const currentRound = tx.getReviewRound(task.id, round.id);
1348
1572
  const currentWorkspace = tx.getReviewRoundWorkspace(task.id, round.id);
@@ -1408,6 +1632,7 @@ export class FileTaskWorkspacePreparer {
1408
1632
  removed ||= result === "removed";
1409
1633
  }
1410
1634
  await removeWorkspaceView(workspace.root);
1635
+ this.#resourceRegistrar().markWorkspaceDeleted(workspace);
1411
1636
  try {
1412
1637
  this.#recordWorkspaceRemoval(task, workspace, this.#fallbackWorkspace(), {
1413
1638
  workItemId: item.id,
@@ -1469,6 +1694,7 @@ export class FileTaskWorkspacePreparer {
1469
1694
  }
1470
1695
  assertTaskArchiveState(requireTask(this.store, task.id), task);
1471
1696
  await removeWorkspaceView(main.root);
1697
+ this.#resourceRegistrar().markWorkspaceDeleted(main);
1472
1698
  assertTaskArchiveState(requireTask(this.store, task.id), task);
1473
1699
  this.#recordWorkspaceRemoval(task, main, this.#fallbackWorkspace());
1474
1700
  }
@@ -1490,6 +1716,28 @@ export class FileTaskWorkspacePreparer {
1490
1716
  if (!["draft", "active"].includes(task.status)) {
1491
1717
  throw new Error(`Only a draft or active Task can be rebuilt in place: ${task.id}/${task.status}.`);
1492
1718
  }
1719
+ // The rebuild holds every touched Project's maintenance fence for its
1720
+ // whole duration, so the Controller defers preparation and no other
1721
+ // maintenance interleaves. The resume path may clean up legacy
1722
+ // worktrees/refs in any Project; the fresh path touches its bound
1723
+ // Projects plus any still holding the Task's legacy refs.
1724
+ const projectIds = task.workspaceIdentity !== undefined
1725
+ ? this.store.listProjects().map(({ id }) => id)
1726
+ : [
1727
+ ...new Set([
1728
+ ...task.projectBindings.map(({ projectId }) => projectId),
1729
+ ...(await this.listLegacyTaskRefs(task.id)).map(({ projectId }) => projectId)
1730
+ ])
1731
+ ];
1732
+ const releaseMaintenance = acquireProjectMaintenanceLocks(this.home, projectIds);
1733
+ try {
1734
+ return await this.#rebuildTaskWorkspaceLocked(task, options);
1735
+ }
1736
+ finally {
1737
+ releaseMaintenance();
1738
+ }
1739
+ }
1740
+ async #rebuildTaskWorkspaceLocked(task, options) {
1493
1741
  if (task.workspaceIdentity !== undefined) {
1494
1742
  const current = this.store.getTaskWorkspace(task.id);
1495
1743
  if (current !== null && current.owner.type === "task") {
@@ -1500,6 +1748,9 @@ export class FileTaskWorkspacePreparer {
1500
1748
  // ("dirty") status and can no longer be removed cleanly.
1501
1749
  await this.#removeLegacyWorktrees(task, this.store.listProjects().map((project) => project.id));
1502
1750
  const archived = await this.#archiveLegacyRefs(task);
1751
+ // Reclaim token worktrees/branches a crashed prepare or rebuild left
1752
+ // behind without a catalog record, so they do not accumulate.
1753
+ await this.#reclaimOrphanedTaskWorktrees(task);
1503
1754
  return { task: requireTask(this.store, task.id), archived, resumed: true };
1504
1755
  }
1505
1756
  assertTaskHasNoEvidence(this.store, task.id);
@@ -1511,6 +1762,12 @@ export class FileTaskWorkspacePreparer {
1511
1762
  && await this.#inspectLegacyTaskEntries(task, existing.entries) === "dirty") {
1512
1763
  throw new Error(`Task workspace is dirty and blocks the rebuild: ${task.id}.`);
1513
1764
  }
1765
+ // Reclaim token worktrees/branches a crashed prepare or rebuild left
1766
+ // behind without a catalog record, before minting a new identity. A hard
1767
+ // crash after `ensureWorktree` but before the Task/catalog transaction
1768
+ // leaves the Task unbound; without this reaping the fresh path would
1769
+ // report success and leave the orphaned token worktree/branch behind.
1770
+ await this.#reclaimOrphanedTaskWorktrees(task);
1514
1771
  // Resolve verified remote SHAs before any Git side effect, mirroring the
1515
1772
  // first-prepare pinning: a remote default Project is fetched and its
1516
1773
  // exact advertised SHA is pinned; a local or explicit ref is validated.
@@ -1579,6 +1836,7 @@ export class FileTaskWorkspacePreparer {
1579
1836
  root,
1580
1837
  entries: prepared.map(({ entry }) => entry)
1581
1838
  }, this.now());
1839
+ this.#registerWorkspace(workspace);
1582
1840
  // Switch the durable record only now that every new ref/worktree exists.
1583
1841
  this.store.transaction((tx) => {
1584
1842
  const latest = requireTask(tx, task.id);
@@ -1635,7 +1893,16 @@ export class FileTaskWorkspacePreparer {
1635
1893
  async listLegacyTaskRefs(taskId) {
1636
1894
  const found = [];
1637
1895
  for (const project of this.store.listProjects()) {
1638
- const refs = await this.git.listRefs(project.path, "refs/heads/yui/");
1896
+ let refs;
1897
+ try {
1898
+ refs = await this.git.listRefs(project.path, "refs/heads/yui/");
1899
+ }
1900
+ catch (error) {
1901
+ // A deleted external checkout has no refs to scan; skip it.
1902
+ if (isMissingPath(error))
1903
+ continue;
1904
+ throw error;
1905
+ }
1639
1906
  for (const ref of refs) {
1640
1907
  if (!isLegacyTaskRef(ref))
1641
1908
  continue;
@@ -1653,26 +1920,66 @@ export class FileTaskWorkspacePreparer {
1653
1920
  * Task are refused: their worktrees may still be live. Terminal and
1654
1921
  * unknown-owner refs are archived; an already-archived or missing ref is
1655
1922
  * simply absent on retry.
1923
+ *
1924
+ * Every registered worktree on a to-be-archived ref is preflighted before
1925
+ * the first side effect: a dirty or unidentifiable one fails the whole
1926
+ * archive with nothing created, deleted, or removed. A same-repo worktree
1927
+ * this Home does not manage that still has the ref checked out fails the
1928
+ * archive closed as well, so deleting the ref cannot strand it.
1656
1929
  */
1657
1930
  async archiveLegacyTaskRefs(taskId) {
1658
1931
  const home = this.store.getHomeIdentity();
1659
1932
  const refused = [];
1660
1933
  const archived = [];
1661
- for (const entry of await this.listLegacyTaskRefs(taskId)) {
1662
- const owner = this.store.getTask(entry.taskId);
1663
- if (owner !== null && ["draft", "active"].includes(owner.status)) {
1664
- refused.push(`${entry.projectId}:${entry.ref}`);
1665
- continue;
1934
+ // The pre-lock scan discovers ONLY the sorted Project lock set. The pending
1935
+ // set is rebuilt under the fence (below) by re-listing refs and
1936
+ // re-classifying their owners, so a Task that transitions terminal ->
1937
+ // active between the scan and the lock is refused rather than archived.
1938
+ const scanned = await this.listLegacyTaskRefs(taskId);
1939
+ const projectIds = [...new Set(scanned.map(({ projectId }) => projectId))].sort();
1940
+ // The fence covers preflight and archive alike: a concurrent rebuild
1941
+ // must not remove a worktree between its inspection and its ref's
1942
+ // archival.
1943
+ const releaseMaintenance = projectIds.length === 0
1944
+ ? () => { }
1945
+ : acquireProjectMaintenanceLocks(this.home, projectIds);
1946
+ try {
1947
+ const locked = new Set(projectIds);
1948
+ const pending = [];
1949
+ for (const entry of await this.listLegacyTaskRefs(taskId)) {
1950
+ if (!locked.has(entry.projectId))
1951
+ continue; // added after the pre-lock scan; not fenced.
1952
+ const owner = this.store.getTask(entry.taskId);
1953
+ if (owner !== null && ["draft", "active"].includes(owner.status)) {
1954
+ refused.push(`${entry.projectId}:${entry.ref}`);
1955
+ continue;
1956
+ }
1957
+ pending.push({
1958
+ project: requireProject(this.store, entry.projectId),
1959
+ taskId: entry.taskId,
1960
+ ref: entry.ref,
1961
+ archiveRef: taskArchiveRef(home.homeId, entry.ref)
1962
+ });
1666
1963
  }
1667
- const project = requireProject(this.store, entry.projectId);
1668
- await this.git.archiveRef({
1669
- repositoryPath: project.path,
1670
- sourceRef: entry.ref,
1671
- archiveRef: taskArchiveRef(home.homeId, entry.ref)
1672
- });
1673
- archived.push(`${entry.projectId}:${entry.ref}`);
1964
+ for (const target of pending) {
1965
+ await this.#assertLegacyRefWorktreeArchivable(target);
1966
+ }
1967
+ for (const target of pending) {
1968
+ // Re-validate under the fence: a Task reopened after classification
1969
+ // but before this point must not have its ref deleted.
1970
+ const current = this.store.getTask(target.taskId);
1971
+ if (current !== null && ["draft", "active"].includes(current.status)) {
1972
+ refused.push(`${target.project.id}:${target.ref}`);
1973
+ continue;
1974
+ }
1975
+ await this.#archiveLegacyRef(target);
1976
+ archived.push(`${target.project.id}:${target.ref}`);
1977
+ }
1978
+ return { archived, refused };
1979
+ }
1980
+ finally {
1981
+ releaseMaintenance();
1674
1982
  }
1675
- return { archived, refused };
1676
1983
  }
1677
1984
  async #inspectEntries(taskSegment, roleName, entries) {
1678
1985
  if (entries.length === 0)
@@ -1692,8 +1999,9 @@ export class FileTaskWorkspacePreparer {
1692
1999
  }
1693
2000
  return found ? "clean" : "missing";
1694
2001
  }
1695
- async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace) {
2002
+ async #snapshotReviewWorkspaceEntries(reviewRoundId, workspace, reviewBaseCommit) {
1696
2003
  const changed = [];
2004
+ let dirty = false;
1697
2005
  for (const entry of workspace.entries) {
1698
2006
  if (await this.git.headRef(entry.path) !== entry.branch) {
1699
2007
  throw new Error(`Review Project workspace left its managed branch: ${reviewRoundId}/${entry.projectId}.`);
@@ -1707,11 +2015,22 @@ export class FileTaskWorkspacePreparer {
1707
2015
  }
1708
2016
  if (head !== entry.baseCommit)
1709
2017
  changed.push(head);
2018
+ if (!await this.git.isClean(entry.path))
2019
+ dirty = true;
1710
2020
  }
1711
2021
  if (changed.length > 1) {
1712
2022
  throw new Error(`Review workspace has diagnostic commits in multiple Projects; preserve it for Leader routing: ${reviewRoundId}.`);
1713
2023
  }
1714
- return changed.length === 0 ? {} : { evidenceCommit: changed[0] };
2024
+ // A dirty worktree has uncommitted diagnostics: no single commit captures
2025
+ // the tree the checks ran on, so no evidenceCommit can attest it and the
2026
+ // queue must re-run the gate. A clean worktree attests that checks ran on
2027
+ // the recorded tree: the frozen base when the reviewer made no commits, or
2028
+ // the reviewer's single diagnostic commit otherwise.
2029
+ if (dirty)
2030
+ return {};
2031
+ return changed.length === 0
2032
+ ? { evidenceCommit: reviewBaseCommit }
2033
+ : { evidenceCommit: changed[0] };
1715
2034
  }
1716
2035
  #projectContainer(projectName) {
1717
2036
  return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
@@ -1753,6 +2072,7 @@ export class FileTaskWorkspacePreparer {
1753
2072
  if (removal === "dirty") {
1754
2073
  throw new Error(`Unadopted managed worktree is dirty and was retained at ${entry.path}; inspect it and retry.`);
1755
2074
  }
2075
+ this.#resourceRegistrar().markPathsDeleted([entry.path]);
1756
2076
  }
1757
2077
  }
1758
2078
  /**
@@ -1769,7 +2089,16 @@ export class FileTaskWorkspacePreparer {
1769
2089
  const seen = new Set();
1770
2090
  const archived = [];
1771
2091
  for (const project of projects) {
1772
- const refs = await this.git.listRefs(project.path, `refs/heads/yui/${task.id}/`);
2092
+ let refs;
2093
+ try {
2094
+ refs = await this.git.listRefs(project.path, `refs/heads/yui/${task.id}/`);
2095
+ }
2096
+ catch (error) {
2097
+ // A deleted external checkout has no refs to archive; skip it.
2098
+ if (isMissingPath(error))
2099
+ continue;
2100
+ throw error;
2101
+ }
1773
2102
  for (const ref of refs) {
1774
2103
  const ownerTaskId = ref.slice("refs/heads/yui/".length).split("/")[0];
1775
2104
  if (ownerTaskId !== task.id || !isLegacyTaskRef(ref))
@@ -1778,9 +2107,10 @@ export class FileTaskWorkspacePreparer {
1778
2107
  if (seen.has(key))
1779
2108
  continue;
1780
2109
  seen.add(key);
1781
- await this.git.archiveRef({
1782
- repositoryPath: project.path,
1783
- sourceRef: ref,
2110
+ await this.#archiveLegacyRef({
2111
+ project,
2112
+ taskId: task.id,
2113
+ ref,
1784
2114
  archiveRef: taskArchiveRef(home.homeId, ref)
1785
2115
  });
1786
2116
  archived.push(key);
@@ -1788,6 +2118,77 @@ export class FileTaskWorkspacePreparer {
1788
2118
  }
1789
2119
  return archived;
1790
2120
  }
2121
+ /**
2122
+ * The recorded worktree of a legacy Task ref, when the ref is the Task's
2123
+ * main branch. The legacy layout registers exactly one worktree per Task
2124
+ * and Project (`<taskId>/main` on `yui/<taskId>/main`); every other legacy
2125
+ * ref has no registered worktree and is archived directly.
2126
+ */
2127
+ #legacyRefWorktree(target) {
2128
+ const identity = worktreeIdentity(target.taskId, MAIN_WORKTREE);
2129
+ if (target.ref !== `refs/heads/${identity.branch}`)
2130
+ return undefined;
2131
+ const container = this.#projectContainer(target.project.name);
2132
+ return {
2133
+ repositoryPath: target.project.path,
2134
+ container,
2135
+ path: join(container, target.taskId, MAIN_WORKTREE),
2136
+ branch: identity.branch,
2137
+ taskSegment: target.taskId,
2138
+ roleName: MAIN_WORKTREE
2139
+ };
2140
+ }
2141
+ /**
2142
+ * Preflight the worktree of a to-be-archived ref: a registered worktree
2143
+ * must be clean (or absent) before the archive may touch any ref. An
2144
+ * unidentifiable worktree makes `inspectRecordedWorktree` throw, which
2145
+ * likewise fails the archive closed.
2146
+ */
2147
+ async #assertLegacyRefWorktreeArchivable(target) {
2148
+ const worktree = this.#legacyRefWorktree(target);
2149
+ if (worktree === undefined)
2150
+ return;
2151
+ const state = await this.git.inspectRecordedWorktree(worktree);
2152
+ if (state === "dirty") {
2153
+ throw new Error(`Legacy Task worktree is dirty and blocks the archive: ${target.taskId}/${target.project.id}.`);
2154
+ }
2155
+ }
2156
+ /**
2157
+ * Archive one legacy ref after removing its registered worktree. The
2158
+ * worktree leaves first (its commit retained in the archive ref), then the
2159
+ * active ref is deleted; a dirty worktree fails the ref closed. Each step
2160
+ * is resumable: a same-commit archive ref resumes, a missing worktree and
2161
+ * an already-deleted source are no-ops.
2162
+ *
2163
+ * Before any mutation, a same-repo worktree this Home does not manage that
2164
+ * still has the ref checked out fails the archive closed: `archiveRef`
2165
+ * deletes the ref with `update-ref -d`, which bypasses git's
2166
+ * worktree-occupancy check and would strand that worktree with an unborn
2167
+ * HEAD. The recorded worktree removed below is excluded from the check.
2168
+ */
2169
+ async #archiveLegacyRef(target) {
2170
+ const worktree = this.#legacyRefWorktree(target);
2171
+ await this.git.assertNoForeignWorktreeOnRef({
2172
+ repositoryPath: target.project.path,
2173
+ ref: target.ref,
2174
+ excludeWorktreePath: worktree?.path
2175
+ });
2176
+ if (worktree !== undefined) {
2177
+ const removal = await this.git.removeRecordedWorktree({
2178
+ ...worktree,
2179
+ retainedRef: target.archiveRef
2180
+ });
2181
+ if (removal === "dirty") {
2182
+ throw new Error(`Legacy Task worktree is dirty and blocks the archive: ${target.taskId}/${target.project.id}.`);
2183
+ }
2184
+ this.#resourceRegistrar().markPathsDeleted([worktree.path]);
2185
+ }
2186
+ await this.git.archiveRef({
2187
+ repositoryPath: target.project.path,
2188
+ sourceRef: target.ref,
2189
+ archiveRef: target.archiveRef
2190
+ });
2191
+ }
1791
2192
  /**
1792
2193
  * Remove the legacy worktrees of a Task (the bare `<taskId>/main` layout).
1793
2194
  * A missing worktree is expected on retry and ignored; a dirty one blocks.
@@ -1809,6 +2210,70 @@ export class FileTaskWorkspacePreparer {
1809
2210
  if (removal === "dirty") {
1810
2211
  throw new Error(`Legacy Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
1811
2212
  }
2213
+ this.#resourceRegistrar().markPathsDeleted([join(container, task.id, MAIN_WORKTREE)]);
2214
+ }
2215
+ }
2216
+ /**
2217
+ * Reclaim token worktrees of this Task that no catalog record owns. A
2218
+ * crashed prepare or rebuild (SIGKILL before the catalog transaction) leaves
2219
+ * a token-bearing worktree and branch behind; a retry mints a fresh token,
2220
+ * so the old one is orphaned. The resume path scans this Home's project
2221
+ * containers for `task-N-<token>` directories and removes any the catalog
2222
+ * no longer owns, reusing the recorded-worktree mechanism: prove the exact
2223
+ * identity, retain the commit in the Home archive, remove the worktree, then
2224
+ * archive and delete its now-unchecked-out branch.
2225
+ *
2226
+ * Only this Home's project containers are scanned, and every candidate must
2227
+ * pass the exact recorded-worktree proof (its branch retained in this
2228
+ * Home's Project repository) before it is removed. A deleted external
2229
+ * checkout orphans the worktree directory, which `removeRecordedWorktree`
2230
+ * removes outright; its branch is gone with the repository and needs no
2231
+ * further action.
2232
+ */
2233
+ async #reclaimOrphanedTaskWorktrees(task) {
2234
+ const cataloged = new Set(this.store.listManagedWorkspaces(task.id)
2235
+ .flatMap((workspace) => workspace.entries.map(({ path }) => path)));
2236
+ const homeId = this.store.getHomeIdentity().homeId;
2237
+ for (const project of this.store.listProjects()) {
2238
+ const container = this.#projectContainer(project.name);
2239
+ for (const segment of await listTaskTokenSegments(container, task.id)) {
2240
+ const worktreePath = join(container, segment, MAIN_WORKTREE);
2241
+ if (cataloged.has(worktreePath))
2242
+ continue;
2243
+ const branch = worktreeIdentity(segment, MAIN_WORKTREE).branch;
2244
+ const archiveRef = taskArchiveRef(homeId, `refs/heads/${branch}`);
2245
+ const removal = await this.git.removeRecordedWorktree({
2246
+ repositoryPath: project.path,
2247
+ container,
2248
+ path: worktreePath,
2249
+ branch,
2250
+ retainedRef: archiveRef,
2251
+ taskSegment: segment,
2252
+ roleName: MAIN_WORKTREE
2253
+ });
2254
+ if (removal === "dirty") {
2255
+ throw new Error(`Orphaned Task worktree is dirty and blocks the rebuild: ${task.id}/${project.id}.`);
2256
+ }
2257
+ this.#resourceRegistrar().markPathsDeleted([worktreePath]);
2258
+ // The worktree is gone; archive+delete its now-unchecked-out branch.
2259
+ // A deleted external checkout takes the branch with it, so a missing
2260
+ // repository or an already-absent branch is a no-op.
2261
+ let branchExists = false;
2262
+ try {
2263
+ branchExists = await this.git.refExists(project.path, `refs/heads/${branch}`);
2264
+ }
2265
+ catch (error) {
2266
+ if (!isMissingPath(error))
2267
+ throw error;
2268
+ }
2269
+ if (branchExists) {
2270
+ await this.git.archiveRef({
2271
+ repositoryPath: project.path,
2272
+ sourceRef: `refs/heads/${branch}`,
2273
+ archiveRef
2274
+ });
2275
+ }
2276
+ }
1812
2277
  }
1813
2278
  }
1814
2279
  async #inspectLegacyTaskEntries(task, entries) {
@@ -1932,6 +2397,17 @@ function executionLaneLineage(store, task, executionGroupId, executionLaneId, hi
1932
2397
  throw new Error(`ReviewRound not found: ${hint.reviewRoundId}.`);
1933
2398
  return { purpose: "review", workItemId: round.workItemId, reviewRoundId: round.id };
1934
2399
  }
2400
+ // Prefer the active Run's exact WorkItem for this Lane; fall back to the
2401
+ // first queued WorkItem only when no active Run owns the Lane.
2402
+ const activeLaneRun = store.listAgentRuns(task.id)
2403
+ .find((run) => run.status === "active"
2404
+ && run.purpose === "execution"
2405
+ && run.executionGroupId === executionGroupId
2406
+ && run.executionLaneId === executionLaneId
2407
+ && run.workItemId !== undefined);
2408
+ if (activeLaneRun?.workItemId !== undefined) {
2409
+ return { purpose: "execution", workItemId: activeLaneRun.workItemId, reviewRoundId: "" };
2410
+ }
1935
2411
  for (const item of store.listWorkItems(task.id)) {
1936
2412
  if (workItemExecutionGroupById(item, executionGroupId)?.lanes.some(({ id }) => id === executionLaneId)) {
1937
2413
  return { purpose: "execution", workItemId: item.id, reviewRoundId: "" };
@@ -1993,6 +2469,76 @@ function retireWorkspaceBoundSession(store, taskId, roleName, now) {
1993
2469
  store.saveTaskRoleSessionSet(retireTaskRoleSessionsForWorkspace(sessions, now));
1994
2470
  }
1995
2471
  }
2472
+ function canCorrectActiveWorkItemRoleWorkspaceHint(store, taskId, role, item, workspace) {
2473
+ if (role.taskId !== taskId
2474
+ || role.status !== "running"
2475
+ || item.taskId !== taskId
2476
+ || item.assignee !== role.name
2477
+ || ["completed", "failed", "retired"].includes(item.status)
2478
+ || workspace.owner.type !== "work-item"
2479
+ || workspace.owner.taskId !== taskId
2480
+ || workspace.owner.workItemId !== item.id)
2481
+ return false;
2482
+ const writableProjects = workspace.entries
2483
+ .filter(({ access }) => access === "write")
2484
+ .map(({ projectId }) => projectId)
2485
+ .sort();
2486
+ if (!isDeepStrictEqual(writableProjects, [...item.writeProjectIds].sort()))
2487
+ return false;
2488
+ const run = store.getActiveAgentRun(taskId, role.name);
2489
+ if (run === null
2490
+ || run.status !== "active"
2491
+ || run.purpose !== "execution"
2492
+ || run.workItemId !== item.id
2493
+ || run.workspace === undefined
2494
+ || !sameManagedWorkspaceIdentity(run.workspace, workspace)
2495
+ || run.effective.agentId !== role.activeAgentId
2496
+ || !sameEffectiveWorkspace(run.effective.workspace, workspace))
2497
+ return false;
2498
+ const sessions = store.getTaskRoleSessionSet(taskId, role.name);
2499
+ const session = sessions?.sessions[sessions.activeAgentId];
2500
+ if (sessions === null
2501
+ || sessions.owner.scope !== "task"
2502
+ || sessions.owner.taskId !== taskId
2503
+ || sessions.owner.roleName !== role.name
2504
+ || sessions.activeAgentId !== role.activeAgentId
2505
+ || sessions.inFlight === null
2506
+ || sessions.inFlight.agentId !== role.activeAgentId
2507
+ || sessions.inFlight.runId !== run.id
2508
+ || sessions.inFlight.receiptId !== formatAgentRunReceiptId(taskId, run.id)
2509
+ || session === undefined
2510
+ || session.agentId !== role.activeAgentId
2511
+ || session.adapterId !== run.effective.adapterId
2512
+ || session.launchId === undefined
2513
+ || session.nativeSessionId === undefined
2514
+ || !["ready", "running"].includes(session.status)
2515
+ || !isDeepStrictEqual(session.effective, run.effective)
2516
+ || !sameEffectiveWorkspace(session.effective.workspace, workspace))
2517
+ return false;
2518
+ const lifecycleMailbox = store.getWorkMailbox(runtimeLifecycleTarget({
2519
+ scope: "task",
2520
+ taskId,
2521
+ roleName: role.name
2522
+ }));
2523
+ if (hasRuntimeCleanupObligation(lifecycleMailbox))
2524
+ return false;
2525
+ const lifecycle = lifecycleMailbox?.processing;
2526
+ if (lifecycle !== null
2527
+ && lifecycle !== undefined
2528
+ && isRuntimeLaunchReservation(lifecycle)) {
2529
+ const executionRef = lifecycle.executionRef;
2530
+ if (!isRuntimeLaunchReservation(lifecycle, session.launchId)
2531
+ || executionRef?.type !== "run"
2532
+ || executionRef.taskId !== taskId
2533
+ || executionRef.id !== run.id)
2534
+ return false;
2535
+ }
2536
+ return true;
2537
+ }
2538
+ function sameEffectiveWorkspace(effective, workspace) {
2539
+ return effective.root === workspace.root
2540
+ && isDeepStrictEqual(effective.entries, workspace.entries);
2541
+ }
1996
2542
  function sameManagedWorkspace(left, right) {
1997
2543
  return isDeepStrictEqual(left, right);
1998
2544
  }
@@ -2003,6 +2549,34 @@ function isMissingPath(error) {
2003
2549
  return typeof error === "object" && error !== null && "code" in error
2004
2550
  && error.code === "ENOENT";
2005
2551
  }
2552
+ /**
2553
+ * The token-bearing ref segments (`task-N-<8hex>`) under a Project's worktree
2554
+ * container for one Task. A missing container yields no segments; legacy
2555
+ * (`task-N`) and foreign-Task directories are ignored.
2556
+ */
2557
+ async function listTaskTokenSegments(container, taskId) {
2558
+ let entries;
2559
+ try {
2560
+ entries = await readdir(container, { withFileTypes: true });
2561
+ }
2562
+ catch (error) {
2563
+ if (isMissingPath(error))
2564
+ return [];
2565
+ throw error;
2566
+ }
2567
+ const prefix = `${taskId}-`;
2568
+ const segments = [];
2569
+ for (const entry of entries) {
2570
+ if (!entry.isDirectory())
2571
+ continue;
2572
+ if (!entry.name.startsWith(prefix))
2573
+ continue;
2574
+ if (!TASK_WORKSPACE_TOKEN_PATTERN.test(entry.name.slice(prefix.length)))
2575
+ continue;
2576
+ segments.push(entry.name);
2577
+ }
2578
+ return segments.sort();
2579
+ }
2006
2580
  function recordWorkspaceDisposition(store, taskId, workItem, now) {
2007
2581
  const item = store.getWorkItem(taskId, workItem.workItemId);
2008
2582
  if (item === null)