@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
@@ -0,0 +1,315 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Per-Project maintenance fence.
5
+ *
6
+ * `project migrate`, Task workspace rebuild, legacy ref archive, and Task
7
+ * archive cleanup are separate CLI processes that all mutate a Project's
8
+ * Git repository. Without coordination they can interleave with each other
9
+ * and with the Controller's worktree preparation. The fence is an exclusive
10
+ * directory under the Home's `locks/` area (O_EXCL via mkdir), so it works
11
+ * for managed and external Projects alike and never touches the Project's
12
+ * own checkout. A crashed holder is reclaimed once its owner process is
13
+ * gone; the owner file records PID + process start time, so a recycled PID
14
+ * can never pass for the original holder.
15
+ *
16
+ * Maintenance operations are long-lived, so a contended fence fails fast
17
+ * with a retryable error instead of blocking the CLI for seconds.
18
+ */
19
+ const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS = 250;
20
+ const PROJECT_MAINTENANCE_LOCK_RETRY_MS = 10;
21
+ /** A lock older than this with a dead owner is reclaimed. */
22
+ const STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS = 1_000;
23
+ /**
24
+ * Raised when a Project's maintenance fence is already held. The caller
25
+ * made no changes and is safe to retry once the holder finishes.
26
+ */
27
+ export class ProjectMaintenanceLockedError extends Error {
28
+ projectId;
29
+ retryable = true;
30
+ constructor(projectId) {
31
+ super(`Project maintenance is already in progress for ${projectId}; retry once it finishes.`);
32
+ this.name = "ProjectMaintenanceLockedError";
33
+ this.projectId = projectId;
34
+ }
35
+ }
36
+ /** Directory of one Project's maintenance fence, below the Home's locks area. */
37
+ export function projectMaintenanceLockPath(home, projectId) {
38
+ return join(home, "locks", "projects", `${projectId}.lock`);
39
+ }
40
+ /**
41
+ * Acquire one Project's maintenance fence. Returns the release function;
42
+ * callers MUST release on every exit path (try/finally). A live holder
43
+ * fails fast with {@link ProjectMaintenanceLockedError}; a stale (dead)
44
+ * holder is reclaimed.
45
+ *
46
+ * There is no in-process reentrancy: every acquisition contends, so two
47
+ * independent operations in the same process are mutually exclusive just
48
+ * like two processes. Genuine lexical nesting (an operation that needs the
49
+ * fence while its caller already holds it) goes through private
50
+ * already-locked methods, never through a second acquisition.
51
+ */
52
+ export function acquireProjectMaintenanceLock(home, projectId) {
53
+ const lock = projectMaintenanceLockPath(home, projectId);
54
+ mkdirSync(join(home, "locks", "projects"), { recursive: true, mode: 0o700 });
55
+ const deadline = Date.now() + PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS;
56
+ while (true) {
57
+ try {
58
+ mkdirSync(lock, { mode: 0o700 });
59
+ const ownerIdentity = writeOwnerIdentity(lock);
60
+ let released = false;
61
+ return () => {
62
+ if (released)
63
+ return;
64
+ released = true;
65
+ // Release only the exact acquired instance: a lock that was reclaimed
66
+ // and replaced by a successor after a crash is never deleted by a
67
+ // stale release handle.
68
+ releaseOwnedProjectMaintenanceLock(lock, ownerIdentity);
69
+ };
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
73
+ throw error;
74
+ reclaimStaleProjectMaintenanceLock(lock);
75
+ if (Date.now() >= deadline)
76
+ throw new ProjectMaintenanceLockedError(projectId);
77
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, PROJECT_MAINTENANCE_LOCK_RETRY_MS);
78
+ }
79
+ }
80
+ }
81
+ /** Write this process's owner identity and return the exact bytes recorded. */
82
+ function writeOwnerIdentity(lock) {
83
+ const identity = `${process.pid}:${processStartIdentity() ?? ""}`;
84
+ writeFileSync(join(lock, "owner"), `${identity}\n`, { mode: 0o600 });
85
+ return identity;
86
+ }
87
+ /**
88
+ * Remove the lock directory only when its owner file still records the exact
89
+ * identity this handle acquired. A replaced (successor) lock is left intact.
90
+ */
91
+ function releaseOwnedProjectMaintenanceLock(lock, ownerIdentity) {
92
+ let owner;
93
+ try {
94
+ owner = readFileSync(join(lock, "owner"), "utf8").trim();
95
+ }
96
+ catch (error) {
97
+ if (isEnoent(error))
98
+ return; // already gone; nothing to release.
99
+ throw error;
100
+ }
101
+ if (owner !== ownerIdentity)
102
+ return;
103
+ rmSync(lock, { recursive: true, force: true });
104
+ }
105
+ /**
106
+ * Acquire the maintenance fence for several Projects in a stable (sorted)
107
+ * order, so multi-Project maintenance can never deadlock against itself.
108
+ * A failure releases every fence already taken. Every Project contends,
109
+ * including ones this process already holds: callers that need nesting must
110
+ * use an already-locked private path instead of acquiring twice.
111
+ */
112
+ export function acquireProjectMaintenanceLocks(home, projectIds) {
113
+ const releases = [];
114
+ try {
115
+ for (const projectId of [...new Set(projectIds)].sort()) {
116
+ releases.push(acquireProjectMaintenanceLock(home, projectId));
117
+ }
118
+ }
119
+ catch (error) {
120
+ for (const release of releases.reverse())
121
+ release();
122
+ throw error;
123
+ }
124
+ let released = false;
125
+ return () => {
126
+ if (released)
127
+ return;
128
+ released = true;
129
+ for (const release of releases.reverse())
130
+ release();
131
+ };
132
+ }
133
+ /**
134
+ * Non-blocking fence check for the Controller: a Project is fenced only
135
+ * while a live process holds its lock. A stale (dead-owner) lock reads as
136
+ * unfenced, so a crashed maintenance process never wedges preparation.
137
+ */
138
+ export function isProjectMaintenanceFenced(home, projectId) {
139
+ const lock = projectMaintenanceLockPath(home, projectId);
140
+ if (!existsSync(lock))
141
+ return false;
142
+ return lockOwnerIsAlive(lock);
143
+ }
144
+ /**
145
+ * Reclaim a stale (dead-owner) lock through a crash-safe compare-and-delete
146
+ * critical section. Two contenders can both observe the same dead owner;
147
+ * without serialization the second could rmSync the successor lock the first
148
+ * created after reclaiming, admitting two holders. A reclaim lock (itself
149
+ * reclaimable) serializes reclaimers, and under it we re-read the owner and
150
+ * delete only when it is still the exact stale instance observed above.
151
+ */
152
+ function reclaimStaleProjectMaintenanceLock(lock) {
153
+ let expectedOwner;
154
+ try {
155
+ expectedOwner = readFileSync(join(lock, "owner"), "utf8");
156
+ }
157
+ catch (error) {
158
+ if (!isEnoent(error))
159
+ throw error;
160
+ // Ownerless lock: a crash between mkdir and owner publication. Reclaim
161
+ // only when the directory is old enough that the creator is not still
162
+ // initializing; a fresh lock may have its owner file written imminently.
163
+ try {
164
+ if (Date.now() - statSync(lock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
165
+ return;
166
+ }
167
+ catch (statError) {
168
+ if (isEnoent(statError))
169
+ return;
170
+ throw statError;
171
+ }
172
+ expectedOwner = null;
173
+ }
174
+ try {
175
+ if (Date.now() - statSync(lock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
176
+ return;
177
+ }
178
+ catch (error) {
179
+ if (isEnoent(error))
180
+ return;
181
+ throw error;
182
+ }
183
+ if (expectedOwner !== null && lockOwnerIsAlive(lock))
184
+ return;
185
+ const reclaimLock = `${lock}.reclaim`;
186
+ for (let attempt = 0; attempt < 2; attempt += 1) {
187
+ try {
188
+ mkdirSync(reclaimLock, { mode: 0o700 });
189
+ }
190
+ catch (error) {
191
+ if (!isEexist(error))
192
+ throw error;
193
+ // Another reclaimer holds the critical section. Reclaim its lock only
194
+ // when orphaned (dead owner and old enough), then retry once; a live
195
+ // reclaimer will finish and the caller's next O_EXCL mkdir settles it.
196
+ if (reclaimOrphanedReclaimLock(reclaimLock))
197
+ continue;
198
+ return;
199
+ }
200
+ try {
201
+ writeOwnerIdentity(reclaimLock);
202
+ // Compare-and-delete under the lock: re-read and remove only the exact
203
+ // stale instance observed above. A successor that replaced the lock has
204
+ // different owner bytes (and a fresh mtime) and is never clobbered.
205
+ let stat;
206
+ try {
207
+ stat = statSync(lock);
208
+ }
209
+ catch (error) {
210
+ if (isEnoent(error))
211
+ return;
212
+ throw error;
213
+ }
214
+ if (Date.now() - stat.mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
215
+ return;
216
+ let currentOwner;
217
+ try {
218
+ currentOwner = readFileSync(join(lock, "owner"), "utf8");
219
+ }
220
+ catch (error) {
221
+ if (!isEnoent(error))
222
+ throw error;
223
+ // Still ownerless: only reclaim when the initial read was also
224
+ // ownerless. A non-null expectedOwner means another process wrote
225
+ // the owner file between the two reads — leave its lock intact.
226
+ if (expectedOwner !== null)
227
+ return;
228
+ currentOwner = null;
229
+ }
230
+ if (currentOwner !== expectedOwner)
231
+ return;
232
+ if (currentOwner !== null && lockOwnerIsAlive(lock))
233
+ return;
234
+ rmSync(lock, { recursive: true, force: true });
235
+ }
236
+ finally {
237
+ rmSync(reclaimLock, { recursive: true, force: true });
238
+ }
239
+ return;
240
+ }
241
+ }
242
+ /**
243
+ * Reclaim a reclaim-lock directory only when it is provably orphaned: its
244
+ * owner is dead (or unrecorded) AND it is older than the age bound, so a
245
+ * lock whose owner just mkdir'ed but has not written its owner is not stolen.
246
+ * Uses the same PID + start-time identity check as the main fence, so a
247
+ * recycled PID with a different start identity does not keep an orphaned
248
+ * reclaim lock alive. An ownerless (ENOENT owner) lock is still reclaimed;
249
+ * an unreadable owner fails closed (treated as live).
250
+ * Returns true when it removed the lock (caller retries), false otherwise.
251
+ */
252
+ function reclaimOrphanedReclaimLock(reclaimLock) {
253
+ try {
254
+ if (Date.now() - statSync(reclaimLock).mtimeMs < STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS)
255
+ return false;
256
+ if (lockOwnerIsAlive(reclaimLock))
257
+ return false;
258
+ rmSync(reclaimLock, { recursive: true, force: true });
259
+ return true;
260
+ }
261
+ catch (error) {
262
+ return isEnoent(error);
263
+ }
264
+ }
265
+ function lockOwnerIsAlive(lock) {
266
+ let owner;
267
+ try {
268
+ owner = readFileSync(join(lock, "owner"), "utf8").trim();
269
+ }
270
+ catch (error) {
271
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
272
+ return false;
273
+ // An unreadable owner file fails closed: treat the fence as held.
274
+ return true;
275
+ }
276
+ const separator = owner.indexOf(":");
277
+ const pid = Number.parseInt(separator < 0 ? owner : owner.slice(0, separator), 10);
278
+ if (!Number.isInteger(pid) || pid <= 0)
279
+ return false;
280
+ const recordedIdentity = separator < 0 ? "" : owner.slice(separator + 1);
281
+ // PID + start-time identity: a recycled PID can never match the holder.
282
+ const currentIdentity = processStartIdentity(pid);
283
+ if (currentIdentity !== undefined) {
284
+ return recordedIdentity !== "" && currentIdentity === recordedIdentity;
285
+ }
286
+ // Off /proc (non-Linux): fall back to liveness.
287
+ return processIsAlive(pid);
288
+ }
289
+ /** Linux process start time (clock ticks since boot) for a PID; undefined off /proc. */
290
+ function processStartIdentity(pid = process.pid) {
291
+ try {
292
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
293
+ const close = stat.lastIndexOf(")");
294
+ const fields = stat.slice(close + 2).split(" ");
295
+ return fields[19] ?? "0"; // field 22: starttime
296
+ }
297
+ catch {
298
+ return undefined;
299
+ }
300
+ }
301
+ function processIsAlive(pid) {
302
+ try {
303
+ process.kill(pid, 0);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ return error instanceof Error && "code" in error && error.code === "EPERM";
308
+ }
309
+ }
310
+ function isEnoent(error) {
311
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
312
+ }
313
+ function isEexist(error) {
314
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
315
+ }
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { retireConfirmedAbsentInactiveTaskRolePlaceholders } from "../executor/agentExecutor.js";
3
3
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
4
4
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
5
+ import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
5
6
  import { WorkspaceCleanupBlockedError } from "./taskWorkspacePreparer.js";
6
7
  export { WorkspaceCleanupBlockedError } from "./taskWorkspacePreparer.js";
7
8
  /**
@@ -71,15 +72,32 @@ export class TaskWorkspaceCoordinator {
71
72
  }
72
73
  if (item.workspaceDisposition === disposition)
73
74
  return "missing";
74
- const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
75
- if (state === "dirty")
76
- return "dirty";
77
- this.#assertWorkItemRuntimeQuiescent(item);
78
- await this.#stopLiveRoles(item.taskId, this.#workItemRoleNames(item));
79
- const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
80
- if (laneCleanup === "dirty")
81
- return "dirty";
82
- return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
75
+ // Hold the per-Project maintenance fence so a concurrent migrate/rebuild/
76
+ // archive cannot interleave with worktree removal.
77
+ const workspace = this.store.getWorkItemWorkspace(item.taskId, item.id);
78
+ const projectIds = workspace === null
79
+ ? []
80
+ : workspace.entries
81
+ .filter(({ access }) => access === "write")
82
+ .map(({ projectId }) => projectId);
83
+ const releaseMaintenance = projectIds.length === 0
84
+ ? () => { }
85
+ : acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
86
+ try {
87
+ const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
88
+ if (state === "dirty")
89
+ return "dirty";
90
+ this.#assertWorkItemRuntimeQuiescent(item);
91
+ this.#assertNoActiveWorkItemDurableJobs(item);
92
+ await this.#stopLiveRoles(item.taskId, this.#workItemRoleNames(item));
93
+ const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
94
+ if (laneCleanup === "dirty")
95
+ return "dirty";
96
+ return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
97
+ }
98
+ finally {
99
+ releaseMaintenance();
100
+ }
83
101
  }
84
102
  async cleanupWorkItemRuntime(taskId, workItemId) {
85
103
  const item = this.store.getWorkItem(taskId, workItemId);
@@ -96,16 +114,33 @@ export class TaskWorkspaceCoordinator {
96
114
  if (round.status !== "completed" && round.status !== "failed") {
97
115
  throw new Error(`ReviewRound must be terminal before cleanup: ${round.id}.`);
98
116
  }
99
- const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
100
- if (state === "dirty")
101
- return "dirty";
102
- await this.#stopLiveRoles(taskId, this.#reviewRoundRoleNames(round));
103
- const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
104
- if (laneCleanup === "dirty")
105
- return "dirty";
106
- return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
117
+ // Hold the per-Project maintenance fence so a concurrent migrate/rebuild/
118
+ // archive cannot interleave with worktree removal.
119
+ const workspace = this.store.getReviewRoundWorkspace(taskId, reviewRoundId);
120
+ const projectIds = workspace === null
121
+ ? []
122
+ : workspace.entries
123
+ .filter(({ access }) => access === "write")
124
+ .map(({ projectId }) => projectId);
125
+ const releaseMaintenance = projectIds.length === 0
126
+ ? () => { }
127
+ : acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
128
+ try {
129
+ const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
130
+ if (state === "dirty")
131
+ return "dirty";
132
+ await this.#stopLiveRoles(taskId, this.#reviewRoundRoleNames(round));
133
+ const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
134
+ if (laneCleanup === "dirty")
135
+ return "dirty";
136
+ return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
137
+ }
138
+ finally {
139
+ releaseMaintenance();
140
+ }
107
141
  }
108
142
  async cleanupTaskForArchive(taskId, disposition) {
143
+ let releaseMaintenance;
109
144
  try {
110
145
  const task = this.store.getTask(taskId);
111
146
  if (task === null)
@@ -116,6 +151,15 @@ export class TaskWorkspaceCoordinator {
116
151
  const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
117
152
  .sort((left, right) => managedWorkspaceKey(left.owner)
118
153
  .localeCompare(managedWorkspaceKey(right.owner)));
154
+ // Archive cleanup removes worktrees from every Project the Task uses:
155
+ // hold each Project's maintenance fence so the Controller defers
156
+ // preparation and no migrate/rebuild/archive interleaves.
157
+ const projectIds = new Set(task.projectBindings.map(({ projectId }) => projectId));
158
+ for (const workspace of managedWorkspaces) {
159
+ for (const entry of workspace.entries)
160
+ projectIds.add(entry.projectId);
161
+ }
162
+ releaseMaintenance = acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
119
163
  const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
120
164
  const workspaces = managedWorkspaces
121
165
  .filter(({ owner }) => owner.type === "work-item");
@@ -207,6 +251,7 @@ export class TaskWorkspaceCoordinator {
207
251
  if (roleNames.length > 0) {
208
252
  await this.runtime.stopTaskRoleSessions(taskId, roleNames);
209
253
  }
254
+ await this.runtime.assertTaskPhysicalResourcesReleased?.(task.id);
210
255
  this.#assertTaskArchiveSnapshot(snapshot);
211
256
  for (const workspace of laneWorkspaces) {
212
257
  this.#assertTaskArchiveLifecycle(task);
@@ -245,6 +290,9 @@ export class TaskWorkspaceCoordinator {
245
290
  })
246
291
  };
247
292
  }
293
+ finally {
294
+ releaseMaintenance?.();
295
+ }
248
296
  }
249
297
  #assertTaskArchiveSnapshot(snapshot) {
250
298
  this.#assertTaskArchiveLifecycle(snapshot.task);
@@ -274,6 +322,28 @@ export class TaskWorkspaceCoordinator {
274
322
  throw new WorkspaceCleanupBlockedError("active-run", `work-item:${item.taskId}/${item.id}`, true, `Work item still has an active Run: ${item.taskId}/${item.id}.`);
275
323
  }
276
324
  }
325
+ /**
326
+ * rr5/f4: A WorkItem workspace must not be removed while a DurableJob it
327
+ * owns could still be using it. Queued, running, and unacknowledged
328
+ * unknown-needs-attention jobs are unsettled — the runner (or its corpse)
329
+ * may still hold the worktree. Acknowledged unknown jobs are settled: a
330
+ * human/Leader has taken responsibility for the outcome.
331
+ */
332
+ #assertNoActiveWorkItemDurableJobs(item) {
333
+ const jobs = this.store.listDurableJobs?.(item.taskId);
334
+ if (jobs === undefined)
335
+ return;
336
+ const blocking = jobs.filter((job) => (job.owner.kind === "work-item"
337
+ && job.owner.workItemId === item.id
338
+ && (job.status === "queued"
339
+ || job.status === "running"
340
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
341
+ if (blocking.length > 0) {
342
+ throw new WorkspaceCleanupBlockedError("active-durable-job", `work-item:${item.taskId}/${item.id}`, true, `Work item ${item.id} still has ${blocking.length} active DurableJob(s): `
343
+ + `${blocking.map((job) => `${job.id}/${job.status}`).join(", ")}. `
344
+ + "Cancel or acknowledge them before cleanup.");
345
+ }
346
+ }
277
347
  #workItemRoleNames(item) {
278
348
  return [
279
349
  ...(item.assignee === undefined ? [] : [item.assignee]),