@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,5 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { performance } from "node:perf_hooks";
4
+ import { setTimeout as delay } from "node:timers/promises";
3
5
  /**
4
6
  * Per-Project maintenance fence.
5
7
  *
@@ -12,35 +14,66 @@ import { join } from "node:path";
12
14
  * gone; the owner file records PID + process start time, so a recycled PID
13
15
  * can never pass for the original holder.
14
16
  *
15
- * Maintenance operations are long-lived, so a contended fence fails fast
16
- * with a retryable error instead of blocking the CLI for seconds.
17
+ * Contenders wait asynchronously within one monotonic budget. Only lock
18
+ * acquisition is retried; the caller's protected operation is never replayed.
17
19
  */
18
- const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS = 250;
19
- const PROJECT_MAINTENANCE_LOCK_RETRY_MS = 10;
20
+ const PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS = 60_000;
21
+ const PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS = 200;
22
+ const PROJECT_MAINTENANCE_LOCK_RETRY_MAX_MS = 500;
20
23
  /** A lock older than this with a dead owner is reclaimed. */
21
24
  const STALE_PROJECT_MAINTENANCE_LOCK_AGE_MS = 1_000;
22
25
  /**
23
- * Raised when a Project's maintenance fence is already held. The caller
24
- * made no changes and is safe to retry once the holder finishes.
26
+ * Raised when acquisition exhausted its budget. This acquisition has not
27
+ * started the protected operation; callers retain responsibility for any
28
+ * earlier effects and must not replay the whole operation implicitly.
25
29
  */
26
30
  export class ProjectMaintenanceLockedError extends Error {
31
+ timeoutMs;
27
32
  projectId;
28
33
  retryable = true;
29
- constructor(projectId) {
30
- super(`Project maintenance is already in progress for ${projectId}; retry once it finishes.`);
34
+ constructor(projectId, timeoutMs = PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS) {
35
+ super(`Timed out after ${timeoutMs}ms waiting for Project maintenance on ${projectId}; `
36
+ + "the lock was not acquired. Read current state before retrying.");
37
+ this.timeoutMs = timeoutMs;
31
38
  this.name = "ProjectMaintenanceLockedError";
32
39
  this.projectId = projectId;
33
40
  }
34
41
  }
42
+ export class ProjectMaintenanceLockCancelledError extends Error {
43
+ projectId;
44
+ constructor(projectId, cause) {
45
+ super(`Cancelled waiting for Project maintenance on ${projectId}; the lock was not acquired.`, { cause });
46
+ this.projectId = projectId;
47
+ this.name = "ProjectMaintenanceLockCancelledError";
48
+ }
49
+ }
50
+ function acquisitionBudget(options) {
51
+ const timeoutMs = options.timeoutMs ?? PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS;
52
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
53
+ throw new RangeError("Project maintenance timeout must be a finite nonnegative number.");
54
+ }
55
+ const now = options.now ?? (() => performance.now());
56
+ return {
57
+ timeoutMs,
58
+ deadline: now() + timeoutMs,
59
+ now,
60
+ random: options.random ?? Math.random,
61
+ wait: options.wait ?? ((milliseconds, signal) => delay(milliseconds, undefined, { signal })),
62
+ signal: options.signal
63
+ };
64
+ }
65
+ function assertNotCancelled(projectId, signal) {
66
+ if (signal?.aborted)
67
+ throw new ProjectMaintenanceLockCancelledError(projectId, signal.reason);
68
+ }
35
69
  /** Directory of one Project's maintenance fence, below the Home's locks area. */
36
70
  export function projectMaintenanceLockPath(home, projectId) {
37
71
  return join(home, "locks", "projects", `${projectId}.lock`);
38
72
  }
39
73
  /**
40
74
  * Acquire one Project's maintenance fence. Returns the release function;
41
- * callers MUST release on every exit path (try/finally). A live holder
42
- * fails fast with {@link ProjectMaintenanceLockedError}; a stale (dead)
43
- * holder is reclaimed.
75
+ * callers MUST release on every exit path (try/finally). A live holder is
76
+ * never stolen, including at timeout; a stale (dead) holder is reclaimed.
44
77
  *
45
78
  * There is no in-process reentrancy: every acquisition contends, so two
46
79
  * independent operations in the same process are mutually exclusive just
@@ -48,11 +81,22 @@ export function projectMaintenanceLockPath(home, projectId) {
48
81
  * fence while its caller already holds it) goes through private
49
82
  * already-locked methods, never through a second acquisition.
50
83
  */
51
- export function acquireProjectMaintenanceLock(home, projectId) {
84
+ export function acquireProjectMaintenanceLock(home, projectId, options = {}) {
85
+ return acquireWithBudget(home, projectId, acquisitionBudget(options));
86
+ }
87
+ async function acquireWithBudget(home, projectId, budget, firstProject = true) {
52
88
  const lock = projectMaintenanceLockPath(home, projectId);
89
+ assertNotCancelled(projectId, budget.signal);
53
90
  mkdirSync(join(home, "locks", "projects"), { recursive: true, mode: 0o700 });
54
- const deadline = Date.now() + PROJECT_MAINTENANCE_LOCK_TIMEOUT_MS;
91
+ let firstAttempt = firstProject;
55
92
  while (true) {
93
+ assertNotCancelled(projectId, budget.signal);
94
+ // Always try the first Project immediately, even for a zero-budget probe.
95
+ // Every retry and subsequent Project shares the original deadline.
96
+ if (!firstAttempt && budget.now() >= budget.deadline) {
97
+ throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
98
+ }
99
+ firstAttempt = false;
56
100
  try {
57
101
  mkdirSync(lock, { mode: 0o700 });
58
102
  const ownerIdentity = writeOwnerIdentity(lock);
@@ -70,10 +114,22 @@ export function acquireProjectMaintenanceLock(home, projectId) {
70
114
  catch (error) {
71
115
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
72
116
  throw error;
117
+ if (budget.now() >= budget.deadline) {
118
+ throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
119
+ }
73
120
  reclaimStaleProjectMaintenanceLock(lock);
74
- if (Date.now() >= deadline)
75
- throw new ProjectMaintenanceLockedError(projectId);
76
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, PROJECT_MAINTENANCE_LOCK_RETRY_MS);
121
+ const remaining = budget.deadline - budget.now();
122
+ if (remaining <= 0)
123
+ throw new ProjectMaintenanceLockedError(projectId, budget.timeoutMs);
124
+ const jitter = PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS
125
+ + budget.random() * (PROJECT_MAINTENANCE_LOCK_RETRY_MAX_MS - PROJECT_MAINTENANCE_LOCK_RETRY_MIN_MS);
126
+ try {
127
+ await budget.wait(Math.min(jitter, remaining), budget.signal);
128
+ }
129
+ catch (waitError) {
130
+ assertNotCancelled(projectId, budget.signal);
131
+ throw waitError;
132
+ }
77
133
  }
78
134
  }
79
135
  }
@@ -108,11 +164,12 @@ function releaseOwnedProjectMaintenanceLock(lock, ownerIdentity) {
108
164
  * including ones this process already holds: callers that need nesting must
109
165
  * use an already-locked private path instead of acquiring twice.
110
166
  */
111
- export function acquireProjectMaintenanceLocks(home, projectIds) {
167
+ export async function acquireProjectMaintenanceLocks(home, projectIds, options = {}) {
168
+ const budget = acquisitionBudget(options);
112
169
  const releases = [];
113
170
  try {
114
171
  for (const projectId of [...new Set(projectIds)].sort()) {
115
- releases.push(acquireProjectMaintenanceLock(home, projectId));
172
+ releases.push(await acquireWithBudget(home, projectId, budget, releases.length === 0));
116
173
  }
117
174
  }
118
175
  catch (error) {
@@ -1,4 +1,10 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { lstat } from "node:fs/promises";
3
+ import { isArchivePersistenceFailure, recordArchiveCleanup } from "../task/archiveDiagnostics.js";
4
+ import { projectTaskRemoteDeliveryFromStore } from "../commands/taskRemoteDeliveryCommand.js";
5
+ import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
6
+ import { archiveSettlementChecks, archiveExecutionChecks } from "../task/archivePreflight.js";
7
+ import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
2
8
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
9
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
4
10
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
@@ -81,8 +87,12 @@ export class TaskWorkspaceCoordinator {
81
87
  .map(({ projectId }) => projectId);
82
88
  const releaseMaintenance = projectIds.length === 0
83
89
  ? () => { }
84
- : acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
90
+ : await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
85
91
  try {
92
+ if (!isDeepStrictEqual(this.store.getWorkItem(taskId, workItemId), item)
93
+ || !isDeepStrictEqual(this.store.getWorkItemWorkspace(taskId, workItemId), workspace)) {
94
+ throw new Error(`Work item changed while waiting for cleanup: ${taskId}/${workItemId}.`);
95
+ }
86
96
  const state = await this.preparer.inspectWorkItemWorkspace(item.taskId, item.id);
87
97
  if (state === "dirty")
88
98
  return "dirty";
@@ -92,7 +102,7 @@ export class TaskWorkspaceCoordinator {
92
102
  const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
93
103
  if (laneCleanup === "dirty")
94
104
  return "dirty";
95
- return this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
105
+ return await this.preparer.cleanupWorkItemWorkspace(item.taskId, item.id, disposition);
96
106
  }
97
107
  finally {
98
108
  releaseMaintenance();
@@ -133,8 +143,12 @@ export class TaskWorkspaceCoordinator {
133
143
  .map(({ projectId }) => projectId);
134
144
  const releaseMaintenance = projectIds.length === 0
135
145
  ? () => { }
136
- : acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
146
+ : await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
137
147
  try {
148
+ if (!isDeepStrictEqual(this.store.getReviewRound(taskId, reviewRoundId), round)
149
+ || !isDeepStrictEqual(this.store.getReviewRoundWorkspace(taskId, reviewRoundId), workspace)) {
150
+ throw new Error(`ReviewRound changed while waiting for cleanup: ${taskId}/${reviewRoundId}.`);
151
+ }
138
152
  const state = await this.preparer.inspectReviewRoundWorkspace(taskId, reviewRoundId);
139
153
  if (state === "dirty")
140
154
  return "dirty";
@@ -142,7 +156,7 @@ export class TaskWorkspaceCoordinator {
142
156
  const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForReviewRound(taskId, reviewRoundId);
143
157
  if (laneCleanup === "dirty")
144
158
  return "dirty";
145
- return this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
159
+ return await this.preparer.cleanupReviewRoundWorkspace(taskId, reviewRoundId);
146
160
  }
147
161
  finally {
148
162
  releaseMaintenance();
@@ -157,6 +171,9 @@ export class TaskWorkspaceCoordinator {
157
171
  if (task.status !== "completed" && task.status !== "cancelled") {
158
172
  throw new Error(`Task must be completed or retired before archive cleanup: ${task.id}.`);
159
173
  }
174
+ const settlement = [...archiveSettlementChecks(this.store, task), ...archiveExecutionChecks(this.store, taskId)];
175
+ if (settlement.length > 0)
176
+ throw new CleanupInspectionError(settlement);
160
177
  const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
161
178
  .sort((left, right) => managedWorkspaceKey(left.owner)
162
179
  .localeCompare(managedWorkspaceKey(right.owner)));
@@ -168,7 +185,14 @@ export class TaskWorkspaceCoordinator {
168
185
  for (const entry of workspace.entries)
169
186
  projectIds.add(entry.projectId);
170
187
  }
171
- releaseMaintenance = acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
188
+ releaseMaintenance = await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
189
+ this.#assertTaskArchiveLifecycle(task);
190
+ const currentWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
191
+ .sort((left, right) => managedWorkspaceKey(left.owner)
192
+ .localeCompare(managedWorkspaceKey(right.owner)));
193
+ if (!isDeepStrictEqual(currentWorkspaces, managedWorkspaces)) {
194
+ throw new Error(`Task workspaces changed while waiting for archive cleanup: ${taskId}.`);
195
+ }
172
196
  const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
173
197
  const integrationWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "integration-attempt");
174
198
  const workspaces = managedWorkspaces
@@ -196,89 +220,19 @@ export class TaskWorkspaceCoordinator {
196
220
  workItems: allWorkItems,
197
221
  reviewRounds: allReviewRounds
198
222
  };
199
- for (const round of reviewRounds) {
200
- if (round.status !== "completed" && round.status !== "failed") {
201
- throw new Error(`ReviewRound must be terminal before archive cleanup: ${round.id}.`);
202
- }
203
- if (await this.preparer.inspectReviewRoundWorkspace(task.id, round.id) === "dirty") {
204
- return {
205
- taskId,
206
- status: "retained-dirty",
207
- ...(task.cwd === undefined ? {} : { path: task.cwd }),
208
- error: `ReviewRound worktree is dirty: ${round.id}.`,
209
- reason: "dirty-worktree",
210
- resource: `review-round:${task.id}/${round.id}`,
211
- retryable: true
212
- };
213
- }
214
- }
215
- for (const item of workItems) {
216
- if (await this.preparer.inspectWorkItemWorkspace(task.id, item.id) === "dirty") {
217
- return {
218
- taskId,
219
- status: "retained-dirty",
220
- ...(task.cwd === undefined ? {} : { path: task.cwd }),
221
- error: `WorkItem worktree is dirty: ${item.id}.`,
222
- reason: "dirty-worktree",
223
- resource: `work-item:${task.id}/${item.id}`,
224
- retryable: true
225
- };
226
- }
227
- }
228
- for (const workspace of laneWorkspaces) {
229
- if (workspace.owner.type !== "execution-lane")
230
- continue;
231
- if (await this.preparer.inspectExecutionLaneWorkspace(task.id, workspace.owner.executionGroupId, workspace.owner.executionLaneId) === "dirty") {
232
- return {
233
- taskId,
234
- status: "retained-dirty",
235
- ...(task.cwd === undefined ? {} : { path: task.cwd }),
236
- error: `Execution Lane worktree is dirty: ${managedWorkspaceKey(workspace.owner)}.`,
237
- reason: "dirty-worktree",
238
- resource: managedWorkspaceKey(workspace.owner),
239
- retryable: true
240
- };
241
- }
242
- }
243
- for (const workspace of integrationWorkspaces) {
244
- if (workspace.owner.type !== "integration-attempt")
245
- continue;
246
- const attempt = this.store.getIntegrationAttempt(task.id, workspace.owner.integrationAttemptId);
247
- if (attempt === null
248
- || attempt.status === "running"
249
- || attempt.status === "blocked"
250
- || attempt.status === "validating") {
251
- throw new Error(`IntegrationAttempt must be terminal before archive cleanup: ${workspace.owner.integrationAttemptId}.`);
252
- }
253
- if (await this.preparer.inspectIntegrationWorkspace(task.id, attempt.id) === "dirty") {
254
- return {
255
- taskId,
256
- status: "retained-dirty",
257
- ...(task.cwd === undefined ? {} : { path: task.cwd }),
258
- error: `Integration worktree is dirty: ${attempt.id}.`,
259
- reason: "dirty-worktree",
260
- resource: `integration-attempt:${task.id}/${attempt.id}`,
261
- retryable: true
262
- };
263
- }
264
- }
265
- const state = await this.preparer.inspectTaskMainWorkspace(taskId);
266
- if (state === "dirty") {
267
- return {
268
- taskId,
269
- status: "retained-dirty",
270
- ...(task.cwd === undefined ? {} : { path: task.cwd }),
271
- error: `Task main worktree is dirty: ${task.id}.`,
272
- reason: "dirty-worktree",
273
- resource: `task-worktree:${task.id}`,
274
- retryable: true
275
- };
276
- }
277
- const activeRole = this.store.listRoles(taskId)
278
- .find((role) => this.store.getActiveRun(taskId, role.name) !== null);
279
- if (activeRole !== undefined) {
280
- throw new WorkspaceCleanupBlockedError("active-turn", `role:${task.id}/${activeRole.name}`, true, `Task Role still has an active AgentRun: ${task.id}/${activeRole.name}.`);
223
+ const checks = [];
224
+ for (const workspace of managedWorkspaces) {
225
+ const workspaceDisposition = workspace.owner.type === "work-item"
226
+ && this.store.getWorkItem(task.id, workspace.owner.workItemId)?.status === "retired" ? "abandoned" : disposition;
227
+ const inspected = await this.preparer.inspectWorkspaceCleanup(workspace, workspaceDisposition);
228
+ // Child worktrees are removed first below. Their Git registrations
229
+ // necessarily exist now; the clone's removal boundary rechecks them
230
+ // after every child cleanup and never removes an unresolved owner.
231
+ checks.push(...inspected.filter(c => !(workspace.owner.type === "task"
232
+ && c.reason === "git-worktree-registrations")));
281
233
  }
234
+ if (checks.length > 0)
235
+ throw new CleanupInspectionError(checks);
282
236
  const roleNames = this.store.listRoles(taskId).map(({ name }) => name);
283
237
  await this.#stopLiveRoles(taskId, roleNames);
284
238
  await this.runtime.assertTaskPhysicalResourcesReleased?.(task.id);
@@ -292,7 +246,7 @@ export class TaskWorkspaceCoordinator {
292
246
  for (const workspace of integrationWorkspaces) {
293
247
  this.#assertTaskArchiveLifecycle(task);
294
248
  if (workspace.owner.type === "integration-attempt") {
295
- await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId);
249
+ await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId, { preserveChanges: true });
296
250
  }
297
251
  }
298
252
  for (const round of reviewRounds) {
@@ -304,32 +258,159 @@ export class TaskWorkspaceCoordinator {
304
258
  await this.preparer.cleanupWorkItemWorkspace(task.id, item.id, item.status === "accepted" ? disposition : "abandoned");
305
259
  }
306
260
  this.#assertTaskArchiveLifecycle(task);
307
- return this.preparer.cleanupTaskForArchive(taskId);
261
+ return await this.preparer.cleanupTaskForArchive(taskId, disposition);
308
262
  }
309
263
  catch (error) {
310
264
  const task = this.store.getTask(taskId);
311
265
  return {
312
266
  taskId,
313
- status: "failed",
267
+ status: error instanceof CleanupInspectionError && error.checks.every(c => c.reason === "dirty-worktree")
268
+ ? "retained-dirty" : "failed",
314
269
  ...(task?.cwd === undefined ? {} : { path: task.cwd }),
315
270
  error: error instanceof Error ? error.message : String(error),
316
- ...(error instanceof WorkspaceCleanupBlockedError
317
- ? {
318
- reason: error.reason,
319
- resource: error.resource,
320
- retryable: error.retryable
321
- }
322
- : {
323
- reason: "cleanup-failed",
324
- resource: `task:${taskId}`,
325
- retryable: true
326
- })
271
+ ...(error instanceof CleanupInspectionError
272
+ ? { checks: error.checks, reason: error.checks[0]?.reason ?? "cleanup-failed",
273
+ resource: error.checks[0]?.resource ?? `task:${taskId}`, retryable: true }
274
+ : error instanceof WorkspaceCleanupBlockedError
275
+ ? {
276
+ reason: error.reason,
277
+ resource: error.resource,
278
+ retryable: error.retryable
279
+ }
280
+ : {
281
+ reason: "cleanup-failed",
282
+ resource: `task:${taskId}`,
283
+ retryable: true
284
+ })
327
285
  };
328
286
  }
329
287
  finally {
330
288
  releaseMaintenance?.();
331
289
  }
332
290
  }
291
+ /** Best-effort cleanup only after explicit force admission has committed.
292
+ * This is one foreground attempt, not a retry worker. Each independent result
293
+ * is durable before continuing, and a failed audit write really fails.
294
+ */
295
+ async cleanupArchivedTask(taskId, disposition) {
296
+ const task = this.store.getTask(taskId);
297
+ if (task?.status !== "archived"
298
+ || !this.store.listEvents(taskId).some(e => e.type === "task.archived" && e.payload.force === "true")) {
299
+ throw new Error(`Force archive must commit before cleanup: ${taskId}.`);
300
+ }
301
+ const attempt = async (diagnostic, action) => {
302
+ recordArchiveCleanup(this.store, taskId, diagnostic, "started");
303
+ let status;
304
+ let detail = diagnostic.detail;
305
+ try {
306
+ const result = await action();
307
+ status = result === "dirty" ? "retained" : result;
308
+ if (result === "dirty")
309
+ detail = "Dirty workspace retained; force archive does not discard local changes.";
310
+ }
311
+ catch (error) {
312
+ if (isArchivePersistenceFailure(error))
313
+ throw error;
314
+ status = "retained";
315
+ detail = error instanceof Error ? error.message : String(error);
316
+ }
317
+ recordArchiveCleanup(this.store, taskId, { ...diagnostic, detail }, status);
318
+ return status !== "retained";
319
+ };
320
+ let runtimeReleased = true;
321
+ for (const role of this.store.listRoles(taskId)) {
322
+ const released = await attempt({ resource: `role:${taskId}/${role.name}`,
323
+ detail: "Exact Role runtime stop." }, async () => {
324
+ const checks = archiveExecutionChecks(this.store, taskId).filter(c => c.resource === `role:${taskId}/${role.name}`);
325
+ if (checks.length > 0)
326
+ throw new CleanupInspectionError(checks);
327
+ await this.#stopLiveRoles(taskId, [role.name]);
328
+ return "released";
329
+ });
330
+ runtimeReleased &&= released;
331
+ }
332
+ const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
333
+ detail: "Verify exact physical resource release before workspace deletion." }, async () => {
334
+ const checks = archiveExecutionChecks(this.store, taskId);
335
+ if (checks.length > 0)
336
+ throw new CleanupInspectionError(checks);
337
+ if (!runtimeReleased)
338
+ throw new Error("One or more Role runtimes could not be safely released.");
339
+ if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
340
+ throw new Error("Physical resource release inspection is unavailable; workspace references retained.");
341
+ }
342
+ await this.runtime.assertTaskPhysicalResourcesReleased(taskId);
343
+ return "released";
344
+ });
345
+ if (physicalReleased) {
346
+ // Task main owns the Git objects of its dependent worktrees, so it is
347
+ // always last and remains when any dependent cleanup is unresolved.
348
+ const workspaces = this.store.listManagedWorkspaces(taskId).sort((a, b) => Number(a.owner.type === "task") - Number(b.owner.type === "task"));
349
+ for (const workspace of workspaces) {
350
+ await attempt({ resource: managedWorkspaceKey(workspace.owner),
351
+ detail: "Remove only clean, exactly owned workspace resources.",
352
+ paths: [workspace.root, ...workspace.entries.filter(e => e.access === "write").map(e => e.path)]
353
+ }, async () => {
354
+ const release = await acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
355
+ try {
356
+ if (!isDeepStrictEqual(this.store.getManagedWorkspace(workspace.owner), workspace)) {
357
+ throw new Error("Workspace ownership changed; retained.");
358
+ }
359
+ const owner = workspace.owner;
360
+ switch (owner.type) {
361
+ case "work-item": {
362
+ const item = this.store.getWorkItem(taskId, owner.workItemId);
363
+ if (item?.status === "accepted" && disposition === "integrated") {
364
+ await new WorkItemChangeSetManager(this.store).assertIntegrated(taskId, item.id);
365
+ }
366
+ return await this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
367
+ }
368
+ case "review-round":
369
+ return await this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
370
+ case "execution-lane":
371
+ return await this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
372
+ case "integration-attempt":
373
+ if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
374
+ return "dirty";
375
+ return await this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId, { preserveChanges: true });
376
+ case "task": {
377
+ const current = this.store.getTask(taskId);
378
+ const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
379
+ if (!delivery.allMerged || !delivery.allVerified) {
380
+ throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
381
+ }
382
+ for (const entry of workspace.entries.filter(e => e.access === "write")) {
383
+ const covered = delivery.projects.find(p => p.projectId === entry.projectId);
384
+ const expected = covered?.expectedLocalCommit;
385
+ const path = await lstat(entry.path).catch(error => {
386
+ if (error.code === "ENOENT")
387
+ return null;
388
+ throw error;
389
+ });
390
+ if (expected !== null && expected !== undefined
391
+ && path !== null) {
392
+ const actual = await this.preparer.git.inspect(entry.path, "HEAD");
393
+ if (actual.baseCommit !== expected && actual.baseCommit !== covered?.deliveryLocalCommit) {
394
+ throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
395
+ }
396
+ }
397
+ }
398
+ const result = await this.preparer.cleanupTaskForArchive(taskId, disposition);
399
+ if (result.status !== "removed")
400
+ throw new Error(result.error ?? "Task main or dependent workspace retained.");
401
+ return "removed";
402
+ }
403
+ }
404
+ }
405
+ finally {
406
+ release();
407
+ }
408
+ });
409
+ }
410
+ }
411
+ recordArchiveCleanup(this.store, taskId, { resource: `task:${taskId}`,
412
+ detail: "Foreground cleanup attempt finished; inspect retained resource references and warnings." }, "finished");
413
+ }
333
414
  #assertTaskArchiveSnapshot(snapshot) {
334
415
  this.#assertTaskArchiveLifecycle(snapshot.task);
335
416
  const managedWorkspaces = [...this.store.listManagedWorkspaces(snapshot.task.id)]