@zq-silk/yui 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Cross-process mutual exclusion for one workflow's engine runs. Each CLI
6
+ * invocation is a fresh Node process, so an in-process map cannot coordinate
7
+ * across processes; this lock lives under the FileTaskStore root instead.
8
+ *
9
+ * The lock is a directory created atomically with mkdir(2): its existence is
10
+ * the lock, so a second acquirer fails with EEXIST while the first holds it.
11
+ * The owner receipt (pid + unique fence token) is written to a temp name and
12
+ * renamed into place, so a reader observes either no receipt or a complete
13
+ * one. Incomplete metadata is never reclaimed as dead: a lock without a
14
+ * complete, parseable receipt is treated as live (its owner may be between
15
+ * the mkdir and the receipt rename), so the empty-file window of the old
16
+ * O_EXCL file lock can never produce two owners. A complete receipt whose
17
+ * process is dead is reclaimed.
18
+ *
19
+ * Release verifies the fence token before removing the directory, so a lock
20
+ * that was legitimately transferred (a stale holder was reclaimed and a new
21
+ * owner acquired) is never removed by a stale release closure.
22
+ */
23
+ const WORKFLOW_LOCK_DIRECTORY = ".release-workflow-locks";
24
+ const OWNER_RECEIPT_NAME = "owner";
25
+ const DEFAULT_LOCK_TIMEOUT_MS = 600_000;
26
+ const DEFAULT_LOCK_RETRY_MS = 20;
27
+ export async function acquireWorkflowFileLock(rootDir, taskId, workflowId, options = {}) {
28
+ const lockRoot = join(rootDir, WORKFLOW_LOCK_DIRECTORY);
29
+ mkdirSync(lockRoot, { recursive: true, mode: 0o700 });
30
+ const lockDir = join(lockRoot, `${sanitizeLockPart(taskId)}--${sanitizeLockPart(workflowId)}.lock`);
31
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
32
+ const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
33
+ const deadline = Date.now() + timeoutMs;
34
+ for (;;) {
35
+ try {
36
+ mkdirSync(lockDir, { mode: 0o700 });
37
+ const token = writeOwnerReceipt(lockDir);
38
+ let released = false;
39
+ return () => {
40
+ if (released)
41
+ return;
42
+ released = true;
43
+ // Only remove the lock we own: a receipt with another token means
44
+ // the lock was transferred and a different owner now holds it.
45
+ if (!receiptNamesOwner(lockDir, token))
46
+ return;
47
+ rmSync(lockDir, { recursive: true, force: true });
48
+ };
49
+ }
50
+ catch (error) {
51
+ if (!isEExist(error))
52
+ throw error;
53
+ reclaimDeadWorkflowLock(lockDir);
54
+ if (Date.now() >= deadline) {
55
+ throw new Error(`Timed out waiting for the release workflow lock: ${taskId}/${workflowId}.`);
56
+ }
57
+ // Yield to the event loop so a concurrent run in the same process can
58
+ // progress while this one waits.
59
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * Writes the owner receipt atomically: a temp file inside the (already
65
+ * exclusively owned) lock directory, renamed to the receipt name. A reader
66
+ * therefore sees a complete receipt or none, never a partial one. A failure
67
+ * here would strand the lock after our death (it could never be reclaimed
68
+ * without a receipt), so the lock directory is removed and the error raised.
69
+ */
70
+ function writeOwnerReceipt(lockDir) {
71
+ const token = randomUUID();
72
+ const receipt = { pid: process.pid, token };
73
+ const temp = join(lockDir, `.owner.${token}.tmp`);
74
+ const fd = openSync(temp, "wx", 0o600);
75
+ try {
76
+ writeFileSync(fd, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ try {
82
+ renameSync(temp, join(lockDir, OWNER_RECEIPT_NAME));
83
+ }
84
+ catch (error) {
85
+ rmSync(lockDir, { recursive: true, force: true });
86
+ throw error;
87
+ }
88
+ return token;
89
+ }
90
+ /**
91
+ * The complete owner receipt, or null when the lock carries no complete
92
+ * receipt (the owner may be mid-acquisition) or an unparseable one. Null
93
+ * means "do not reclaim": incomplete metadata is never treated as dead.
94
+ */
95
+ function readOwnerReceipt(lockDir) {
96
+ let raw;
97
+ try {
98
+ raw = readFileSync(join(lockDir, OWNER_RECEIPT_NAME), "utf8");
99
+ }
100
+ catch (error) {
101
+ if (isEnoent(error))
102
+ return null;
103
+ throw error;
104
+ }
105
+ try {
106
+ const parsed = JSON.parse(raw);
107
+ if (typeof parsed !== "object" || parsed === null)
108
+ return null;
109
+ const receipt = parsed;
110
+ if (typeof receipt.pid !== "number" || !Number.isInteger(receipt.pid) || receipt.pid <= 0) {
111
+ return null;
112
+ }
113
+ if (typeof receipt.token !== "string" || receipt.token.length === 0)
114
+ return null;
115
+ return { pid: receipt.pid, token: receipt.token };
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function receiptNamesOwner(lockDir, token) {
122
+ return readOwnerReceipt(lockDir)?.token === token;
123
+ }
124
+ /**
125
+ * Reclaims a lock whose owner process is dead. A lock without a complete
126
+ * receipt is left alone: its owner may be between the mkdir and the receipt
127
+ * rename, and reclaiming in that window would hand the pathname to a second
128
+ * owner while the first still believes it holds the lock.
129
+ *
130
+ * The reclaim itself is serialized with an atomic marker created via
131
+ * `linkSync`: only one process can create the marker, so a second reclaimer
132
+ * cannot remove a lock that was already reclaimed and re-acquired by a
133
+ * third process between the first reclaimer's receipt check and its delete.
134
+ * The receipt is re-read under the marker so a transferred lock is never
135
+ * removed.
136
+ */
137
+ function reclaimDeadWorkflowLock(lockDir) {
138
+ const receipt = readOwnerReceipt(lockDir);
139
+ if (receipt === null)
140
+ return;
141
+ if (processIsAlive(receipt.pid))
142
+ return;
143
+ const markerPath = join(lockDir, ".reclaiming");
144
+ const tempPath = join(lockDir, `.reclaiming.${process.pid}.${randomUUID()}.tmp`);
145
+ try {
146
+ const fd = openSync(tempPath, "wx", 0o600);
147
+ try {
148
+ writeFileSync(fd, `${process.pid}\n`, { mode: 0o600 });
149
+ }
150
+ finally {
151
+ closeSync(fd);
152
+ }
153
+ try {
154
+ linkSync(tempPath, markerPath);
155
+ }
156
+ catch (error) {
157
+ if (!isEExist(error))
158
+ throw error;
159
+ // A concurrent or crashed reclaim left a marker. If its process is
160
+ // dead, the marker is stale: remove it and claim once.
161
+ if (!isReclaimMarkerStale(markerPath))
162
+ return;
163
+ rmSync(markerPath, { force: true });
164
+ linkSync(tempPath, markerPath);
165
+ }
166
+ }
167
+ catch (error) {
168
+ rmSync(tempPath, { force: true });
169
+ if (isEnoent(error))
170
+ return; // Lock directory was removed
171
+ throw error;
172
+ }
173
+ try {
174
+ // Re-read under the marker: the lock may have been transferred to a
175
+ // live owner between our first read and the marker claim.
176
+ const current = readOwnerReceipt(lockDir);
177
+ if (current === null)
178
+ return;
179
+ if (current.token !== receipt.token)
180
+ return;
181
+ if (processIsAlive(current.pid))
182
+ return;
183
+ rmSync(lockDir, { recursive: true, force: true });
184
+ }
185
+ finally {
186
+ rmSync(markerPath, { force: true });
187
+ }
188
+ }
189
+ function isReclaimMarkerStale(markerPath) {
190
+ try {
191
+ const pid = parseInt(readFileSync(markerPath, "utf8").trim(), 10);
192
+ return Number.isInteger(pid) && pid > 0 && !processIsAlive(pid);
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ function processIsAlive(pid) {
199
+ try {
200
+ process.kill(pid, 0);
201
+ return true;
202
+ }
203
+ catch (error) {
204
+ return isEperm(error);
205
+ }
206
+ }
207
+ function isEExist(error) {
208
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
209
+ }
210
+ function isEnoent(error) {
211
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
212
+ }
213
+ function isEperm(error) {
214
+ return error instanceof Error && "code" in error && error.code === "EPERM";
215
+ }
216
+ function sanitizeLockPart(value) {
217
+ return value.replace(/[^A-Za-z0-9_-]/g, "-");
218
+ }
@@ -44,6 +44,55 @@ export class NodeGitWorkspace {
44
44
  return null;
45
45
  }
46
46
  }
47
+ /**
48
+ * Whether two commits hold the same tree content on the given paths.
49
+ * An enqueued ChangeSet whose head agrees with the target on every path
50
+ * it touched is already represented there and converges without a new
51
+ * commit, even when other unrelated changes landed in between.
52
+ *
53
+ * The captured paths are literal filenames, so `--literal-pathspecs`
54
+ * disables pathspec magic: a name such as `:(exclude)*` must not exclude
55
+ * every path and fake a converged tree. It is a global option and
56
+ * therefore must precede the `diff` subcommand.
57
+ */
58
+ async treesAgreeOnPaths(input) {
59
+ if (input.paths.length === 0)
60
+ return false;
61
+ return gitSucceeds([
62
+ "-C", input.repositoryPath,
63
+ "--literal-pathspecs",
64
+ "diff", "--quiet",
65
+ input.leftCommit, input.rightCommit,
66
+ "--", ...input.paths
67
+ ]);
68
+ }
69
+ /**
70
+ * Every path changed between two commits. The integration queue uses this
71
+ * to fence a validated entry: a target advance whose real path delta
72
+ * overlaps the entry's own paths (or whose impact cannot be proven)
73
+ * invalidates the entry's reusable evidence.
74
+ */
75
+ async changedFilesBetween(input) {
76
+ const output = await git([
77
+ "-C", input.repositoryPath,
78
+ "diff", "--name-only",
79
+ input.fromCommit, input.toCommit
80
+ ]);
81
+ return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
82
+ }
83
+ /**
84
+ * Files deleted between two commits. Uses `--diff-filter=D` so a rename
85
+ * reports its source path here (and its destination in `changedFilesBetween`),
86
+ * letting the containment proof verify both sides of a rename.
87
+ */
88
+ async deletedFilesBetween(input) {
89
+ const output = await git([
90
+ "-C", input.repositoryPath,
91
+ "diff", "--name-only", "--diff-filter=D", "--no-renames",
92
+ input.fromCommit, input.toCommit
93
+ ]);
94
+ return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
95
+ }
47
96
  /**
48
97
  * Resolve the configured Project branch directly from its remote. This is
49
98
  * deliberately read-only: unlike `refresh`, it never advances the stable
@@ -307,6 +356,19 @@ export class NodeGitWorkspace {
307
356
  baseCommit: baseCommit.toLowerCase()
308
357
  };
309
358
  }
359
+ async assertRefAt(repositoryPath, ref, expectedCommit) {
360
+ const root = (await this.inspect(repositoryPath)).root;
361
+ // `git update-ref <ref> <new> <old>` is atomic: it succeeds only when the
362
+ // ref still points at <old>. Using the same commit for both new and old
363
+ // makes it a pure compare-and-swap probe — the ref does not move, but a
364
+ // concurrent advance between the last read and this call is detected.
365
+ await git([
366
+ "-C", root, "update-ref",
367
+ safeRef(ref),
368
+ expectedCommit,
369
+ expectedCommit
370
+ ]);
371
+ }
310
372
  async refExists(repositoryPath, ref) {
311
373
  const root = (await this.inspect(repositoryPath)).root;
312
374
  return gitSucceeds([
@@ -420,6 +482,56 @@ export class NodeGitWorkspace {
420
482
  ]);
421
483
  }
422
484
  }
485
+ async assertNoForeignWorktreeOnRef(input) {
486
+ const root = (await this.inspect(input.repositoryPath)).root;
487
+ const wanted = safeRef(input.ref);
488
+ // The recorded worktree is removed in the same archive flow; it is the
489
+ // only same-repo worktree on the ref that may stay for now. A path that
490
+ // no longer exists cannot match a live worktree.
491
+ const excluded = input.excludeWorktreePath === undefined
492
+ ? undefined
493
+ : await realpath(resolve(input.excludeWorktreePath)).catch(() => undefined);
494
+ const porcelain = await git(["-C", root, "worktree", "list", "--porcelain"]);
495
+ const records = [];
496
+ let current = { prunable: false };
497
+ for (const line of porcelain.split("\n")) {
498
+ if (line.length === 0) {
499
+ const path = current.path;
500
+ if (path !== undefined) {
501
+ records.push({ path, branch: current.branch, prunable: current.prunable });
502
+ }
503
+ current = { prunable: false };
504
+ continue;
505
+ }
506
+ if (line.startsWith("worktree ")) {
507
+ current.path = line.slice("worktree ".length);
508
+ }
509
+ else if (line.startsWith("branch ")) {
510
+ current.branch = line.slice("branch ".length);
511
+ }
512
+ else if (line.startsWith("prunable")) {
513
+ current.prunable = true;
514
+ }
515
+ }
516
+ const lastPath = current.path;
517
+ if (lastPath !== undefined) {
518
+ records.push({ path: lastPath, branch: current.branch, prunable: current.prunable });
519
+ }
520
+ for (const record of records) {
521
+ // A dead registration (its directory is gone) occupies nothing; a
522
+ // worktree on another ref or a detached HEAD does not occupy this one.
523
+ if (record.prunable || record.branch !== wanted)
524
+ continue;
525
+ // git reports canonical absolute paths; compare canonicals so a
526
+ // symlinked workspace root cannot make the recorded worktree look
527
+ // foreign.
528
+ const canonical = await realpath(record.path).catch(() => record.path);
529
+ if (canonical === excluded)
530
+ continue;
531
+ throw new Error(`Ref ${wanted} is checked out by a worktree outside this Home's management ` +
532
+ `(${record.path}); the archive refuses to delete the ref and strand that worktree.`);
533
+ }
534
+ }
423
535
  async isAncestor(repositoryPath, ancestor, descendant) {
424
536
  const root = (await this.inspect(repositoryPath)).root;
425
537
  return gitSucceeds([
@@ -577,12 +689,53 @@ export class NodeGitWorkspace {
577
689
  }
578
690
  return "removed";
579
691
  }
692
+ /**
693
+ * Remove a stranded worktree whose Git common-dir no longer matches the
694
+ * Project's current repository (e.g. after a `project migrate` switched the
695
+ * catalog). The normal {@link removeWorktree} path rejects such worktrees
696
+ * via `assertOwnedWorktree`; this fallback inspects and removes through the
697
+ * worktree's own Git identity instead. Only call for unadopted worktrees
698
+ * that are safe to discard (e.g. Lane preparation compensation).
699
+ */
700
+ async removeStrandedWorktree(path) {
701
+ const kind = await pathKind(path);
702
+ if (kind === undefined)
703
+ return "missing";
704
+ if (kind === "symlink")
705
+ throw new Error("Stranded worktree path must not be a symbolic link.");
706
+ // Inspect dirty state through the worktree's own Git, bypassing the
707
+ // Project ownership check that fails after a catalog switch.
708
+ const porcelain = await git(["-C", path, "status", "--porcelain=v1", "--untracked-files=all"]);
709
+ if (porcelain.length > 0)
710
+ return "dirty";
711
+ try {
712
+ await git(["-C", path, "worktree", "remove", "--force", "--", path]);
713
+ }
714
+ catch {
715
+ // The worktree's common-dir (old external repo) may itself be gone.
716
+ // Remove the directory directly; the worktree is unadopted.
717
+ await rm(path, { recursive: true, force: true });
718
+ }
719
+ return "removed";
720
+ }
580
721
  async inspectRecordedWorktree(input) {
581
722
  const inspected = await inspectExactRecordedWorktree(input);
582
723
  return inspected?.state ?? "missing";
583
724
  }
584
725
  async removeRecordedWorktree(input) {
585
- const inspected = await inspectExactRecordedWorktree(input);
726
+ let inspected;
727
+ try {
728
+ inspected = await inspectExactRecordedWorktree(input);
729
+ }
730
+ catch (error) {
731
+ // A deleted external checkout takes the worktree's common dir with it,
732
+ // so every git op against the worktree fails. When the Project
733
+ // repository itself is gone, the worktree's Git identity, HEAD, index,
734
+ // and dirty state can no longer be proven. An extant directory is
735
+ // retained and the caller fails closed with a manual-cleanup diagnosis;
736
+ // only a directory that is also absent is treated as missing.
737
+ return await recordedWorktreeWithoutRepository(input, error);
738
+ }
586
739
  if (inspected === undefined)
587
740
  return "missing";
588
741
  if (inspected.state === "dirty")
@@ -776,6 +929,29 @@ async function resolveRefCommit(repositoryPath, ref) {
776
929
  throw new Error("Git returned an invalid ref commit.");
777
930
  return commit;
778
931
  }
932
+ /**
933
+ * Classify a recorded worktree whose Project repository can no longer be
934
+ * inspected. A deleted external checkout takes the worktree's common dir with
935
+ * it, so the worktree's Git identity, HEAD, index, and dirty/untracked state
936
+ * cannot be proven. An extant directory is retained and the caller fails
937
+ * closed with a bounded manual-cleanup diagnosis; only a directory that is
938
+ * also absent is treated as missing. The exact recorded identity is
939
+ * re-validated so a mismatched path/branch is never classified.
940
+ */
941
+ async function recordedWorktreeWithoutRepository(input, cause) {
942
+ if (await pathKind(input.repositoryPath) !== undefined)
943
+ throw cause;
944
+ const identity = worktreeIdentity(input.taskSegment, input.roleName);
945
+ const expectedPath = managedPath(resolve(input.container), identity.directory);
946
+ if (resolve(input.path) !== expectedPath || input.branch !== identity.branch) {
947
+ throw cause;
948
+ }
949
+ if (await pathKind(expectedPath) === undefined)
950
+ return "missing";
951
+ throw new Error(`Recorded worktree survives its Project repository and needs manual cleanup: ${expectedPath}. `
952
+ + `The Project repository is gone, so the worktree's Git state cannot be verified; `
953
+ + `remove the directory manually once its contents are safe.`);
954
+ }
779
955
  async function retainCommitRef(repositoryPath, ref, commit) {
780
956
  const target = safeRef(ref);
781
957
  if (await gitSucceeds([