@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { chmod, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
3
  import { dirname, join, resolve } from "node:path";
4
+ import { releaseWorkflowScratchRoot } from "../storage/homeLayout.js";
5
5
  import { runUpdate } from "../cli/updateOrchestrator.js";
6
6
  import { activatedControllerEntrypoint } from "../cli/updatePorts.js";
7
7
  import { restartFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
@@ -581,7 +581,7 @@ export function createReleaseWorkflowPorts(deps) {
581
581
  // publish` read THIS snapshot, so a TOCTOU replacement of the
582
582
  // original path after the integrity check cannot change what is
583
583
  // published. The snapshot is removed once publish completes.
584
- const snapshot = await writeVerifiedTarballSnapshot(bytes);
584
+ const snapshot = await writeVerifiedTarballSnapshot(deps.home, bytes);
585
585
  try {
586
586
  // Inspect the tarball manifest before publishing: the actual
587
587
  // package name/version must match the frozen declaration, not
@@ -727,8 +727,12 @@ export function createReleaseWorkflowPorts(deps) {
727
727
  // P2-2 (rr19): Install in a fresh temp directory with an isolated
728
728
  // npm cache, so npx cannot reuse a local node_modules binary. Then
729
729
  // run the installed binary directly and verify the output matches
730
- // the pinned version.
731
- const smokeDir = await mkdtemp(join(tmpdir(), "yui-smoke-"));
730
+ // the pinned version. The smoke directory is a Yui-authored release
731
+ // artifact, so it lands under the Home release-workflow scratch root,
732
+ // never a shared system temp root.
733
+ const scratch = releaseWorkflowScratchRoot(deps.home);
734
+ await mkdir(scratch, { recursive: true, mode: 0o700 });
735
+ const smokeDir = await mkdtemp(join(scratch, "yui-smoke-"));
732
736
  const smokeCache = join(smokeDir, "npm-cache");
733
737
  try {
734
738
  const install = await run("npm", [
@@ -1642,10 +1646,14 @@ async function queryControllerLifecycle(run, globalYui, home, expectedVersion) {
1642
1646
  * random name and read-only permissions (P1-3, rr22). The manifest
1643
1647
  * inspection and `npm publish` both read this snapshot, so replacing the
1644
1648
  * original path after the integrity check cannot change what is published.
1645
- * The caller removes the snapshot once publish completes.
1649
+ * The caller removes the snapshot once publish completes. The snapshot is a
1650
+ * Yui-authored release artifact, so it lands under the Home release-workflow
1651
+ * scratch root, never a shared system temp root.
1646
1652
  */
1647
- async function writeVerifiedTarballSnapshot(bytes) {
1648
- const snapshot = join(tmpdir(), `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
1653
+ async function writeVerifiedTarballSnapshot(home, bytes) {
1654
+ const scratch = releaseWorkflowScratchRoot(home);
1655
+ await mkdir(scratch, { recursive: true, mode: 0o700 });
1656
+ const snapshot = join(scratch, `yui-release-snapshot-${randomBytes(12).toString("hex")}.tgz`);
1649
1657
  // "wx" fails if the random name already exists, so a pre-existing file
1650
1658
  // (or symlink) can never be overwritten or followed.
1651
1659
  await writeFile(snapshot, bytes, { flag: "wx" });
@@ -679,7 +679,10 @@ export class NodeGitWorkspace {
679
679
  return this.#ensureManagedWorktree({
680
680
  repositoryPath: input.repositoryPath,
681
681
  container: input.container,
682
- identity: worktreeIdentity(input.taskSegment, input.roleName),
682
+ identity: {
683
+ directory: input.directory,
684
+ branch: worktreeIdentity(input.taskSegment, input.roleName).branch
685
+ },
683
686
  baseRef: input.baseRef
684
687
  });
685
688
  }
@@ -687,7 +690,10 @@ export class NodeGitWorkspace {
687
690
  return this.#ensureManagedWorktree({
688
691
  repositoryPath: input.repositoryPath,
689
692
  container: input.container,
690
- identity: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
693
+ identity: {
694
+ directory: input.directory,
695
+ branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
696
+ },
691
697
  baseRef: input.baseRef,
692
698
  allowRebase: true
693
699
  });
@@ -734,19 +740,19 @@ export class NodeGitWorkspace {
734
740
  if (state === "dirty")
735
741
  return state;
736
742
  const container = resolve(input.container);
737
- const identity = worktreeIdentity(input.taskSegment, input.roleName);
738
- const path = managedPath(container, identity.directory);
743
+ const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
744
+ const path = managedPath(container, input.directory);
739
745
  const project = await this.inspect(input.repositoryPath);
740
746
  if (state === "missing") {
741
747
  if (input.deleteBranch === true) {
742
- await git(["-C", project.root, "worktree", "prune"]);
743
- await deleteBranchIfPresent(project.root, identity.branch);
748
+ await removeMissingWorktreeRegistration(project.root, path, branch);
749
+ await deleteBranchIfPresent(project.root, branch);
744
750
  }
745
751
  return state;
746
752
  }
747
753
  await git(["-C", project.root, "worktree", "remove", "--", path]);
748
754
  if (input.deleteBranch === true) {
749
- await deleteBranchIfPresent(project.root, identity.branch);
755
+ await deleteBranchIfPresent(project.root, branch);
750
756
  }
751
757
  return "removed";
752
758
  }
@@ -779,6 +785,36 @@ export class NodeGitWorkspace {
779
785
  }
780
786
  return "removed";
781
787
  }
788
+ async removeTaskClone(input) {
789
+ const identity = worktreeIdentity(input.taskSegment, "main");
790
+ const expected = managedPath(resolve(input.container), input.directory);
791
+ if (resolve(input.path) !== expected || input.branch !== identity.branch) {
792
+ throw new Error("Task clone does not match its recorded managed identity.");
793
+ }
794
+ const kind = await pathKind(expected);
795
+ if (kind === undefined)
796
+ return "missing";
797
+ if (kind === "symlink")
798
+ throw new Error("Task clone must not be a symbolic link.");
799
+ await canonicalContainer(input.container, false);
800
+ if (await realpath(expected) !== expected || await pathKind(join(expected, ".git")) !== "directory") {
801
+ throw new Error("Task clone is not a standalone owned Git directory.");
802
+ }
803
+ const repository = await this.inspect(expected);
804
+ if (repository.root !== expected || repository.gitDirectory !== join(expected, ".git")) {
805
+ throw new Error("Task clone Git ownership cannot be established.");
806
+ }
807
+ await assertExpectedBranch(expected, input.branch);
808
+ if (!await this.isClean(expected))
809
+ return "dirty";
810
+ const worktrees = (await git(["-C", expected, "worktree", "list", "--porcelain", "-z"]))
811
+ .split("\0").filter(line => line.startsWith("worktree "));
812
+ if (worktrees.length !== 1 || worktrees[0] !== `worktree ${expected}`) {
813
+ throw new Error("Task clone still owns Git worktree registrations; retained.");
814
+ }
815
+ await rm(expected, { recursive: true });
816
+ return "removed";
817
+ }
782
818
  async inspectRecordedWorktree(input) {
783
819
  const inspected = await inspectExactRecordedWorktree(input);
784
820
  return inspected?.state ?? "missing";
@@ -824,7 +860,7 @@ export class NodeGitWorkspace {
824
860
  }
825
861
  async inspectWorktree(input) {
826
862
  const container = resolve(input.container);
827
- const path = managedPath(container, worktreeIdentity(input.taskSegment, input.roleName).directory);
863
+ const path = managedPath(container, input.directory);
828
864
  const kind = await pathKind(path);
829
865
  if (kind === undefined)
830
866
  return "missing";
@@ -840,7 +876,10 @@ export class NodeGitWorkspace {
840
876
  return this.#removeManagedWorktree({
841
877
  repositoryPath: input.repositoryPath,
842
878
  container: input.container,
843
- identity: integrationWorktreeIdentity(input.taskSegment, input.integrationId),
879
+ identity: {
880
+ directory: input.directory,
881
+ branch: integrationWorktreeIdentity(input.taskSegment, input.integrationId).branch
882
+ },
844
883
  discardChanges: input.discardChanges
845
884
  });
846
885
  }
@@ -851,6 +890,9 @@ export class NodeGitWorkspace {
851
890
  if (kind === "symlink")
852
891
  throw new Error("Managed worktree path must not be a symbolic link.");
853
892
  const repository = await this.inspect(input.repositoryPath);
893
+ if (kind === undefined) {
894
+ await removeMissingWorktreeRegistration(repository.root, path, input.identity.branch);
895
+ }
854
896
  if (kind === "directory") {
855
897
  const canonicalContainerPath = await canonicalContainer(container, false);
856
898
  await assertOwnedWorktree(repository, canonicalContainerPath, path);
@@ -884,6 +926,21 @@ export class NodeGitWorkspace {
884
926
  return kind === "directory" ? "removed" : "missing";
885
927
  }
886
928
  }
929
+ /** Missing paths do not imply missing Git metadata. Never prune another
930
+ * workspace's registration as a side effect of cleaning this exact owner.
931
+ */
932
+ async function removeMissingWorktreeRegistration(repositoryRoot, path, branch) {
933
+ const records = (await git(["-C", repositoryRoot, "worktree", "list", "--porcelain", "-z"])).split("\0\0");
934
+ const fields = records.map(record => record.split("\0")).find(record => record.includes(`worktree ${path}`));
935
+ if (fields === undefined)
936
+ return;
937
+ if (!fields.includes(`branch refs/heads/${branch}`) || fields.some(field => field.startsWith("locked"))) {
938
+ throw new Error(`Missing worktree has uncertain or locked Git ownership: ${path}; metadata retained.`);
939
+ }
940
+ if (await pathKind(path) !== undefined)
941
+ throw new Error(`Worktree reappeared before metadata cleanup: ${path}.`);
942
+ await git(["-C", repositoryRoot, "worktree", "remove", "--force", "--", path]);
943
+ }
887
944
  async function deleteBranchIfPresent(repositoryRoot, branch) {
888
945
  const branchRef = `refs/heads/${branch}`;
889
946
  if (!await gitSucceeds([
@@ -960,9 +1017,9 @@ async function assertOwnedWorktree(project, container, path) {
960
1017
  }
961
1018
  async function inspectExactRecordedWorktree(input) {
962
1019
  const container = resolve(input.container);
963
- const identity = worktreeIdentity(input.taskSegment, input.roleName);
964
- const expectedPath = managedPath(container, identity.directory);
965
- if (resolve(input.path) !== expectedPath || input.branch !== identity.branch) {
1020
+ const expectedBranch = worktreeIdentity(input.taskSegment, input.roleName).branch;
1021
+ const expectedPath = managedPath(container, input.directory);
1022
+ if (resolve(input.path) !== expectedPath || input.branch !== expectedBranch) {
966
1023
  throw new Error("Recorded managed worktree identity is invalid.");
967
1024
  }
968
1025
  const kind = await pathKind(expectedPath);
@@ -1023,9 +1080,9 @@ async function resolveRefCommit(repositoryPath, ref) {
1023
1080
  async function recordedWorktreeWithoutRepository(input, cause) {
1024
1081
  if (await pathKind(input.repositoryPath) !== undefined)
1025
1082
  throw cause;
1026
- const identity = worktreeIdentity(input.taskSegment, input.roleName);
1027
- const expectedPath = managedPath(resolve(input.container), identity.directory);
1028
- if (resolve(input.path) !== expectedPath || input.branch !== identity.branch) {
1083
+ const branch = worktreeIdentity(input.taskSegment, input.roleName).branch;
1084
+ const expectedPath = managedPath(resolve(input.container), input.directory);
1085
+ if (resolve(input.path) !== expectedPath || input.branch !== branch) {
1029
1086
  throw cause;
1030
1087
  }
1031
1088
  if (await pathKind(expectedPath) === undefined)
@@ -1,4 +1,8 @@
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";
2
6
  import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
3
7
  import { managedWorkspaceKey } from "../worktree/managedWorkspace.js";
4
8
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
@@ -247,6 +251,7 @@ export class TaskWorkspaceCoordinator {
247
251
  if (attempt === null
248
252
  || attempt.status === "running"
249
253
  || attempt.status === "blocked"
254
+ || attempt.status === "conflicted"
250
255
  || attempt.status === "validating") {
251
256
  throw new Error(`IntegrationAttempt must be terminal before archive cleanup: ${workspace.owner.integrationAttemptId}.`);
252
257
  }
@@ -330,6 +335,135 @@ export class TaskWorkspaceCoordinator {
330
335
  releaseMaintenance?.();
331
336
  }
332
337
  }
338
+ /** Best-effort cleanup only after explicit force admission has committed.
339
+ * This is one foreground attempt, not a retry worker. Each independent result
340
+ * is durable before continuing, and a failed audit write really fails.
341
+ */
342
+ async cleanupArchivedTask(taskId, disposition) {
343
+ const task = this.store.getTask(taskId);
344
+ if (task?.status !== "archived"
345
+ || !this.store.listEvents(taskId).some(e => e.type === "task.archived" && e.payload.force === "true")) {
346
+ throw new Error(`Force archive must commit before cleanup: ${taskId}.`);
347
+ }
348
+ const attempt = async (diagnostic, action) => {
349
+ recordArchiveCleanup(this.store, taskId, diagnostic, "started");
350
+ let status;
351
+ let detail = diagnostic.detail;
352
+ try {
353
+ const result = await action();
354
+ status = result === "dirty" ? "retained" : result;
355
+ if (result === "dirty")
356
+ detail = "Dirty workspace retained; force archive does not discard local changes.";
357
+ }
358
+ catch (error) {
359
+ if (isArchivePersistenceFailure(error))
360
+ throw error;
361
+ status = "retained";
362
+ detail = error instanceof Error ? error.message : String(error);
363
+ }
364
+ recordArchiveCleanup(this.store, taskId, { ...diagnostic, detail }, status);
365
+ return status !== "retained";
366
+ };
367
+ let runtimeReleased = true;
368
+ for (const role of this.store.listRoles(taskId)) {
369
+ const released = await attempt({ resource: `role:${taskId}/${role.name}`,
370
+ detail: "Exact Role runtime stop." }, async () => {
371
+ const provider = this.store.getTaskRoleSessionSet(taskId, role.name)?.providerBinding;
372
+ const mailbox = this.store.getWorkMailbox({ kind: "role", taskId, roleName: role.name });
373
+ if (mailbox?.processing != null && (provider?.run == null
374
+ || !["completed", "failed", "cancelled"].includes(provider.run.status))) {
375
+ throw new Error(`Original mailbox claim ${mailbox.processing.batchId} retained; execution outcome is not known.`);
376
+ }
377
+ if (provider?.run != null && !["completed", "failed", "cancelled"].includes(provider.run.status)) {
378
+ throw new Error(`Provider input ${provider.run.attemptId}/${provider.run.status} retained; establish exact quiescence before resolving it.`);
379
+ }
380
+ await this.#stopLiveRoles(taskId, [role.name]);
381
+ return "released";
382
+ });
383
+ runtimeReleased &&= released;
384
+ }
385
+ const physicalReleased = await attempt({ resource: `runtime:${taskId}`,
386
+ detail: "Verify exact physical resource release before workspace deletion." }, async () => {
387
+ const activeRun = this.store.listRuns(taskId).find(r => r.status === "active");
388
+ const job = this.store.listDurableJobs(taskId).find(j => ["queued", "running", "unknown-needs-attention"].includes(j.status));
389
+ if (activeRun !== undefined || job !== undefined) {
390
+ throw new Error(`Execution resources retained: ${activeRun?.id ?? job?.id}; archive is not proof of quiescence.`);
391
+ }
392
+ if (!runtimeReleased)
393
+ throw new Error("One or more Role runtimes could not be safely released.");
394
+ if (this.runtime.assertTaskPhysicalResourcesReleased === undefined) {
395
+ throw new Error("Physical resource release inspection is unavailable; workspace references retained.");
396
+ }
397
+ await this.runtime.assertTaskPhysicalResourcesReleased(taskId);
398
+ return "released";
399
+ });
400
+ if (physicalReleased) {
401
+ // Task main owns the Git objects of its dependent worktrees, so it is
402
+ // always last and remains when any dependent cleanup is unresolved.
403
+ const workspaces = this.store.listManagedWorkspaces(taskId).sort((a, b) => Number(a.owner.type === "task") - Number(b.owner.type === "task"));
404
+ for (const workspace of workspaces) {
405
+ await attempt({ resource: managedWorkspaceKey(workspace.owner),
406
+ detail: "Remove only clean, exactly owned workspace resources.",
407
+ paths: [workspace.root, ...workspace.entries.filter(e => e.access === "write").map(e => e.path)]
408
+ }, async () => {
409
+ const release = acquireProjectMaintenanceLocks(this.preparer.home, workspace.entries.map(e => e.projectId));
410
+ try {
411
+ if (!isDeepStrictEqual(this.store.getManagedWorkspace(workspace.owner), workspace)) {
412
+ throw new Error("Workspace ownership changed; retained.");
413
+ }
414
+ const owner = workspace.owner;
415
+ switch (owner.type) {
416
+ case "work-item": {
417
+ const item = this.store.getWorkItem(taskId, owner.workItemId);
418
+ if (item?.status === "accepted" && disposition === "integrated") {
419
+ await new WorkItemChangeSetManager(this.store).assertIntegrated(taskId, item.id);
420
+ }
421
+ return this.preparer.cleanupWorkItemWorkspace(taskId, owner.workItemId, item?.status === "retired" ? "abandoned" : disposition);
422
+ }
423
+ case "review-round":
424
+ return this.preparer.cleanupReviewRoundWorkspace(taskId, owner.reviewRoundId);
425
+ case "execution-lane":
426
+ return this.preparer.cleanupExecutionLaneWorkspace(taskId, owner.executionGroupId, owner.executionLaneId);
427
+ case "integration-attempt":
428
+ if (await this.preparer.inspectIntegrationWorkspace(taskId, owner.integrationAttemptId) === "dirty")
429
+ return "dirty";
430
+ return this.preparer.cleanupIntegrationWorkspace(taskId, owner.integrationAttemptId);
431
+ case "task": {
432
+ const current = this.store.getTask(taskId);
433
+ const delivery = projectTaskRemoteDeliveryFromStore(this.store, current);
434
+ if (!delivery.allMerged || !delivery.allVerified) {
435
+ throw new Error("Task main retained: local commits are not covered by verified remote delivery. Force does not discard them.");
436
+ }
437
+ for (const entry of workspace.entries.filter(e => e.access === "write")) {
438
+ const expected = delivery.projects.find(p => p.projectId === entry.projectId)?.expectedLocalCommit;
439
+ const path = await lstat(entry.path).catch(error => {
440
+ if (error.code === "ENOENT")
441
+ return null;
442
+ throw error;
443
+ });
444
+ if (expected !== null && expected !== undefined
445
+ && path !== null) {
446
+ const actual = await this.preparer.git.inspect(entry.path, "HEAD");
447
+ if (actual.baseCommit !== expected)
448
+ throw new Error(`Task main HEAD changed: ${entry.path}; retained.`);
449
+ }
450
+ }
451
+ const result = await this.preparer.cleanupTaskForArchive(taskId);
452
+ if (result.status !== "removed")
453
+ throw new Error(result.error ?? "Task main or dependent workspace retained.");
454
+ return "removed";
455
+ }
456
+ }
457
+ }
458
+ finally {
459
+ release();
460
+ }
461
+ });
462
+ }
463
+ }
464
+ recordArchiveCleanup(this.store, taskId, { resource: `task:${taskId}`,
465
+ detail: "Foreground cleanup attempt finished; inspect retained resource references and warnings." }, "finished");
466
+ }
333
467
  #assertTaskArchiveSnapshot(snapshot) {
334
468
  this.#assertTaskArchiveLifecycle(snapshot.task);
335
469
  const managedWorkspaces = [...this.store.listManagedWorkspaces(snapshot.task.id)]