@zq-silk/yui 2.2.0 → 2.3.0

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 (51) hide show
  1. package/README.md +4 -2
  2. package/dist/cli/commandCatalog.js +8 -3
  3. package/dist/cli/interactionPolicy.js +4 -0
  4. package/dist/commands/taskCommands.js +5 -7
  5. package/dist/commands/taskIntegrationCommands.js +5 -16
  6. package/dist/controlPlaneCli.js +28 -21
  7. package/dist/controller/clientRuntime.js +3 -2
  8. package/dist/integration/gitIntegrationService.js +8 -67
  9. package/dist/job/jobCleanupInspection.js +67 -0
  10. package/dist/repository/gitWorkspace.js +41 -12
  11. package/dist/repository/taskWorkspaceCoordinator.js +72 -145
  12. package/dist/repository/taskWorkspacePreparer.js +39 -43
  13. package/dist/repository/workspaceCleanupInspection.js +88 -7
  14. package/dist/resources/resourceRegistrar.js +16 -10
  15. package/dist/runtime/integrationRuntimeIsolation.js +21 -0
  16. package/dist/runtime/taskRuntimeIsolation.js +91 -21
  17. package/dist/task/archiveDiagnostics.js +26 -8
  18. package/dist/task/archivePreflight.js +26 -9
  19. package/dist/task/leaderArchive.js +9 -15
  20. package/dist/web/assets/client/api.js +8 -0
  21. package/dist/web/assets/client/app.js +10 -6
  22. package/dist/web/assets/client/components.js +95 -36
  23. package/dist/web/assets/client/detail.js +110 -64
  24. package/dist/web/assets/client/dock.js +95 -11
  25. package/dist/web/assets/client/evidence.js +286 -94
  26. package/dist/web/assets/client/i18n.js +178 -62
  27. package/dist/web/assets/client/overview.js +9 -13
  28. package/dist/web/assets/client/records.js +142 -7
  29. package/dist/web/assets/client/sections.js +234 -198
  30. package/dist/web/assets/icons.js +4 -0
  31. package/dist/web/assets/styles/components.js +64 -17
  32. package/dist/web/assets/styles/responsive.js +7 -5
  33. package/dist/web/assets/styles/tokens.js +1 -1
  34. package/dist/web/assets/styles/views.js +48 -31
  35. package/dist/web/webServer.js +12 -1
  36. package/dist/web/webTaskSurface.js +8 -1
  37. package/docs/observability/README.md +13 -3
  38. package/docs/observability/README.zh-CN.md +10 -3
  39. package/docs/release-workflow.md +25 -0
  40. package/docs/release-workflow.zh-CN.md +19 -0
  41. package/docs/task-delivery.md +36 -8
  42. package/docs/task-delivery.zh-CN.md +24 -7
  43. package/i18n/README.zh-CN.md +3 -2
  44. package/package.json +1 -1
  45. package/skills/yui-leader/references/execution.md +2 -1
  46. package/skills/yui-leader/references/integration.md +20 -0
  47. package/skills/yui-operator/SKILL.md +4 -0
  48. package/skills/yui-reviewer/SKILL.md +3 -0
  49. package/skills/yui-runtime/SKILL.md +4 -0
  50. package/skills/yui-runtime/references/publication.md +20 -4
  51. package/skills/yui-worker/SKILL.md +3 -0
@@ -1,10 +1,10 @@
1
- import { lstat } from "node:fs/promises";
2
1
  import { isDeepStrictEqual } from "node:util";
3
2
  import { isArchivePersistenceFailure, recordArchiveCleanup } from "../task/archiveDiagnostics.js";
4
3
  import { archiveExecutionChecks, archiveSettlementChecks } from "../task/archivePreflight.js";
5
- import { projectTaskRemoteDeliveryFromStore } from "../task/remoteDeliveryService.js";
6
4
  import { CleanupInspectionError } from "../workspace/cleanupInspection.js";
7
5
  import { WorkItemChangeSetManager } from "../workspace/workItemChangeSetManager.js";
6
+ import { withResourceRegistry } from "../resources/resourceRegistryStore.js";
7
+ import { jobCleanupBlocker } from "../job/jobCleanupInspection.js";
8
8
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
9
9
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
10
10
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
@@ -97,8 +97,9 @@ export class TaskWorkspaceCoordinator {
97
97
  if (state === "dirty")
98
98
  return "dirty";
99
99
  this.#assertWorkItemRuntimeQuiescent(item);
100
- this.#assertNoActiveWorkItemDurableJobs(item);
100
+ this.#assertNoActiveWorkItemDurableJobs(item, "before-role-stop");
101
101
  await this.#stopLiveRoles(item.taskId, this.#workItemRoleNames(item));
102
+ this.#assertNoActiveWorkItemDurableJobs(item);
102
103
  const laneCleanup = await this.preparer.cleanupExecutionLaneWorkspacesForWorkItem(item.taskId, item.id);
103
104
  if (laneCleanup === "dirty")
104
105
  return "dirty";
@@ -162,30 +163,32 @@ export class TaskWorkspaceCoordinator {
162
163
  releaseMaintenance();
163
164
  }
164
165
  }
165
- async cleanupTaskForArchive(taskId, disposition) {
166
- let releaseMaintenance;
166
+ /** Business/runtime admission only. Physical workspaces may safely remain;
167
+ * their removal uses the same foreground cleanup after ordinary or force archive.
168
+ */
169
+ async prepareTaskForArchive(taskId) {
170
+ const task = this.store.getTask(taskId);
171
+ if (task === null)
172
+ throw new Error(`Task not found: ${taskId}.`);
173
+ if (task.status !== "completed" && task.status !== "cancelled") {
174
+ throw new Error(`Task must be completed or retired before archive: ${task.id}.`);
175
+ }
176
+ const settlement = [...archiveSettlementChecks(this.store, task),
177
+ ...archiveExecutionChecks(this.store, taskId, this.preparer.home, "before-role-stop")];
178
+ if (settlement.length > 0)
179
+ throw new CleanupInspectionError(settlement);
180
+ const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
181
+ .sort((left, right) => managedWorkspaceKey(left.owner)
182
+ .localeCompare(managedWorkspaceKey(right.owner)));
183
+ // Fence Project preparation while stopping exact Task runtimes; do not
184
+ // mutate Project paths until after the independent archive commit.
185
+ const projectIds = new Set(task.projectBindings.map(({ projectId }) => projectId));
186
+ for (const workspace of managedWorkspaces) {
187
+ for (const entry of workspace.entries)
188
+ projectIds.add(entry.projectId);
189
+ }
190
+ const releaseMaintenance = await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
167
191
  try {
168
- const task = this.store.getTask(taskId);
169
- if (task === null)
170
- throw new Error(`Task not found: ${taskId}.`);
171
- if (task.status !== "completed" && task.status !== "cancelled") {
172
- throw new Error(`Task must be completed or retired before archive cleanup: ${task.id}.`);
173
- }
174
- const settlement = [...archiveSettlementChecks(this.store, task), ...archiveExecutionChecks(this.store, taskId)];
175
- if (settlement.length > 0)
176
- throw new CleanupInspectionError(settlement);
177
- const managedWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
178
- .sort((left, right) => managedWorkspaceKey(left.owner)
179
- .localeCompare(managedWorkspaceKey(right.owner)));
180
- // Archive cleanup removes worktrees from every Project the Task uses:
181
- // hold each Project's maintenance fence so the Controller defers
182
- // preparation and no Project maintenance or Task archive interleaves.
183
- const projectIds = new Set(task.projectBindings.map(({ projectId }) => projectId));
184
- for (const workspace of managedWorkspaces) {
185
- for (const entry of workspace.entries)
186
- projectIds.add(entry.projectId);
187
- }
188
- releaseMaintenance = await acquireProjectMaintenanceLocks(this.preparer.home, projectIds);
189
192
  this.#assertTaskArchiveLifecycle(task);
190
193
  const currentWorkspaces = [...this.store.listManagedWorkspaces(task.id)]
191
194
  .sort((left, right) => managedWorkspaceKey(left.owner)
@@ -193,111 +196,43 @@ export class TaskWorkspaceCoordinator {
193
196
  if (!isDeepStrictEqual(currentWorkspaces, managedWorkspaces)) {
194
197
  throw new Error(`Task workspaces changed while waiting for archive cleanup: ${taskId}.`);
195
198
  }
196
- const laneWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "execution-lane");
197
- const integrationWorkspaces = managedWorkspaces.filter(({ owner }) => owner.type === "integration-attempt");
198
- const workspaces = managedWorkspaces
199
- .filter(({ owner }) => owner.type === "work-item");
200
- const workItems = workspaces.map((workspace) => {
201
- const workItemId = workspace.owner.type === "work-item"
202
- ? workspace.owner.workItemId
203
- : "";
204
- const item = this.store.getWorkItem(task.id, workItemId);
205
- if (item === null)
206
- throw new Error(`Work item not found: ${task.id}/${workItemId}.`);
207
- if (item.status !== "accepted" && item.status !== "retired") {
208
- throw new Error(`Work item must be completed or retired before archive cleanup: ${item.id}.`);
209
- }
210
- return item;
211
- });
212
199
  const allWorkItems = [...this.store.listWorkItems(task.id)]
213
200
  .sort((left, right) => left.id.localeCompare(right.id));
214
201
  const allReviewRounds = [...this.store.listReviewRounds(task.id)]
215
202
  .sort((left, right) => left.id.localeCompare(right.id));
216
- const reviewRounds = allReviewRounds.filter((round) => (round.workspace !== undefined && round.workspaceDisposition?.kind !== "removed"));
217
203
  const snapshot = {
218
204
  task,
219
205
  managedWorkspaces,
220
206
  workItems: allWorkItems,
221
207
  reviewRounds: allReviewRounds
222
208
  };
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")));
233
- }
234
- if (checks.length > 0)
235
- throw new CleanupInspectionError(checks);
236
209
  const roleNames = this.store.listRoles(taskId).map(({ name }) => name);
237
210
  await this.#stopLiveRoles(taskId, roleNames);
238
211
  await this.runtime.releaseTaskTerminals(task.id);
239
- this.#assertTaskArchiveSnapshot(snapshot);
240
- for (const workspace of laneWorkspaces) {
241
- this.#assertTaskArchiveLifecycle(task);
242
- if (workspace.owner.type === "execution-lane") {
243
- await this.preparer.cleanupExecutionLaneWorkspace(task.id, workspace.owner.executionGroupId, workspace.owner.executionLaneId);
244
- }
245
- }
246
- for (const workspace of integrationWorkspaces) {
247
- this.#assertTaskArchiveLifecycle(task);
248
- if (workspace.owner.type === "integration-attempt") {
249
- await this.preparer.cleanupIntegrationWorkspace(task.id, workspace.owner.integrationAttemptId, { preserveChanges: true });
250
- }
251
- }
252
- for (const round of reviewRounds) {
253
- this.#assertTaskArchiveLifecycle(task);
254
- await this.preparer.cleanupReviewRoundWorkspace(task.id, round.id);
212
+ if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
213
+ throw new Error("Archive requires exact physical runtime inspection.");
255
214
  }
256
- for (const item of workItems) {
257
- this.#assertTaskArchiveLifecycle(task);
258
- await this.preparer.cleanupWorkItemWorkspace(task.id, item.id, item.status === "accepted" ? disposition : "abandoned");
259
- }
260
- this.#assertTaskArchiveLifecycle(task);
261
- return await this.preparer.cleanupTaskForArchive(taskId, disposition);
262
- }
263
- catch (error) {
264
- const task = this.store.getTask(taskId);
265
- return {
266
- taskId,
267
- status: error instanceof CleanupInspectionError && error.checks.every(c => c.reason === "dirty-worktree")
268
- ? "retained-dirty" : "failed",
269
- ...(task?.cwd === undefined ? {} : { path: task.cwd }),
270
- error: error instanceof Error ? error.message : String(error),
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
- })
285
- };
215
+ await this.runtime.assertTaskPhysicalResourcesReleased(task.id);
216
+ this.#assertTaskArchiveSnapshot(snapshot);
217
+ const rechecked = [...archiveSettlementChecks(this.store, task), ...archiveExecutionChecks(this.store, taskId, this.preparer.home)];
218
+ if (rechecked.length > 0)
219
+ throw new CleanupInspectionError(rechecked);
286
220
  }
287
221
  finally {
288
- releaseMaintenance?.();
222
+ releaseMaintenance();
289
223
  }
290
224
  }
291
- /** Best-effort cleanup only after explicit force admission has committed.
225
+ /** Best-effort cleanup only after authorized archive admission has committed.
292
226
  * This is one foreground attempt, not a retry worker. Each independent result
293
227
  * is durable before continuing, and a failed audit write really fails.
294
228
  */
295
229
  async cleanupArchivedTask(taskId, disposition) {
296
230
  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}.`);
231
+ if (task?.status !== "archived") {
232
+ throw new Error(`Archive must commit before cleanup: ${taskId}.`);
300
233
  }
234
+ recordArchiveCleanup(this.store, taskId, { resource: `cleanup-pass:${taskId}`,
235
+ detail: "Begin one foreground exact-owner cleanup pass." }, "started");
301
236
  const attempt = async (diagnostic, action) => {
302
237
  recordArchiveCleanup(this.store, taskId, diagnostic, "started");
303
238
  let status;
@@ -306,7 +241,7 @@ export class TaskWorkspaceCoordinator {
306
241
  const result = await action();
307
242
  status = result === "dirty" ? "retained" : result;
308
243
  if (result === "dirty")
309
- detail = "Dirty workspace retained; force archive does not discard local changes.";
244
+ detail = "Dirty workspace retained; archive does not discard local changes.";
310
245
  }
311
246
  catch (error) {
312
247
  if (isArchivePersistenceFailure(error))
@@ -321,7 +256,7 @@ export class TaskWorkspaceCoordinator {
321
256
  for (const role of this.store.listRoles(taskId)) {
322
257
  const released = await attempt({ resource: `role:${taskId}/${role.name}`,
323
258
  detail: "Exact Role runtime stop." }, async () => {
324
- const checks = archiveExecutionChecks(this.store, taskId).filter(c => c.resource === `role:${taskId}/${role.name}`);
259
+ const checks = archiveExecutionChecks(this.store, taskId, this.preparer.home).filter(c => c.resource === `role:${taskId}/${role.name}`);
325
260
  if (checks.length > 0)
326
261
  throw new CleanupInspectionError(checks);
327
262
  await this.#stopLiveRoles(taskId, [role.name]);
@@ -331,12 +266,16 @@ export class TaskWorkspaceCoordinator {
331
266
  }
332
267
  const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
333
268
  detail: "Verify exact physical resource release before workspace deletion." }, async () => {
334
- const checks = archiveExecutionChecks(this.store, taskId);
269
+ const checks = archiveExecutionChecks(this.store, taskId, this.preparer.home);
335
270
  if (checks.length > 0)
336
271
  throw new CleanupInspectionError(checks);
337
272
  if (!runtimeReleased)
338
273
  throw new Error("One or more Role runtimes could not be safely released.");
339
274
  await this.runtime.releaseTaskTerminals(taskId);
275
+ if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
276
+ throw new Error("Exact physical resource release inspection is unavailable.");
277
+ }
278
+ await this.runtime.assertTaskPhysicalResourcesReleased(taskId);
340
279
  return "released";
341
280
  });
342
281
  if (physicalReleased) {
@@ -367,31 +306,10 @@ export class TaskWorkspaceCoordinator {
367
306
  case "execution-lane":
368
307
  return await this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
369
308
  case "integration-attempt":
370
- if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
371
- return "dirty";
372
- return await this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId, { preserveChanges: true });
309
+ return await this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId);
373
310
  case "task": {
374
- const current = this.store.getTask(taskId);
375
- const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
376
- if (!delivery.allMerged || !delivery.allVerified) {
377
- throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
378
- }
379
- for (const entry of workspace.entries.filter(e => e.access === "write")) {
380
- const covered = delivery.projects.find(p => p.projectId === entry.projectId);
381
- const expected = covered?.expectedLocalCommit;
382
- const path = await lstat(entry.path).catch(error => {
383
- if (error.code === "ENOENT")
384
- return null;
385
- throw error;
386
- });
387
- if (expected !== null && expected !== undefined
388
- && path !== null) {
389
- const actual = await this.preparer.git.inspect(entry.path, "HEAD");
390
- if (actual.baseCommit !== expected && actual.baseCommit !== covered?.deliveryLocalCommit) {
391
- throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
392
- }
393
- }
394
- }
311
+ // The preparer reloads the shared identity, dirt, baseline and
312
+ // delivery checks; do not maintain a second post-archive gate.
395
313
  const result = await this.preparer.cleanupTaskForArchive(taskId, disposition);
396
314
  if (result.status !== "removed")
397
315
  throw new Error(result.error ?? "Task main or dependent workspace retained.");
@@ -405,6 +323,17 @@ export class TaskWorkspaceCoordinator {
405
323
  });
406
324
  }
407
325
  }
326
+ // The registry remains the authority for scratch/resources not represented
327
+ // by a current workspace. Do not invent ownership or sweep those paths.
328
+ // Expose exact unresolved registrations instead of claiming full release.
329
+ const registered = withResourceRegistry(this.preparer.home, undefined, registry => Object.values(registry.load().records).filter(record => record.owner.taskId === taskId && record.disposition !== "deleted"));
330
+ for (const record of registered) {
331
+ recordArchiveCleanup(this.store, taskId, {
332
+ resource: `resource:${record.id}`, paths: [record.path],
333
+ detail: `Registered ${record.kind} retained (${record.disposition}); owner=${JSON.stringify(record.owner)}. `
334
+ + "No deletion attempted by archive for this remaining registration. Inspect with yui resources gc --dry-run."
335
+ }, "retained");
336
+ }
408
337
  recordArchiveCleanup(this.store, taskId, { resource: `task:${taskId}`,
409
338
  detail: "Foreground cleanup attempt finished; inspect retained resource references and warnings." }, "finished");
410
339
  }
@@ -436,24 +365,18 @@ export class TaskWorkspaceCoordinator {
436
365
  throw new WorkspaceCleanupBlockedError("active-turn", `work-item:${item.taskId}/${item.id}`, true, `Work item still has an active AgentRun: ${item.taskId}/${item.id}.`);
437
366
  }
438
367
  }
439
- /**
440
- * rr5/f4: A WorkItem workspace must not be removed while a DurableJob it
441
- * owns could still be using it. Queued, running, and unacknowledged
442
- * unknown-needs-attention jobs are unsettled — the runner (or its corpse)
443
- * may still hold the worktree. Acknowledged unknown jobs are settled: a
444
- * human/Leader has taken responsibility for the outcome.
368
+ /** Check result/runner before stopping Roles, then physical references before
369
+ * cleaning any child lanes. The preparer repeats the full check at removal.
445
370
  */
446
- #assertNoActiveWorkItemDurableJobs(item) {
371
+ #assertNoActiveWorkItemDurableJobs(item, phase = "before-removal") {
447
372
  const jobs = this.store.listDurableJobs(item.taskId);
448
373
  const blocking = jobs.filter((job) => (job.owner.kind === "work-item"
449
374
  && job.owner.workItemId === item.id
450
- && (job.status === "queued"
451
- || job.status === "running"
452
- || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))));
375
+ && jobCleanupBlocker(job, this.preparer.home, phase) !== undefined));
453
376
  if (blocking.length > 0) {
454
377
  throw new WorkspaceCleanupBlockedError("active-durable-job", `work-item:${item.taskId}/${item.id}`, true, `Work item ${item.id} still has ${blocking.length} active DurableJob(s): `
455
378
  + `${blocking.map((job) => `${job.id}/${job.status}`).join(", ")}. `
456
- + "Cancel or acknowledge them before cleanup.");
379
+ + "Settle their outcomes and establish physical exit before cleanup; acknowledgement alone is not stop evidence.");
457
380
  }
458
381
  }
459
382
  #workItemRoleNames(item) {
@@ -470,6 +393,10 @@ export class TaskWorkspaceCoordinator {
470
393
  }
471
394
  async #stopLiveRoles(taskId, roleNames) {
472
395
  const targets = [...new Set(roleNames)];
396
+ const unresolved = archiveExecutionChecks(this.store, taskId, this.preparer.home, "before-role-stop")
397
+ .filter(check => targets.some(roleName => check.resource === `role:${taskId}/${roleName}`));
398
+ if (unresolved.length > 0)
399
+ throw new CleanupInspectionError(unresolved);
473
400
  for (const roleName of targets) {
474
401
  if (this.store.getActiveRun(taskId, roleName) !== null) {
475
402
  throw new Error(`Role has an active AgentRun: ${taskId}/${roleName}.`);
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, readdir, readlink, rm, rmdir, symlink, unlink } from "node:fs/promises";
1
+ import { lstat, mkdir, readdir, readlink, rmdir, symlink, unlink } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { recordArchiveCleanup } from "../task/archiveDiagnostics.js";
@@ -25,7 +25,8 @@ import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
25
25
  import { acquireProjectMaintenanceLocks, ProjectMaintenanceLockCancelledError, ProjectMaintenanceLockedError } from "./projectMaintenanceLock.js";
26
26
  import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
27
27
  import { generateTaskWorkspaceIdentity, taskWorkspaceRefSegment, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
28
- import { inspectWorkspaceCleanup } from "./workspaceCleanupInspection.js";
28
+ import { inspectWorkspaceCleanup, inspectWorkspaceContainer } from "./workspaceCleanupInspection.js";
29
+ import { defaultIntegrationRuntimeIsolation } from "../runtime/integrationRuntimeIsolation.js";
29
30
  const MAIN_WORKTREE = "main";
30
31
  const LEADER_ROLE = "leader";
31
32
  /**
@@ -73,7 +74,7 @@ export class FileTaskWorkspacePreparer {
73
74
  this.maintenanceLockOptions = maintenanceLockOptions;
74
75
  }
75
76
  inspectWorkspaceCleanup(workspace, disposition, forceArchive = false) {
76
- return inspectWorkspaceCleanup(this.store, this.git, workspace, disposition, forceArchive);
77
+ return inspectWorkspaceCleanup(this.store, this.git, workspace, disposition, forceArchive, this.home);
77
78
  }
78
79
  async #assertWorkspaceCleanup(workspace, disposition) {
79
80
  const checks = await this.inspectWorkspaceCleanup(workspace, disposition);
@@ -1296,7 +1297,7 @@ export class FileTaskWorkspacePreparer {
1296
1297
  this.#recordArchivePathRemoval(task, entry.path, result);
1297
1298
  removed ||= result === "removed";
1298
1299
  }
1299
- await removeWorkspaceView(workspace.root);
1300
+ await removeWorkspaceView(workspace.root, workspace);
1300
1301
  this.#resourceRegistrar().markWorkspaceDeleted(workspace);
1301
1302
  this.store.removeManagedWorkspace(workspace.owner);
1302
1303
  return removed ? "removed" : "missing";
@@ -1907,7 +1908,7 @@ export class FileTaskWorkspacePreparer {
1907
1908
  this.#recordArchivePathRemoval(task, entry.path, result);
1908
1909
  removed ||= result === "removed";
1909
1910
  }
1910
- await removeWorkspaceView(workspace.root);
1911
+ await removeWorkspaceView(workspace.root, workspace);
1911
1912
  this.#resourceRegistrar().markWorkspaceDeleted(workspace);
1912
1913
  this.store.transaction((tx) => {
1913
1914
  const currentRound = tx.getReviewRound(task.id, round.id);
@@ -1976,7 +1977,7 @@ export class FileTaskWorkspacePreparer {
1976
1977
  this.#recordArchivePathRemoval(task, entry.path, result);
1977
1978
  removed ||= result === "removed";
1978
1979
  }
1979
- await removeWorkspaceView(workspace.root);
1980
+ await removeWorkspaceView(workspace.root, workspace);
1980
1981
  this.#resourceRegistrar().markWorkspaceDeleted(workspace);
1981
1982
  try {
1982
1983
  this.#recordWorkspaceRemoval(task, workspace, this.#fallbackWorkspace(), {
@@ -2027,39 +2028,36 @@ export class FileTaskWorkspacePreparer {
2027
2028
  throw new Error(`IntegrationAttempt must be terminal before cleanup: ${attempt.id}.`);
2028
2029
  }
2029
2030
  const workspace = this.store.getIntegrationWorkspace(task.id, attempt.id);
2030
- // A failed Integration's explicitly disposable conflict tree retains its
2031
- // existing cleanup contract outside archive. Archive never discards dirt.
2032
- if (workspace !== null && (options.preserveChanges || task.status === "archived" || attempt.status !== "failed")) {
2033
- await this.#assertWorkspaceCleanup(workspace, "abandoned");
2034
- }
2031
+ if (workspace === null)
2032
+ return "missing"; // no current resource ownership to delete
2033
+ // Terminal history does not authorize discarding conflict resolutions or
2034
+ // diagnostics. All entry points use the same exact-owner inspection.
2035
+ const runtime = options.runtimeIsolation ?? defaultIntegrationRuntimeIsolation(this.home, this.store.getHomeIdentity().homeId);
2036
+ const checks = await inspectWorkspaceCleanup(this.store, this.git, workspace, "abandoned", false, this.home, runtime);
2037
+ if (checks.length > 0)
2038
+ throw new CleanupInspectionError(checks);
2039
+ runtime.cleanup(runtime.preflight({ workspace, allowExactActive: true }), attempt.status === "committed" ? "completion" : "failure");
2035
2040
  const project = requireProject(this.store, attempt.projectId);
2036
- // The recorded entry pins the exact on-disk worktree path; reconstruct the
2037
- // owner root from it so removal targets the real Git worktree regardless of
2038
- // how the layout helper derives it. Absent a recorded workspace, fall back
2039
- // to the deterministic integration owner root + bound directory.
2040
- const recorded = workspace?.entries[0];
2041
- const directory = recorded?.directory
2042
- ?? task.projectBindings.find(({ projectId }) => projectId === attempt.projectId)?.directory;
2043
- if (directory === undefined) {
2044
- throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
2041
+ // Only the recorded entry grants resource ownership; no history-derived
2042
+ // path reconstruction after the current resource record has been removed.
2043
+ const recorded = workspace.entries[0];
2044
+ if (recorded === undefined || recorded.projectId !== project.id || workspace.entries.length !== 1) {
2045
+ throw new Error(`Integration workspace Project identity is unavailable: ${task.id}/${project.id}.`);
2045
2046
  }
2046
2047
  const result = await this.git.removeIntegrationWorktree({
2047
2048
  repositoryPath: this.#taskRepositoryPath(task.id, project.id),
2048
- container: recorded !== undefined
2049
- ? dirname(recorded.path)
2050
- : this.#integrationWorkspaceRoot(task.id, attempt.id),
2051
- directory,
2049
+ container: dirname(recorded.path),
2050
+ directory: recorded.directory,
2052
2051
  taskSegment: this.#taskSegment(task),
2053
2052
  integrationId: attempt.id,
2054
- discardChanges: options.preserveChanges !== true && task.status !== "archived" && attempt.status === "failed"
2053
+ discardChanges: false
2055
2054
  });
2056
2055
  if (result !== "dirty") {
2057
- for (const entry of workspace?.entries ?? []) {
2056
+ for (const entry of workspace.entries) {
2058
2057
  this.#recordArchivePathRemoval(task, entry.path, result);
2059
2058
  }
2060
- if (workspace !== null)
2061
- this.#resourceRegistrar().markWorkspaceDeleted(workspace);
2062
- await rm(join(this.home, "artifacts", "integration-checks", task.id, attempt.id), { recursive: true, force: true });
2059
+ this.#resourceRegistrar().markWorkspaceDeleted(workspace);
2060
+ // Check logs are stable historical evidence, not worktree scratch.
2063
2061
  this.store.removeManagedWorkspace({
2064
2062
  type: "integration-attempt",
2065
2063
  taskId: task.id,
@@ -2097,15 +2095,9 @@ export class FileTaskWorkspacePreparer {
2097
2095
  if (main !== null) {
2098
2096
  await this.#assertWorkspaceCleanup(main, disposition);
2099
2097
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2100
- // Scratch-only workspaces have no Git entries or Git workspace identity.
2101
- if (main.entries.length > 0 && await this.#inspectEntries(task.id, this.#taskSegment(task), MAIN_WORKTREE, main.entries) === "dirty") {
2102
- return {
2103
- taskId,
2104
- status: "retained-dirty",
2105
- ...(task.cwd === undefined ? {} : { path: task.cwd })
2106
- };
2107
- }
2108
- for (const entry of main.entries) {
2098
+ // The shared preflight and removeTaskClone both inspect clone identity.
2099
+ // Do not re-enter linked-worktree inspection against a missing clone.
2100
+ for (const entry of main.entries.filter(e => e.access === "write")) {
2109
2101
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2110
2102
  const result = await this.git.removeTaskClone({
2111
2103
  path: entry.path, container: main.root, directory: entry.directory,
@@ -2117,7 +2109,7 @@ export class FileTaskWorkspacePreparer {
2117
2109
  this.#recordArchivePathRemoval(task, entry.path, result);
2118
2110
  }
2119
2111
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2120
- await removeWorkspaceView(main.root);
2112
+ await removeWorkspaceView(main.root, main);
2121
2113
  this.#resourceRegistrar().markWorkspaceDeleted(main);
2122
2114
  assertTaskArchiveState(requireTask(this.store, task.id), task);
2123
2115
  this.#recordWorkspaceRemoval(task, main, this.#fallbackWorkspace());
@@ -2175,9 +2167,6 @@ export class FileTaskWorkspacePreparer {
2175
2167
  #reviewRoundWorkspaceRoot(taskId, round) {
2176
2168
  return join(resolveTaskRoot(this.home), safePathSegment(taskId), "reviews", safePathSegment(this.#reviewWorktreeName(round)));
2177
2169
  }
2178
- #integrationWorkspaceRoot(taskId, integrationId) {
2179
- return integrationWorkspaceRoot(this.home, taskId, integrationId);
2180
- }
2181
2170
  #reviewWorktreeName(round) {
2182
2171
  return round.scope === "task"
2183
2172
  ? `reviewer-${round.reviewerRoleName}`
@@ -2570,7 +2559,12 @@ async function ensureWorkspaceView(root, entries) {
2570
2559
  await symlink(target, join(root, directory), "dir");
2571
2560
  }
2572
2561
  }
2573
- async function removeWorkspaceView(root) {
2562
+ async function removeWorkspaceView(root, workspace) {
2563
+ if (workspace !== undefined) {
2564
+ const checks = await inspectWorkspaceContainer(workspace);
2565
+ if (checks.length > 0)
2566
+ throw new CleanupInspectionError(checks);
2567
+ }
2574
2568
  let entries;
2575
2569
  try {
2576
2570
  entries = await readdir(root, { withFileTypes: true });
@@ -2584,6 +2578,8 @@ async function removeWorkspaceView(root) {
2584
2578
  if (!entry.isSymbolicLink()) {
2585
2579
  throw new Error(`Managed workspace contains an unexpected entry: ${join(root, entry.name)}.`);
2586
2580
  }
2581
+ }
2582
+ for (const entry of entries) {
2587
2583
  await unlink(join(root, entry.name));
2588
2584
  }
2589
2585
  await rmdir(root);