@zq-silk/yui 0.15.9 → 0.15.12

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 (160) hide show
  1. package/ARCHITECTURE.md +8 -4
  2. package/ARCHITECTURE.zh-CN.md +5 -2
  3. package/README.md +13 -5
  4. package/dist/agent/launchEnvironment.js +7 -0
  5. package/dist/agentRun/agentRun.js +3 -0
  6. package/dist/cli/commandCatalog.js +64 -16
  7. package/dist/cli/interactionPolicy.js +7 -3
  8. package/dist/cli/managedDiagnostics.js +1 -1
  9. package/dist/cli/updateOrchestrator.js +24 -1
  10. package/dist/cli/updatePorts.js +7 -3
  11. package/dist/cli/upgradeCommand.js +42 -2
  12. package/dist/cli.js +381 -107
  13. package/dist/commands/executionAuditCommands.js +10 -0
  14. package/dist/commands/globalRoleCommands.js +339 -4
  15. package/dist/commands/projectCommands.js +50 -22
  16. package/dist/commands/releaseCommands.js +18 -0
  17. package/dist/commands/taskActor.js +25 -0
  18. package/dist/commands/taskCommands.js +586 -96
  19. package/dist/commands/taskIntegrationCommands.js +19 -39
  20. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  21. package/dist/commands/taskOverviewCommand.js +4 -3
  22. package/dist/commands/taskPublicationAdoptCommand.js +127 -0
  23. package/dist/commands/taskPublicationCommands.js +11 -2
  24. package/dist/commands/taskPublicationVerifyCommand.js +23 -39
  25. package/dist/commands/taskRemoteDeliveryCommand.js +22 -11
  26. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  27. package/dist/context/runContextPack.js +3 -0
  28. package/dist/context/taskCatalog.js +187 -0
  29. package/dist/context/taskContext.js +55 -6
  30. package/dist/controller/agentHostObservation.js +155 -0
  31. package/dist/controller/clientRuntime.js +17 -2
  32. package/dist/controller/controller.js +14 -2
  33. package/dist/controller/fileSchedulerStoreAdapter.js +519 -25
  34. package/dist/controller/globalInputDelivery.js +132 -0
  35. package/dist/controller/jobControl.js +6 -2
  36. package/dist/controller/providerRetryAdmission.js +100 -0
  37. package/dist/controller/providerRetryDelivery.js +218 -0
  38. package/dist/controller/resourceInventory.js +14 -4
  39. package/dist/controller/resourceInventoryLinux.js +2 -6
  40. package/dist/controller/runtime.js +117 -7
  41. package/dist/controller/runtimeEventInbox.js +32 -3
  42. package/dist/controller/runtimeEventProcessor.js +26 -6
  43. package/dist/controller/runtimeHookRunFence.js +75 -19
  44. package/dist/controller/structuredProviderObservation.js +133 -70
  45. package/dist/coordination/workMailboxQueue.js +5 -0
  46. package/dist/execution/workItemExecutionProjection.js +1 -1
  47. package/dist/executor/agentExecutor.js +64 -4
  48. package/dist/executor/executorRegistry.js +3 -0
  49. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  50. package/dist/integration/deliveryObligation.js +2 -1
  51. package/dist/integration/gitIntegrationService.js +329 -386
  52. package/dist/integration/integrationAttempt.js +30 -4
  53. package/dist/integration/integrationQueueService.js +7 -7
  54. package/dist/integration/integrationSourceApplication.js +323 -0
  55. package/dist/lifecycle/exactRunTerminalization.js +4 -1
  56. package/dist/message/globalInterrupt.js +33 -0
  57. package/dist/message/globalProviderRetry.js +15 -0
  58. package/dist/message/inputControlResolution.js +106 -0
  59. package/dist/message/message.js +367 -0
  60. package/dist/message/messageContinuation.js +126 -3
  61. package/dist/message/taskInterrupt.js +34 -0
  62. package/dist/observability/executionAudit.js +19 -0
  63. package/dist/observability/orchestrationMetrics.js +1 -1
  64. package/dist/release/releaseHandover.js +22 -0
  65. package/dist/release/releaseWorkflowPorts.js +15 -7
  66. package/dist/repository/gitWorkspace.js +430 -107
  67. package/dist/repository/projectMaintenanceLock.js +75 -18
  68. package/dist/repository/taskWorkspaceCoordinator.js +182 -101
  69. package/dist/repository/taskWorkspacePreparer.js +205 -72
  70. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  71. package/dist/repository/workspaceCleanupInspection.js +187 -0
  72. package/dist/resources/resourceDiscovery.js +3 -2
  73. package/dist/runtime/agentError.js +5 -3
  74. package/dist/runtime/agentHost.js +179 -82
  75. package/dist/runtime/agentHostCompatibility.js +127 -0
  76. package/dist/runtime/agentHostProtocol.js +53 -0
  77. package/dist/runtime/builtinAgentErrorMappers.js +91 -0
  78. package/dist/runtime/codexAppServerRuntime.js +34 -3
  79. package/dist/runtime/executionEnvironment.js +0 -19
  80. package/dist/runtime/launchBroker.js +6 -0
  81. package/dist/runtime/providerControl.js +5 -1
  82. package/dist/runtime/providerRetry.js +198 -0
  83. package/dist/runtime/providerRuntimeIdentity.js +28 -2
  84. package/dist/runtime/sessionReconciliation.js +4 -4
  85. package/dist/runtime/sessionTokenMetrics.js +15 -5
  86. package/dist/runtime/structuredProviderHost.js +6 -2
  87. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  88. package/dist/runtime/taskUsageMetrics.js +275 -0
  89. package/dist/runtime/tmuxAdapters.js +5 -3
  90. package/dist/scheduler/activeRoleRunDelivery.js +12 -0
  91. package/dist/scheduler/leaderWakeupProcessor.js +5 -0
  92. package/dist/scheduler/operatorEvent.js +4 -0
  93. package/dist/scheduler/taskExecutionProjection.js +38 -6
  94. package/dist/scheduler/taskObservabilityProjection.js +6 -44
  95. package/dist/scheduler/wakeReason.js +7 -1
  96. package/dist/scheduler/wakeupQueue.js +2 -0
  97. package/dist/setup/setupCommand.js +26 -8
  98. package/dist/storage/homeLayout.js +130 -0
  99. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  100. package/dist/storage/migrations/integrationContinuation.js +104 -0
  101. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  102. package/dist/storage/sqliteSchema.js +167 -4
  103. package/dist/storage/sqliteStore.js +57 -1
  104. package/dist/storage/storageVersions.js +1 -1
  105. package/dist/storage/storeRpc.js +2 -0
  106. package/dist/storage/taskCatalog.js +123 -0
  107. package/dist/storage/taskStore.js +2 -0
  108. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  109. package/dist/task/archiveDiagnostics.js +129 -0
  110. package/dist/task/archivePreflight.js +124 -0
  111. package/dist/task/nextAction.js +44 -11
  112. package/dist/task/publicationAdoption.js +56 -0
  113. package/dist/task/publicationReference.js +10 -0
  114. package/dist/task/remoteDelivery.js +31 -16
  115. package/dist/web/assets/client/app.js +147 -17
  116. package/dist/web/assets/client/components.js +56 -13
  117. package/dist/web/assets/client/i18n.js +78 -4
  118. package/dist/web/assets/client/taskSurface.js +108 -1
  119. package/dist/web/assets/client/view.js +39 -8
  120. package/dist/web/assets/shell.js +29 -0
  121. package/dist/web/assets/styles/layout.js +8 -1
  122. package/dist/web/assets/styles/widgets.js +12 -0
  123. package/dist/web/webServer.js +131 -4
  124. package/dist/web/webSnapshot.js +16 -6
  125. package/dist/web/webTaskSurface.js +222 -5
  126. package/dist/workspace/cleanupInspection.js +63 -0
  127. package/dist/workspace/workItemChangeSetManager.js +111 -35
  128. package/docs/agent-result-consumption.md +4 -0
  129. package/docs/agent-result-consumption.zh-CN.md +3 -0
  130. package/docs/agent-runtime-drivers.md +7 -0
  131. package/docs/agent-runtime-drivers.zh-CN.md +5 -0
  132. package/docs/architecture/README.md +2 -0
  133. package/docs/architecture/README.zh-CN.md +3 -1
  134. package/docs/architecture/capabilities-and-resources.md +30 -5
  135. package/docs/architecture/capabilities-and-resources.zh-CN.md +23 -3
  136. package/docs/managed-turn-and-session-runtime.md +47 -0
  137. package/docs/managed-turn-and-session-runtime.zh-CN.md +40 -0
  138. package/docs/observability/README.md +62 -0
  139. package/docs/observability/README.zh-CN.md +47 -0
  140. package/docs/project-refresh.md +77 -0
  141. package/docs/project-refresh.zh-CN.md +59 -0
  142. package/docs/provider-retry.md +70 -0
  143. package/docs/release-workflow.md +39 -0
  144. package/docs/release-workflow.zh-CN.md +29 -0
  145. package/docs/sqlite-control-plane-design.md +223 -1
  146. package/docs/task-delivery.md +133 -13
  147. package/docs/task-delivery.zh-CN.md +99 -10
  148. package/docs/task-discovery.md +102 -0
  149. package/docs/task-discovery.zh-CN.md +86 -0
  150. package/docs/testing/verification-levels.md +40 -0
  151. package/docs/testing/verification-levels.zh-CN.md +23 -0
  152. package/i18n/README.zh-CN.md +13 -7
  153. package/package.json +1 -1
  154. package/skills/yui-leader/references/execution.md +154 -51
  155. package/skills/yui-leader/references/integration.md +52 -2
  156. package/skills/yui-operator/SKILL.md +19 -3
  157. package/skills/yui-reviewer/SKILL.md +4 -0
  158. package/skills/yui-runtime/SKILL.md +42 -0
  159. package/skills/yui-runtime/references/publication.md +42 -0
  160. package/skills/yui-runtime/references/recovery.md +24 -0
@@ -1,6 +1,7 @@
1
1
  import { lstat, mkdir, readlink, readdir, rm, rmdir, symlink, unlink } from "node:fs/promises";
2
- import { isAbsolute, join, relative, resolve } from "node:path";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
+ import { recordArchiveCleanup } from "../task/archiveDiagnostics.js";
4
5
  import { retireTaskRoleSessionsForWorkspace, updateRoleAgentSessionStatus } from "../executor/agentExecutor.js";
5
6
  import { formatWorkspacePreflightError } from "../executor/workspacePreflightClassification.js";
6
7
  import { updateRole } from "../role/role.js";
@@ -18,10 +19,13 @@ import { enqueueWork } from "../coordination/workMailboxQueue.js";
18
19
  import { createCandidateGitSnapshot, createDirectTaskMainSnapshot, workItemExecutionGroupById, recordWorkItemWorkspaceDisposition } from "../workItem/workItem.js";
19
20
  import { createManagedWorkspace, isTaskOwnedWorkspace, managedWorkspaceKey, managedWorktreeName, sameManagedWorkspaceIdentity } from "../worktree/managedWorkspace.js";
20
21
  import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
21
- import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
22
+ import { acquireProjectMaintenanceLocks, ProjectMaintenanceLockedError, ProjectMaintenanceLockCancelledError } from "./projectMaintenanceLock.js";
22
23
  import { generateTaskWorkspaceIdentity, taskWorkspaceRefSegment, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
23
24
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
24
25
  import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
26
+ import { managedTaskRoot } from "../storage/homeLayout.js";
27
+ import { inspectWorkspaceCleanup } from "./workspaceCleanupInspection.js";
28
+ import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
25
29
  const MAIN_WORKTREE = "main";
26
30
  const LEADER_ROLE = "leader";
27
31
  /**
@@ -60,11 +64,21 @@ export class FileTaskWorkspacePreparer {
60
64
  store;
61
65
  git;
62
66
  now;
63
- constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date()) {
67
+ maintenanceLockOptions;
68
+ constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), maintenanceLockOptions = {}) {
64
69
  this.home = home;
65
70
  this.store = store;
66
71
  this.git = git;
67
72
  this.now = now;
73
+ this.maintenanceLockOptions = maintenanceLockOptions;
74
+ }
75
+ inspectWorkspaceCleanup(workspace, disposition, forceArchive = false) {
76
+ return inspectWorkspaceCleanup(this.store, this.git, workspace, disposition, forceArchive);
77
+ }
78
+ async #assertWorkspaceCleanup(workspace, disposition) {
79
+ const checks = await this.inspectWorkspaceCleanup(workspace, disposition);
80
+ if (checks.length > 0)
81
+ throw new CleanupInspectionError(checks);
68
82
  }
69
83
  #resourceRegistrarValue;
70
84
  #resourceRegistrar() {
@@ -73,7 +87,7 @@ export class FileTaskWorkspacePreparer {
73
87
  #registerWorkspace(workspace) {
74
88
  this.#resourceRegistrar().registerManagedWorkspace(workspace);
75
89
  }
76
- async prepareTaskWorkspace(taskId) {
90
+ async prepareTaskWorkspace(taskId, signal) {
77
91
  // The per-Project maintenance fence makes prepare mutually exclusive with
78
92
  // Project maintenance and Task archive cleanup: a concurrent migration must not switch the
79
93
  // Project catalog to the Home-managed repo while prepare is creating
@@ -85,7 +99,7 @@ export class FileTaskWorkspacePreparer {
85
99
  for (let attempt = 0;; attempt += 1) {
86
100
  try {
87
101
  const task = requireTask(this.store, taskId);
88
- const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
102
+ const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
89
103
  try {
90
104
  return await this.#prepareTaskWorkspaceLocked(current.id, false);
91
105
  }
@@ -114,11 +128,14 @@ export class FileTaskWorkspacePreparer {
114
128
  * later launch failure can never present itself as "activation never
115
129
  * happened".
116
130
  */
117
- async activateTaskWorkspace(taskId, environment = {}) {
131
+ async activateTaskWorkspace(taskId, environment = {}, signal) {
118
132
  for (let attempt = 0;; attempt += 1) {
119
133
  let attemptedRequest;
120
134
  try {
121
135
  const task = requireTask(this.store, taskId);
136
+ if (signal?.aborted) {
137
+ throw new ProjectMaintenanceLockCancelledError(task.projectBindings[0]?.projectId ?? task.id, signal.reason);
138
+ }
122
139
  const caller = taskLocalActor(this.store, environment, task.id);
123
140
  const actor = task.activationRequest?.operation.actorId === `task:${task.id}/role:leader`
124
141
  ? "leader" : caller;
@@ -146,12 +163,14 @@ export class FileTaskWorkspacePreparer {
146
163
  if (admission.disposition === "ready")
147
164
  attemptedRequest = admission.request;
148
165
  validateDraftTaskForActivation(this.store, task);
149
- const adopted = await this.#adoptActivationEnvironment(task);
150
- return adopted.workspaceFree
151
- ? this.#activateWorkspaceFreeTask(task.id, adopted, actor)
152
- : await this.#prepareActivatedTaskWorkspace(task, adopted, actor);
166
+ return await this.#prepareActivatedTaskWorkspace(task, actor, environment, signal);
153
167
  }
154
168
  catch (error) {
169
+ // Waiting did not execute the workspace operation. Preserve the
170
+ // activation request instead of turning contention/stop into failure.
171
+ if (error instanceof ProjectMaintenanceLockedError
172
+ || error instanceof ProjectMaintenanceLockCancelledError)
173
+ throw error;
155
174
  if (error instanceof StorageConflictError
156
175
  && attempt < TASK_WORKSPACE_PREPARE_MAX_CONFLICT_RETRIES) {
157
176
  continue;
@@ -168,10 +187,28 @@ export class FileTaskWorkspacePreparer {
168
187
  }
169
188
  }
170
189
  /** Prepares the managed workspace of a Task being activated, under its fences. */
171
- async #prepareActivatedTaskWorkspace(task, environment, actor) {
172
- const { release, current } = this.#acquireTaskProjectMaintenanceLocks(task);
190
+ async #prepareActivatedTaskWorkspace(task, actor, callerEnvironment, signal) {
191
+ const { release, current } = await this.#acquireTaskProjectMaintenanceLocks(task, signal);
173
192
  try {
174
- return await this.#prepareTaskWorkspaceLocked(current.id, true, environment, actor);
193
+ taskLocalActor(this.store, callerEnvironment, current.id);
194
+ if (current.executionGate.state !== "enabled") {
195
+ throw new Error(`Task execution is not enabled: ${current.id}.`);
196
+ }
197
+ if (!isDeepStrictEqual(current.activationRequest, task.activationRequest)) {
198
+ throw new Error(`Task activation request changed while waiting for Project maintenance: ${task.id}.`);
199
+ }
200
+ const provider = this.store.getTaskRoleSessionSet(current.id, LEADER_ROLE)?.providerBinding;
201
+ if (provider?.run != null
202
+ && ["submitting", "accepted", "delivery-unknown"].includes(provider.run.status)) {
203
+ throw new Error("Planning native input is unsettled; preserve activation intent and retry after its exact terminal.");
204
+ }
205
+ validateDraftTaskForActivation(this.store, current);
206
+ // Acquire before resource adoption: a timed-out/cancelled waiter must
207
+ // not leave an adopted environment to be repeated on the next attempt.
208
+ const adopted = await this.#adoptActivationEnvironment(current);
209
+ return adopted.workspaceFree
210
+ ? this.#activateWorkspaceFreeTask(current.id, adopted, actor)
211
+ : await this.#prepareTaskWorkspaceLocked(current.id, true, adopted, actor);
175
212
  }
176
213
  finally {
177
214
  release();
@@ -328,12 +365,19 @@ export class FileTaskWorkspacePreparer {
328
365
  * caller re-reads the Task and re-acquires the correct fence set. The
329
366
  * caller owns `release` and MUST call it on every exit path.
330
367
  */
331
- #acquireTaskProjectMaintenanceLocks(task) {
368
+ async #acquireTaskProjectMaintenanceLocks(task, signal) {
332
369
  const projectIds = task.projectBindings.map(({ projectId }) => projectId);
370
+ const waitSignal = signal ?? this.maintenanceLockOptions.signal;
333
371
  const release = projectIds.length === 0
334
372
  ? () => { }
335
- : acquireProjectMaintenanceLocks(this.home, projectIds);
373
+ : await acquireProjectMaintenanceLocks(this.home, projectIds, {
374
+ ...this.maintenanceLockOptions,
375
+ signal: waitSignal
376
+ });
336
377
  try {
378
+ if (waitSignal?.aborted) {
379
+ throw new ProjectMaintenanceLockCancelledError(projectIds[0] ?? task.id, waitSignal.reason);
380
+ }
337
381
  const current = requireTask(this.store, task.id);
338
382
  const currentIds = current.projectBindings.map(({ projectId }) => projectId).sort();
339
383
  const lockedIds = [...projectIds].sort();
@@ -356,13 +400,16 @@ export class FileTaskWorkspacePreparer {
356
400
  * catalog in that gap. The caller owns `release` and must call it on every
357
401
  * exit path.
358
402
  */
359
- acquireTaskProjectMaintenanceLocks(taskId) {
360
- return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId));
403
+ acquireTaskProjectMaintenanceLocks(taskId, signal) {
404
+ return this.#acquireTaskProjectMaintenanceLocks(requireTask(this.store, taskId), signal);
361
405
  }
362
406
  async #prepareTaskWorkspaceLocked(taskId, activate,
363
407
  /** Environment and request identity the activation adopted, when one exists. */
364
408
  activationEnvironment = { workspaceFree: false }, actor = "user") {
365
409
  const task = requireTask(this.store, taskId);
410
+ if (task.executionGate.state !== "enabled") {
411
+ throw new Error(`Task execution is not enabled: ${task.id}.`);
412
+ }
366
413
  if (activate && task.status !== "draft") {
367
414
  throw new Error(`Only a Draft Task can atomically adopt a workspace: ${task.id}/${task.status}.`);
368
415
  }
@@ -537,7 +584,7 @@ export class FileTaskWorkspacePreparer {
537
584
  throw new Error(`Task Project baseline was not resolved: ${project.id}.`);
538
585
  }
539
586
  const identity = worktreeIdentity(taskSegment, MAIN_WORKTREE);
540
- const destination = join(this.#projectContainer(project.name), identity.directory);
587
+ const destination = join(root, safePathSegment(binding.directory));
541
588
  const physical = previous === undefined
542
589
  ? await this.git.clone({
543
590
  remoteUrl: project.remoteUrl,
@@ -908,15 +955,17 @@ export class FileTaskWorkspacePreparer {
908
955
  return projects;
909
956
  }
910
957
  async prepareWorkItemWorkspace(taskId, workItemId) {
911
- const item = requireWorkItem(this.store, taskId, workItemId);
958
+ let item = requireWorkItem(this.store, taskId, workItemId);
912
959
  const task = requireTask(this.store, item.taskId);
913
960
  assertWorkItemWorkspaceEligible(this.store, task, item);
914
961
  // Hold the per-Project maintenance fence across the ensure-main-prepared
915
962
  // and the WorkItem worktree creation, so concurrent Project maintenance
916
963
  // cannot switch the Project catalog between the two. The under-lock Task
917
964
  // snapshot drives every Project read and Git effect below.
918
- const { release, current: lockedTask } = this.#acquireTaskProjectMaintenanceLocks(task);
965
+ const { release, current: lockedTask } = await this.#acquireTaskProjectMaintenanceLocks(task);
919
966
  try {
967
+ item = requireWorkItem(this.store, taskId, workItemId);
968
+ assertWorkItemWorkspaceEligible(this.store, lockedTask, item);
920
969
  const lockedProjectIds = lockedTask.projectBindings.map(({ projectId }) => projectId);
921
970
  await this.#prepareTaskWorkspaceWithRetries(lockedTask.id, lockedProjectIds);
922
971
  // prepareTaskWorkspace may have just minted and persisted the workspace
@@ -966,9 +1015,25 @@ export class FileTaskWorkspacePreparer {
966
1015
  const requestedBase = previous?.access === "write" || requestedBaseRef === undefined
967
1016
  ? null
968
1017
  : await this.git.inspect(mainEntry.path, requestedBaseRef);
1018
+ if (previous?.access === "read" && previous.directory === binding.directory) {
1019
+ const viewPath = join(root, binding.directory);
1020
+ const metadata = await lstat(viewPath).catch((error) => {
1021
+ if (errorCode(error) === "ENOENT")
1022
+ return null;
1023
+ throw error;
1024
+ });
1025
+ if (metadata?.isSymbolicLink()) {
1026
+ if (resolve(root, await readlink(viewPath)) !== resolve(previous.path)) {
1027
+ throw new Error(`Read-only workspace view changed before scope expansion: ${viewPath}.`);
1028
+ }
1029
+ // Remove only the registered read view, never its target.
1030
+ await unlink(viewPath);
1031
+ }
1032
+ }
969
1033
  const physical = await this.git.ensureWorktree({
970
1034
  repositoryPath: mainEntry.path,
971
- container: this.#projectContainer(project.name),
1035
+ container: root,
1036
+ directory: binding.directory,
972
1037
  taskSegment,
973
1038
  roleName: item.id,
974
1039
  baseRef
@@ -1081,9 +1146,12 @@ export class FileTaskWorkspacePreparer {
1081
1146
  // already holds (one locked boundary across preparation and adoption);
1082
1147
  // otherwise the fence is acquired here.
1083
1148
  const { release, current: lockedTask } = heldFence === undefined
1084
- ? this.#acquireTaskProjectMaintenanceLocks(task)
1149
+ ? await this.#acquireTaskProjectMaintenanceLocks(task)
1085
1150
  : { release: () => { }, current: heldFence.current };
1086
1151
  try {
1152
+ if (lockedTask.status !== "active" || lockedTask.executionGate.state !== "enabled") {
1153
+ throw new Error(`Task is not open for Execution Lane preparation: ${lockedTask.id}.`);
1154
+ }
1087
1155
  const lineage = executionLaneLineage(this.store, lockedTask, executionGroupId, executionLaneId, hint);
1088
1156
  const item = lineage.purpose === "execution"
1089
1157
  ? this.store.getWorkItem(taskId, lineage.workItemId)
@@ -1139,7 +1207,8 @@ export class FileTaskWorkspacePreparer {
1139
1207
  const project = requireProject(this.store, entry.projectId);
1140
1208
  const physical = await this.git.ensureWorktree({
1141
1209
  repositoryPath: this.#taskRepositoryPath(taskId, project.id),
1142
- container: this.#projectContainer(project.name),
1210
+ container: existing.root,
1211
+ directory: entry.directory,
1143
1212
  taskSegment,
1144
1213
  roleName: managedWorktreeName(owner),
1145
1214
  baseRef: entry.baseCommit
@@ -1151,6 +1220,7 @@ export class FileTaskWorkspacePreparer {
1151
1220
  return existing;
1152
1221
  }
1153
1222
  const prepared = [];
1223
+ const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
1154
1224
  try {
1155
1225
  for (const binding of lockedTask.projectBindings) {
1156
1226
  const project = requireProject(this.store, binding.projectId);
@@ -1162,7 +1232,8 @@ export class FileTaskWorkspacePreparer {
1162
1232
  const inputHead = inputHeadByProject.get(project.id) ?? sourceEntry.baseCommit;
1163
1233
  const physical = await this.git.ensureWorktree({
1164
1234
  repositoryPath: this.#taskRepositoryPath(taskId, project.id),
1165
- container: this.#projectContainer(project.name),
1235
+ container: root,
1236
+ directory: sourceEntry.directory,
1166
1237
  taskSegment,
1167
1238
  roleName: managedWorktreeName(owner),
1168
1239
  baseRef: inputHead
@@ -1182,7 +1253,6 @@ export class FileTaskWorkspacePreparer {
1182
1253
  }
1183
1254
  });
1184
1255
  }
1185
- const root = this.#executionLaneWorkspaceRoot(taskId, executionGroupId, executionLaneId);
1186
1256
  await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
1187
1257
  const workspace = createManagedWorkspace({
1188
1258
  owner,
@@ -1249,6 +1319,7 @@ export class FileTaskWorkspacePreparer {
1249
1319
  && owner.executionLaneId === executionLaneId));
1250
1320
  if (workspace === undefined)
1251
1321
  return "missing";
1322
+ await this.#assertWorkspaceCleanup(workspace, "abandoned");
1252
1323
  const state = await this.#inspectEntries(task.id, this.#taskSegment(task), managedWorktreeName(workspace.owner), workspace.entries.filter(({ access }) => access === "write"));
1253
1324
  if (state === "dirty")
1254
1325
  return "dirty";
@@ -1257,13 +1328,15 @@ export class FileTaskWorkspacePreparer {
1257
1328
  const project = requireProject(this.store, entry.projectId);
1258
1329
  const result = await this.git.removeWorktree({
1259
1330
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1260
- container: this.#projectContainer(project.name),
1331
+ container: dirname(entry.path),
1332
+ directory: entry.directory,
1261
1333
  taskSegment: this.#taskSegment(task),
1262
1334
  roleName: managedWorktreeName(workspace.owner),
1263
1335
  deleteBranch: true
1264
1336
  });
1265
1337
  if (result === "dirty")
1266
1338
  return "dirty";
1339
+ this.#recordArchivePathRemoval(task, entry.path, result);
1267
1340
  removed ||= result === "removed";
1268
1341
  }
1269
1342
  await removeWorkspaceView(workspace.root);
@@ -1310,7 +1383,8 @@ export class FileTaskWorkspacePreparer {
1310
1383
  const project = requireProject(this.store, entry.projectId);
1311
1384
  const result = await this.git.removeWorktree({
1312
1385
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1313
- container: this.#projectContainer(project.name),
1386
+ container: dirname(entry.path),
1387
+ directory: entry.directory,
1314
1388
  taskSegment,
1315
1389
  roleName: managedWorktreeName(workspace.owner),
1316
1390
  deleteBranch: true
@@ -1381,8 +1455,11 @@ export class FileTaskWorkspacePreparer {
1381
1455
  * never used as a ChangeSet capture source. */
1382
1456
  async prepareReviewRoundWorkspace(taskId, reviewRoundId) {
1383
1457
  const taskSnapshot = requireTask(this.store, taskId);
1384
- const { release, current: task } = this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
1458
+ const { release, current: task } = await this.#acquireTaskProjectMaintenanceLocks(taskSnapshot);
1385
1459
  try {
1460
+ if (task.status !== "active" || task.executionGate.state !== "enabled") {
1461
+ throw new Error(`Task is not open for ReviewRound preparation: ${task.id}.`);
1462
+ }
1386
1463
  const taskSegment = this.#taskSegment(task);
1387
1464
  const round = this.store.getReviewRound(task.id, reviewRoundId);
1388
1465
  if (round === null)
@@ -1417,19 +1494,19 @@ export class FileTaskWorkspacePreparer {
1417
1494
  const snapshotCommits = new Map((taskScope
1418
1495
  ? round.taskCandidate?.projects ?? []
1419
1496
  : candidate.gitSnapshot.projects).map(({ projectId, commit }) => [projectId, commit]));
1497
+ const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round);
1420
1498
  const frozenEntries = taskScope
1421
1499
  ? task.projectBindings.map((binding) => {
1422
1500
  const commit = snapshotCommits.get(binding.projectId);
1423
1501
  if (commit === undefined) {
1424
1502
  throw new Error(`Task Review candidate Project is missing: ${binding.projectId}.`);
1425
1503
  }
1426
- const project = requireProject(this.store, binding.projectId);
1427
1504
  const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
1428
1505
  return {
1429
1506
  projectId: binding.projectId,
1430
1507
  directory: binding.directory,
1431
1508
  access: "write",
1432
- path: join(this.#projectContainer(project.name), identity.directory),
1509
+ path: join(reviewRoot, safePathSegment(binding.directory)),
1433
1510
  branch: identity.branch,
1434
1511
  baseRef: commit,
1435
1512
  baseCommit: commit
@@ -1447,7 +1524,6 @@ export class FileTaskWorkspacePreparer {
1447
1524
  }
1448
1525
  const expectedEntries = new Map(frozenEntries.map((entry) => [entry.projectId, entry]));
1449
1526
  const existing = this.store.getReviewRoundWorkspace(task.id, round.id);
1450
- const reviewRoot = this.#reviewRoundWorkspaceRoot(task.id, round);
1451
1527
  if (taskScope && existing === null) {
1452
1528
  const reassigned = await this.#reassignTaskReviewWorkspace(task, round, reviewer, reviewRoot, frozenEntries);
1453
1529
  if (reassigned !== null)
@@ -1482,7 +1558,7 @@ export class FileTaskWorkspacePreparer {
1482
1558
  }
1483
1559
  const project = requireProject(this.store, entry.projectId);
1484
1560
  const identity = worktreeIdentity(taskSegment, this.#reviewWorktreeName(round));
1485
- const expectedPath = join(this.#projectContainer(project.name), identity.directory);
1561
+ const expectedPath = join(reviewRoot, safePathSegment(entry.directory));
1486
1562
  if (entry.path !== expectedPath || entry.branch !== identity.branch) {
1487
1563
  throw new ReviewRoundWorkspaceEvidenceError(`ReviewRound workspace managed identity mismatch for ${round.id}/${entry.projectId}.`);
1488
1564
  }
@@ -1568,7 +1644,8 @@ export class FileTaskWorkspacePreparer {
1568
1644
  const project = requireProject(this.store, source.projectId);
1569
1645
  const physical = await this.git.ensureWorktree({
1570
1646
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1571
- container: this.#projectContainer(project.name),
1647
+ container: reviewRoot,
1648
+ directory: source.directory,
1572
1649
  taskSegment,
1573
1650
  roleName: this.#reviewWorktreeName(round),
1574
1651
  baseRef: source.baseCommit
@@ -1855,6 +1932,7 @@ export class FileTaskWorkspacePreparer {
1855
1932
  if (!isDeepStrictEqual(round.workspace, workspace)) {
1856
1933
  throw new Error(`ReviewRound workspace record diverged: ${round.id}.`);
1857
1934
  }
1935
+ await this.#assertWorkspaceCleanup(workspace, "abandoned");
1858
1936
  assertWorkspaceSessionsRetirable(this.store, task.id, round.reviewerRoleName, this.now());
1859
1937
  for (const lane of round.executionGroup?.lanes ?? []) {
1860
1938
  assertWorkspaceSessionsRetirable(this.store, task.id, lane.roleName, this.now());
@@ -1869,7 +1947,8 @@ export class FileTaskWorkspacePreparer {
1869
1947
  const project = requireProject(this.store, entry.projectId);
1870
1948
  const result = await this.git.removeWorktree({
1871
1949
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1872
- container: this.#projectContainer(project.name),
1950
+ container: dirname(entry.path),
1951
+ directory: entry.directory,
1873
1952
  taskSegment: this.#taskSegment(task),
1874
1953
  roleName: this.#reviewWorktreeName(round),
1875
1954
  deleteBranch: true
@@ -1877,6 +1956,7 @@ export class FileTaskWorkspacePreparer {
1877
1956
  if (result === "dirty") {
1878
1957
  throw new Error(`Review workspace changed after cleanup preflight: ${round.id}.`);
1879
1958
  }
1959
+ this.#recordArchivePathRemoval(task, entry.path, result);
1880
1960
  removed ||= result === "removed";
1881
1961
  }
1882
1962
  await removeWorkspaceView(workspace.root);
@@ -1920,6 +2000,7 @@ export class FileTaskWorkspacePreparer {
1920
2000
  throw new Error(`WorkItem has no managed isolated worktree workspace: ${item.id}.`);
1921
2001
  }
1922
2002
  assertWorkItemOwnsWorkspace(item, workspace);
2003
+ await this.#assertWorkspaceCleanup(workspace, disposition);
1923
2004
  if (item.assignee !== undefined) {
1924
2005
  assertWorkspaceSessionsRetirable(this.store, task.id, item.assignee, this.now());
1925
2006
  }
@@ -1935,7 +2016,8 @@ export class FileTaskWorkspacePreparer {
1935
2016
  const project = requireProject(this.store, entry.projectId);
1936
2017
  const result = await this.git.removeWorktree({
1937
2018
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
1938
- container: this.#projectContainer(project.name),
2019
+ container: dirname(entry.path),
2020
+ directory: entry.directory,
1939
2021
  taskSegment: this.#taskSegment(task),
1940
2022
  roleName: managedWorktreeName(workspace.owner),
1941
2023
  deleteBranch: true
@@ -1943,6 +2025,7 @@ export class FileTaskWorkspacePreparer {
1943
2025
  if (result === "dirty") {
1944
2026
  throw new Error(`WorkItem workspace changed after cleanup preflight: ${item.id}.`);
1945
2027
  }
2028
+ this.#recordArchivePathRemoval(task, entry.path, result);
1946
2029
  removed ||= result === "removed";
1947
2030
  }
1948
2031
  await removeWorkspaceView(workspace.root);
@@ -1956,7 +2039,7 @@ export class FileTaskWorkspacePreparer {
1956
2039
  catch (error) {
1957
2040
  if (!removed)
1958
2041
  throw error;
1959
- throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}`);
2042
+ throw new Error(`WorkItem worktree was removed but durable cleanup was not recorded; retry cleanup: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1960
2043
  }
1961
2044
  return removed ? "removed" : "missing";
1962
2045
  }
@@ -1971,7 +2054,7 @@ export class FileTaskWorkspacePreparer {
1971
2054
  let found = false;
1972
2055
  for (const entry of workspace.entries.filter(({ access }) => access === "write")) {
1973
2056
  try {
1974
- if (!await this.git.isClean(entry.path))
2057
+ if (!await this.git.inspectClean(entry.path))
1975
2058
  return "dirty";
1976
2059
  found = true;
1977
2060
  }
@@ -1983,7 +2066,7 @@ export class FileTaskWorkspacePreparer {
1983
2066
  }
1984
2067
  return found ? "clean" : "missing";
1985
2068
  }
1986
- async cleanupIntegrationWorkspace(taskId, integrationId) {
2069
+ async cleanupIntegrationWorkspace(taskId, integrationId, options = {}) {
1987
2070
  const task = requireTask(this.store, taskId);
1988
2071
  const attempt = this.store.getIntegrationAttempt(task.id, integrationId);
1989
2072
  if (attempt === null) {
@@ -1991,19 +2074,41 @@ export class FileTaskWorkspacePreparer {
1991
2074
  }
1992
2075
  if (attempt.status === "running"
1993
2076
  || attempt.status === "blocked"
2077
+ || attempt.status === "conflicted"
1994
2078
  || attempt.status === "validating") {
1995
2079
  throw new Error(`IntegrationAttempt must be terminal before cleanup: ${attempt.id}.`);
1996
2080
  }
1997
2081
  const workspace = this.store.getIntegrationWorkspace(task.id, attempt.id);
2082
+ // A failed Integration's explicitly disposable conflict tree retains its
2083
+ // existing cleanup contract outside archive. Archive never discards dirt.
2084
+ if (workspace !== null && (options.preserveChanges || task.status === "archived" || attempt.status !== "failed")) {
2085
+ await this.#assertWorkspaceCleanup(workspace, "abandoned");
2086
+ }
1998
2087
  const project = requireProject(this.store, attempt.projectId);
2088
+ // The recorded entry pins the exact on-disk worktree path; reconstruct the
2089
+ // owner root from it so removal targets the real Git worktree regardless of
2090
+ // how the layout helper derives it. Absent a recorded workspace, fall back
2091
+ // to the deterministic integration owner root + bound directory.
2092
+ const recorded = workspace?.entries[0];
2093
+ const directory = recorded?.directory
2094
+ ?? task.projectBindings.find(({ projectId }) => projectId === attempt.projectId)?.directory;
2095
+ if (directory === undefined) {
2096
+ throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
2097
+ }
1999
2098
  const result = await this.git.removeIntegrationWorktree({
2000
2099
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
2001
- container: this.#projectContainer(project.name),
2100
+ container: recorded !== undefined
2101
+ ? dirname(recorded.path)
2102
+ : this.#integrationWorkspaceRoot(task.id, attempt.id),
2103
+ directory,
2002
2104
  taskSegment: this.#taskSegment(task),
2003
2105
  integrationId: attempt.id,
2004
- discardChanges: attempt.status === "failed"
2106
+ discardChanges: options.preserveChanges !== true && task.status !== "archived" && attempt.status === "failed"
2005
2107
  });
2006
2108
  if (result !== "dirty") {
2109
+ for (const entry of workspace?.entries ?? []) {
2110
+ this.#recordArchivePathRemoval(task, entry.path, result);
2111
+ }
2007
2112
  if (workspace !== null)
2008
2113
  this.#resourceRegistrar().markWorkspaceDeleted(workspace);
2009
2114
  await rm(join(this.home, "artifacts", "integration-checks", task.id, attempt.id), { recursive: true, force: true });
@@ -2025,7 +2130,7 @@ export class FileTaskWorkspacePreparer {
2025
2130
  }
2026
2131
  return this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries);
2027
2132
  }
2028
- async cleanupTaskForArchive(taskId) {
2133
+ async cleanupTaskForArchive(taskId, disposition = "abandoned") {
2029
2134
  const task = requireTask(this.store, taskId);
2030
2135
  assertTaskArchiveState(task, task);
2031
2136
  const isolated = this.store.listManagedWorkspaces(task.id)
@@ -2040,6 +2145,7 @@ export class FileTaskWorkspacePreparer {
2040
2145
  }
2041
2146
  const main = this.store.getTaskWorkspace(task.id);
2042
2147
  if (main !== null) {
2148
+ await this.#assertWorkspaceCleanup(main, disposition);
2043
2149
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2044
2150
  if (await this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
2045
2151
  return {
@@ -2050,10 +2156,14 @@ export class FileTaskWorkspacePreparer {
2050
2156
  }
2051
2157
  for (const entry of main.entries) {
2052
2158
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2053
- const result = await this.git.removeStrandedWorktree(entry.path);
2159
+ const result = await this.git.removeTaskClone({
2160
+ path: entry.path, container: main.root, directory: entry.directory,
2161
+ taskSegment: this.#taskSegment(task), branch: entry.branch
2162
+ });
2054
2163
  if (result === "dirty") {
2055
2164
  throw new Error(`Task workspace changed after cleanup preflight: ${task.id}.`);
2056
2165
  }
2166
+ this.#recordArchivePathRemoval(task, entry.path, result);
2057
2167
  }
2058
2168
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2059
2169
  await removeWorkspaceView(main.root);
@@ -2064,6 +2174,16 @@ export class FileTaskWorkspacePreparer {
2064
2174
  this.#clearTaskWorkspace(task, this.#fallbackWorkspace());
2065
2175
  return { taskId, status: "removed" };
2066
2176
  }
2177
+ #recordArchivePathRemoval(task, path, status) {
2178
+ if (task.status !== "archived")
2179
+ return;
2180
+ recordArchiveCleanup(this.store, task.id, {
2181
+ resource: `path:${path}`, paths: [path],
2182
+ detail: status === "missing"
2183
+ ? "Directory absent; exact Git cleanup completed where applicable."
2184
+ : "Exact owned path removed."
2185
+ }, status, this.now());
2186
+ }
2067
2187
  async #inspectEntries(taskId, taskSegment, roleName, entries) {
2068
2188
  if (entries.length === 0)
2069
2189
  return "missing";
@@ -2072,7 +2192,11 @@ export class FileTaskWorkspacePreparer {
2072
2192
  const project = requireProject(this.store, entry.projectId);
2073
2193
  const state = await this.git.inspectWorktree({
2074
2194
  repositoryPath: this.#taskRepositoryPath(taskId, project.id),
2075
- container: this.#projectContainer(project.name),
2195
+ // A managed worktree lives at `root/<directory>`, so its parent is the
2196
+ // owner workspace root. Deriving it from the entry keeps this helper
2197
+ // agnostic to which owner (main/work-item/review/lane) it serves.
2198
+ container: dirname(entry.path),
2199
+ directory: entry.directory,
2076
2200
  taskSegment,
2077
2201
  roleName
2078
2202
  });
@@ -2082,9 +2206,6 @@ export class FileTaskWorkspacePreparer {
2082
2206
  }
2083
2207
  return found ? "clean" : "missing";
2084
2208
  }
2085
- #projectContainer(projectName) {
2086
- return join(resolveWorktreeRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(projectName));
2087
- }
2088
2209
  /**
2089
2210
  * The Task workspace ref segment for Git worktree derivation: the
2090
2211
  * token-bearing segment for a Task with a persisted identity, its bare id
@@ -2095,13 +2216,16 @@ export class FileTaskWorkspacePreparer {
2095
2216
  return taskWorkspaceRefSegment(task);
2096
2217
  }
2097
2218
  #taskWorkspaceRoot(taskId) {
2098
- return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "main");
2219
+ return join(resolveTaskRoot(this.home), safePathSegment(taskId), "main");
2099
2220
  }
2100
2221
  #workItemWorkspaceRoot(taskId, workItemId) {
2101
- return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
2222
+ return join(resolveTaskRoot(this.home), safePathSegment(taskId), "work-items", safePathSegment(workItemId));
2102
2223
  }
2103
2224
  #reviewRoundWorkspaceRoot(taskId, round) {
2104
- return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "reviews", safePathSegment(this.#reviewWorktreeName(round)));
2225
+ return join(resolveTaskRoot(this.home), safePathSegment(taskId), "reviews", safePathSegment(this.#reviewWorktreeName(round)));
2226
+ }
2227
+ #integrationWorkspaceRoot(taskId, integrationId) {
2228
+ return integrationWorkspaceRoot(this.home, taskId, integrationId);
2105
2229
  }
2106
2230
  #reviewWorktreeName(round) {
2107
2231
  return (round.scope ?? "work-item") === "task"
@@ -2109,7 +2233,7 @@ export class FileTaskWorkspacePreparer {
2109
2233
  : round.id;
2110
2234
  }
2111
2235
  #executionLaneWorkspaceRoot(taskId, groupId, laneId) {
2112
- return join(resolveTaskRoot(this.home, this.store.getConfig().defaultWorkspace), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
2236
+ return join(resolveTaskRoot(this.home), safePathSegment(taskId), "execution-lanes", safePathSegment(groupId), safePathSegment(laneId));
2113
2237
  }
2114
2238
  #fallbackWorkspace() {
2115
2239
  return this.store.getConfig().defaultWorkspace ?? process.cwd();
@@ -2127,7 +2251,8 @@ export class FileTaskWorkspacePreparer {
2127
2251
  }
2128
2252
  const removal = await this.git.removeWorktree({
2129
2253
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
2130
- container: this.#projectContainer(project.name),
2254
+ container: dirname(entry.path),
2255
+ directory: entry.directory,
2131
2256
  taskSegment,
2132
2257
  roleName,
2133
2258
  ...(deleteBranch ? { deleteBranch: true } : {})
@@ -2218,7 +2343,7 @@ function requireTask(store, taskId) {
2218
2343
  return task;
2219
2344
  }
2220
2345
  function assertTaskArchiveState(current, expected) {
2221
- if ((current.status !== "completed" && current.status !== "cancelled")
2346
+ if ((current.status !== "completed" && current.status !== "cancelled" && current.status !== "archived")
2222
2347
  || !isDeepStrictEqual(current, expected)) {
2223
2348
  throw new WorkspaceCleanupBlockedError("task-changed", `task:${expected.id}`, true, `Task changed during archive cleanup: ${expected.id}.`);
2224
2349
  }
@@ -2441,14 +2566,28 @@ function recordWorkspaceDisposition(store, taskId, workItem, now) {
2441
2566
  throw new Error(`Work item not found: ${workItem.workItemId}.`);
2442
2567
  store.saveWorkItem(taskId, recordWorkItemWorkspaceDisposition(item, workItem.disposition, now));
2443
2568
  }
2569
+ /**
2570
+ * Reconcile an owner workspace directory so it presents every bound Project at
2571
+ * `root/<directory>`.
2572
+ *
2573
+ * Writable Projects are real Git worktrees created in place at `root/<directory>`
2574
+ * by the Git port before this runs; they are left untouched here. Read-only
2575
+ * Projects share an upstream worktree (the Task main clone) and are surfaced as
2576
+ * genuine symlinks `root/<directory> -> entry.path`. Only the symlink views are
2577
+ * reconciled: a stale or renamed read link is repaired, an obsolete one removed.
2578
+ * A real directory (a write worktree) is never treated as a view entry, so this
2579
+ * neither creates nor removes committed Git content.
2580
+ */
2444
2581
  async function ensureWorkspaceView(root, entries) {
2445
2582
  await mkdir(root, { recursive: true });
2446
- const expected = new Map(entries.map((entry) => [entry.directory, entry.path]));
2583
+ const expected = new Map(entries.filter((entry) => entry.access === "read").map((entry) => [entry.directory, entry.path]));
2447
2584
  for (const current of await readdir(root, { withFileTypes: true })) {
2585
+ // A write entry is a real worktree directory living in place under root; it
2586
+ // is owned by Git, not by this view, so it is never reconciled or removed
2587
+ // here. Only symlinks (read-entry views) are this function's concern.
2588
+ if (!current.isSymbolicLink())
2589
+ continue;
2448
2590
  const target = expected.get(current.name);
2449
- if (!current.isSymbolicLink()) {
2450
- throw new Error(`Managed workspace contains an unexpected entry: ${join(root, current.name)}.`);
2451
- }
2452
2591
  const path = join(root, current.name);
2453
2592
  const linked = resolve(root, await readlink(path));
2454
2593
  if (target === undefined) {
@@ -2493,23 +2632,17 @@ function safePathSegment(value) {
2493
2632
  function sameCommit(left, right) {
2494
2633
  return left.toLowerCase() === right.toLowerCase();
2495
2634
  }
2496
- export function resolveWorktreeRoot(home, workspace) {
2497
- return join(resolveWorkspaceRoot(home, workspace), "worktree");
2498
- }
2499
- export function resolveTaskRoot(home, workspace) {
2500
- return join(resolveWorkspaceRoot(home, workspace), "tasks");
2635
+ export function resolveTaskRoot(home) {
2636
+ return managedTaskRoot(home);
2501
2637
  }
2502
- function resolveWorkspaceRoot(home, workspace) {
2503
- if (workspace === undefined) {
2504
- throw new Error("Project workspace is not configured; run yui setup.");
2505
- }
2506
- const homeRoot = resolve(home);
2507
- const workspaceRoot = resolve(workspace);
2508
- const fromHome = relative(homeRoot, workspaceRoot);
2509
- if (fromHome === "" || (!fromHome.startsWith("..") && !isAbsolute(fromHome))) {
2510
- throw new Error("Project workspace must be outside YUI_HOME.");
2511
- }
2512
- return workspaceRoot;
2638
+ /**
2639
+ * The integration owner root: `tasks/<taskId>/integrations/<integrationId>`.
2640
+ * A single-project integration worktree lives at `<root>/<boundDirectory>`.
2641
+ * Shared by the preparer and the integration service so create and cleanup
2642
+ * derive the same on-disk path.
2643
+ */
2644
+ export function integrationWorkspaceRoot(home, taskId, integrationId) {
2645
+ return join(managedTaskRoot(home), safePathSegment(taskId), "integrations", safePathSegment(integrationId));
2513
2646
  }
2514
2647
  function errorCode(error) {
2515
2648
  return typeof error === "object" && error !== null && "code" in error