@zq-silk/yui 0.15.12 → 0.16.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 (236) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/ARCHITECTURE.zh-CN.md +3 -2
  3. package/dist/artifacts/artifactCommitLock.js +16 -55
  4. package/dist/artifacts/taskArtifactRepository.js +3 -3
  5. package/dist/cli/agentConfigurationPicker.js +1 -1
  6. package/dist/cli/commandCatalog.js +57 -41
  7. package/dist/cli/completion.js +18 -18
  8. package/dist/cli/completionWizard.js +1 -1
  9. package/dist/cli/dynamicCompletion.js +1 -1
  10. package/dist/cli/interactionPolicy.js +7 -3
  11. package/dist/cli/invocationAuthority.js +64 -0
  12. package/dist/cli/managedDiagnostics.js +1 -1
  13. package/dist/cli/parseRepeatable.js +35 -0
  14. package/dist/cli/updatePorts.js +26 -5
  15. package/dist/cli.js +1280 -1243
  16. package/dist/commands/agentCommands.js +4 -4
  17. package/dist/commands/capabilityCommands.js +1 -1
  18. package/dist/commands/configCommands.js +6 -57
  19. package/dist/commands/configOverview.js +2 -2
  20. package/dist/commands/durableJobCommands.js +12 -6
  21. package/dist/commands/globalRoleCommands.js +53 -29
  22. package/dist/commands/grantCommands.js +0 -3
  23. package/dist/commands/jobCommands.js +1 -4
  24. package/dist/commands/operatorCommands.js +3 -3
  25. package/dist/commands/projectCommands.js +1 -10
  26. package/dist/commands/resourcesCommands.js +10 -40
  27. package/dist/commands/roleConfiguration.js +2 -6
  28. package/dist/commands/taskActivationCommands.js +4 -8
  29. package/dist/commands/taskAgentCapabilities.js +9 -0
  30. package/dist/commands/taskCommandSupport.js +155 -0
  31. package/dist/commands/taskCommandTypes.js +1 -0
  32. package/dist/commands/taskCommands.js +271 -710
  33. package/dist/commands/taskCompletionGate.js +1 -1
  34. package/dist/commands/taskExecutionCommands.js +1 -1
  35. package/dist/commands/taskFactCommands.js +325 -0
  36. package/dist/commands/taskInputCommands.js +12 -42
  37. package/dist/commands/taskIntegrationCommands.js +17 -57
  38. package/dist/commands/taskNextActionCommand.js +7 -6
  39. package/dist/commands/taskPublicationAdoptCommand.js +3 -3
  40. package/dist/commands/taskPublicationCommands.js +1 -1
  41. package/dist/commands/taskPublicationVerifyCommand.js +1 -1
  42. package/dist/commands/taskRemoteDeliveryCommand.js +2 -94
  43. package/dist/commands/taskUpstreamCommands.js +19 -8
  44. package/dist/commands/workflowCommands.js +6 -1
  45. package/dist/completion/completionInstaller.js +26 -26
  46. package/dist/completion/completionState.js +26 -26
  47. package/dist/completion/fileCompletionManager.js +6 -11
  48. package/dist/config/configCatalog.js +0 -2
  49. package/dist/config/timeZone.js +14 -0
  50. package/dist/config/yuiConfig.js +0 -29
  51. package/dist/context/runContextPack.js +2 -3
  52. package/dist/context/taskCatalog.js +3 -5
  53. package/dist/context/taskContext.js +139 -151
  54. package/dist/context/wakeRunReferences.js +11 -0
  55. package/dist/controller/agentCapabilities.js +58 -0
  56. package/dist/controller/agentRuntimeObserver.js +6 -11
  57. package/dist/controller/clientRuntime.js +22 -14
  58. package/dist/controller/controller.js +9 -22
  59. package/dist/controller/fileSchedulerStoreAdapter.js +117 -212
  60. package/dist/controller/jobClient.js +4 -4
  61. package/dist/controller/jobControl.js +60 -25
  62. package/dist/controller/jobSupervisor.js +4 -4
  63. package/dist/controller/providerRetryDelivery.js +7 -7
  64. package/dist/controller/runtime.js +51 -44
  65. package/dist/controller/runtimeEventProcessor.js +0 -5
  66. package/dist/controller/sessionOwnerReconciliation.js +5 -0
  67. package/dist/controller/taskAgentError.js +35 -0
  68. package/dist/coordination/workMailboxQueue.js +2 -24
  69. package/dist/core/controllerClient.js +2 -2
  70. package/dist/core/fileLockOwner.js +74 -0
  71. package/dist/decision/decision.js +17 -0
  72. package/dist/doctor/doctor.js +18 -9
  73. package/dist/event/taskEvent.js +12 -0
  74. package/dist/execution/workItemExecutionProjection.js +2 -2
  75. package/dist/executor/agentCapabilityConfig.js +43 -0
  76. package/dist/executor/agentConfigurationCatalog.js +49 -11
  77. package/dist/executor/agentExecutor.js +5 -8
  78. package/dist/executor/fileRoleLaunchPlanner.js +11 -53
  79. package/dist/executor/taskAgentCapabilities.js +64 -0
  80. package/dist/integration/deliveryObligation.js +1 -69
  81. package/dist/integration/gitIntegrationService.js +60 -84
  82. package/dist/integration/integrationAttempt.js +3 -14
  83. package/dist/integration/integrationSourceApplication.js +4 -12
  84. package/dist/integration/manifestTags.js +2 -2
  85. package/dist/job/durableJob.js +4 -25
  86. package/dist/job/jobAssignmentScope.js +22 -0
  87. package/dist/job/jobOperation.js +16 -0
  88. package/dist/job/jobRunner.js +2 -1
  89. package/dist/job/stepDirectory.js +15 -0
  90. package/dist/kernel/builtinCapabilities.js +15 -11
  91. package/dist/kernel/kernelPorts.js +1 -17
  92. package/dist/lifecycle/exactRunTerminalization.js +1 -9
  93. package/dist/message/inputControlResolution.js +3 -4
  94. package/dist/message/message.js +17 -20
  95. package/dist/message/messageContinuation.js +1 -1
  96. package/dist/milestone/milestone.js +11 -0
  97. package/dist/observability/orchestrationMetrics.js +24 -1
  98. package/dist/observability/runtimeIdentity.js +1 -17
  99. package/dist/output/agentConfigurationPresentation.js +2 -0
  100. package/dist/output/timePresentation.js +1 -14
  101. package/dist/plugins/pluginService.js +12 -4
  102. package/dist/release/releaseWorkflowPorts.js +45 -96
  103. package/dist/release/runtimeRelease.js +9 -15
  104. package/dist/repository/projectMaintenanceLock.js +23 -64
  105. package/dist/repository/taskBaseFreshness.js +1 -1
  106. package/dist/repository/taskWorkspaceCoordinator.js +13 -22
  107. package/dist/repository/taskWorkspacePreparer.js +29 -25
  108. package/dist/repository/workspaceCleanupInspection.js +5 -5
  109. package/dist/resources/autoResourceGc.js +4 -77
  110. package/dist/resources/liveReferences.js +7 -54
  111. package/dist/resources/resourceDiscovery.js +5 -118
  112. package/dist/resources/resourceGc.js +233 -181
  113. package/dist/resources/resourceRegistrar.js +5 -4
  114. package/dist/resources/resourceRegistry.js +3 -37
  115. package/dist/resources/resourceRegistryStore.js +13 -36
  116. package/dist/resources/sqliteResourceRegistry.js +43 -26
  117. package/dist/review/deltaRecheck.js +1 -1
  118. package/dist/review/reviewAcceptance.js +1 -1
  119. package/dist/review/reviewDecision.js +1 -1
  120. package/dist/review/reviewRound.js +8 -7
  121. package/dist/review/taskFinalReviewContractResolution.js +1 -1
  122. package/dist/runtime/acpProtocol.js +4 -51
  123. package/dist/runtime/acpSession.js +10 -75
  124. package/dist/runtime/acpSessionConfiguration.js +1 -8
  125. package/dist/runtime/agentEndpoint.js +3 -2
  126. package/dist/runtime/agentFailureContext.js +43 -0
  127. package/dist/runtime/agentHost.js +15 -15
  128. package/dist/runtime/codexInteractiveHost.js +2 -2
  129. package/dist/runtime/index.js +0 -1
  130. package/dist/runtime/managedCaller.js +21 -1
  131. package/dist/runtime/providerErrors.js +38 -0
  132. package/dist/runtime/runtimeObservation.js +0 -5
  133. package/dist/runtime/runtimeSessionCandidate.js +2 -3
  134. package/dist/runtime/sessionOwnerIdentity.js +1 -1
  135. package/dist/runtime/structuredProviderHost.js +4 -41
  136. package/dist/runtime/tmuxAdapters.js +6 -8
  137. package/dist/scheduler/activeRoleRunDelivery.js +7 -7
  138. package/dist/scheduler/leaderWakeupProcessor.js +19 -6
  139. package/dist/scheduler/operatorEvent.js +12 -20
  140. package/dist/scheduler/operatorInputNotificationProcessor.js +1 -1
  141. package/dist/scheduler/ports.js +5 -8
  142. package/dist/scheduler/roleRunLiveness.js +4 -4
  143. package/dist/scheduler/roleRunStall.js +16 -23
  144. package/dist/scheduler/taskExecutionProjection.js +25 -33
  145. package/dist/scheduler/taskObservabilityProjection.js +0 -1
  146. package/dist/scheduler/taskWake.js +5 -10
  147. package/dist/scheduler/wakeReason.js +2 -0
  148. package/dist/scheduler/wakeupQueue.js +2 -4
  149. package/dist/setup/setupCommand.js +1 -1
  150. package/dist/storage/contextRecords.js +106 -0
  151. package/dist/storage/currentTaskStore.js +1 -1
  152. package/dist/storage/homeLayout.js +13 -17
  153. package/dist/storage/migrations/agentFailureContext.js +22 -0
  154. package/dist/storage/migrations/currentInputContract.js +86 -0
  155. package/dist/storage/migrations/currentRuntimeContract.js +228 -0
  156. package/dist/storage/migrations/historicalVerificationPlan.js +35 -0
  157. package/dist/storage/migrations/integrationContinuation.js +5 -4
  158. package/dist/storage/migrations/narrowAgentFailureContext.js +65 -0
  159. package/dist/storage/migrations/notificationOnlyWakes.js +74 -0
  160. package/dist/storage/migrations/verificationPlanV1.js +162 -0
  161. package/dist/storage/migrations/verificationPolicy.js +74 -0
  162. package/dist/storage/migrations/workItemHistory.js +46 -0
  163. package/dist/storage/persistenceWorker.js +12 -4
  164. package/dist/storage/recordValidation.js +87 -0
  165. package/dist/storage/sqliteSchema.js +142 -1
  166. package/dist/storage/sqliteStore.js +156 -162
  167. package/dist/storage/storageSchema.js +2 -14
  168. package/dist/storage/storageVersions.js +1 -1
  169. package/dist/storage/storeRpc.js +15 -7
  170. package/dist/storage/taskStore.js +3 -7
  171. package/dist/storage/upgrade/upgradeOrchestrator.js +25 -37
  172. package/dist/task/completionReadiness.js +5 -24
  173. package/dist/task/draftPlan.js +0 -53
  174. package/dist/task/nextAction.js +7 -13
  175. package/dist/task/remoteDeliveryService.js +89 -0
  176. package/dist/task/taskActivation.js +2 -25
  177. package/dist/task/taskActivationService.js +0 -2
  178. package/dist/task/taskRecordReference.js +0 -1
  179. package/dist/task/taskSubmission.js +3 -14
  180. package/dist/telemetry/sqliteTelemetryBatch.js +29 -0
  181. package/dist/telemetry/sqliteTelemetryStore.js +49 -49
  182. package/dist/telemetry/telemetryWiring.js +4 -3
  183. package/dist/tmux/tmuxManager.js +29 -20
  184. package/dist/verification/gateArtifact.js +15 -24
  185. package/dist/verification/gateArtifactStore.js +14 -7
  186. package/dist/verification/verificationGateService.js +29 -88
  187. package/dist/verification/verificationPlan.js +27 -72
  188. package/dist/web/assets/client/app.js +1 -1
  189. package/dist/web/assets/client/taskSurface.js +4 -4
  190. package/dist/web/webServer.js +4 -6
  191. package/dist/web/webSnapshot.js +3 -83
  192. package/dist/web/webTaskSurface.js +17 -46
  193. package/dist/workItem/workItem.js +1 -12
  194. package/docs/architecture/capabilities-and-resources.md +14 -2
  195. package/docs/architecture/capabilities-and-resources.zh-CN.md +11 -1
  196. package/docs/managed-turn-and-session-runtime.md +83 -10
  197. package/docs/managed-turn-and-session-runtime.zh-CN.md +65 -11
  198. package/docs/plugin-sdk.md +4 -2
  199. package/docs/provider-runtime.md +3 -1
  200. package/docs/provider-runtime.zh-CN.md +4 -2
  201. package/docs/release-workflow.md +190 -7
  202. package/docs/release-workflow.zh-CN.md +139 -5
  203. package/docs/roles-and-configuration.md +29 -0
  204. package/docs/roles-and-configuration.zh-CN.md +21 -0
  205. package/docs/sqlite-control-plane-design.md +16 -3
  206. package/docs/sqlite-control-plane-design.zh-CN.md +13 -2
  207. package/docs/task-dag-semantics.md +3 -3
  208. package/docs/task-dag-semantics.zh-CN.md +2 -2
  209. package/docs/task-delivery.md +91 -5
  210. package/docs/task-delivery.zh-CN.md +72 -4
  211. package/docs/task-discovery.md +10 -11
  212. package/docs/task-discovery.zh-CN.md +8 -9
  213. package/docs/testing/verification-levels.md +145 -9
  214. package/docs/testing/verification-levels.zh-CN.md +103 -8
  215. package/package.json +1 -1
  216. package/skills/yui-leader/SKILL.md +5 -0
  217. package/skills/yui-leader/references/integration.md +9 -5
  218. package/skills/yui-leader/references/planning.md +9 -3
  219. package/skills/yui-operator/SKILL.md +15 -10
  220. package/skills/yui-runtime/SKILL.md +6 -6
  221. package/skills/yui-runtime/references/recovery.md +47 -0
  222. package/dist/agent/agentRegistry.js +0 -10
  223. package/dist/agentRun/runIdentity.js +0 -22
  224. package/dist/commands/deliveryGuardPreflight.js +0 -30
  225. package/dist/commands/taskIntegrationQueueCommands.js +0 -226
  226. package/dist/commands/taskOverviewCommand.js +0 -377
  227. package/dist/completion/completionWizard.js +0 -125
  228. package/dist/context/dispatchContext.js +0 -110
  229. package/dist/context/wakeNotification.js +0 -144
  230. package/dist/integration/integrationCheckEvidenceReuse.js +0 -53
  231. package/dist/integration/integrationQueueEntry.js +0 -191
  232. package/dist/integration/integrationQueueService.js +0 -810
  233. package/dist/release/fakeReleasePorts.js +0 -55
  234. package/dist/runtime/sessionOwnerRegistry.js +0 -137
  235. package/dist/task/deliveryGuard.js +0 -226
  236. /package/dist/{commands/taskActor.js → task/taskAuthority.js} +0 -0
@@ -9,24 +9,70 @@
9
9
  * Safety model:
10
10
  * - `planResourceGc` is strictly read-only: it discovers, scans, classifies,
11
11
  * and returns a plan. It never writes the registry or moves files.
12
- * - `applyResourceGc` re-discovers and re-scans live references immediately
13
- * before each mutation, so a stale plan can never release a resource that
14
- * gained a reference after planning.
12
+ * - `applyResourceGc` refreshes discovery and scans each physical subtree.
13
+ * Durable owners are re-read under SQLite's writer fence through the move.
15
14
  * - `purgeResourceQuarantine` scans both the original and quarantine paths;
16
15
  * a file held open inside quarantine triggers a restore instead of deletion.
17
16
  * - `restoreAllResourceGc` is the explicit rollback entry point.
18
17
  */
19
- import { execFile, execFileSync } from "node:child_process";
18
+ import { execFileSync } from "node:child_process";
20
19
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
21
- import { dirname, join, resolve } from "node:path";
22
- import { promisify } from "node:util";
23
- import { removeResourceRecord, isResourceQuarantinePath, resourceQuarantineRoot, upsertResourceRecord } from "./resourceRegistry.js";
24
- import { createResourceRegistryStore } from "./resourceRegistryStore.js";
25
- import { isReleasable, isTerminalTaskStatus } from "./resourceTypes.js";
26
- import { scanLiveReferences } from "./liveReferences.js";
20
+ import { dirname, join, resolve, relative, isAbsolute } from "node:path";
21
+ import { isDeepStrictEqual } from "node:util";
22
+ import { scanLiveReferences, readSessionOwnerClaims } from "./liveReferences.js";
27
23
  import { discoverResources } from "./resourceDiscovery.js";
28
- const executeFile = promisify(execFile);
24
+ import { isResourceQuarantinePath, removeResourceRecord, resourceQuarantineRoot, upsertResourceRecord } from "./resourceRegistry.js";
25
+ import { withResourceRegistry } from "./resourceRegistryStore.js";
26
+ import { isReleasable, isTerminalTaskStatus } from "./resourceTypes.js";
29
27
  export const DEFAULT_QUARANTINE_TTL_HOURS = 24;
28
+ /** One current snapshot shared by manual and automatic GC. The execution
29
+ * boundary reads it again while holding the database's existing writer fence. */
30
+ export function readResourceGcState(store) {
31
+ return store.readTransaction(reader => {
32
+ const tasks = reader.listTasks();
33
+ return {
34
+ projects: reader.listProjects(),
35
+ sessionOwners: reader.listSessionOwners(),
36
+ managedWorkspaces: tasks.flatMap(task => reader.listManagedWorkspaces(task.id)),
37
+ taskStatusById: new Map(tasks.map(task => [task.id, task.status])),
38
+ activeWorkspaceOwnerPaths: tasks.flatMap(task => [
39
+ ...reader.listRuns(task.id).filter(run => run.status === "active").flatMap(run => {
40
+ const workspace = run.workspace ?? run.effective.workspace;
41
+ return [workspace.root, ...workspace.entries.map(entry => entry.path)];
42
+ }),
43
+ ...reader.listDurableJobs(task.id).filter(job => job.status === "queued" || job.status === "running"
44
+ || (job.status === "unknown-needs-attention" && job.acknowledgedAt === undefined))
45
+ .map(job => job.workspace)
46
+ ])
47
+ };
48
+ });
49
+ }
50
+ function within(path, parent) {
51
+ const child = relative(resolve(parent), resolve(path));
52
+ return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith("../"));
53
+ }
54
+ function currentDurableRefs(record, state) {
55
+ const refs = [];
56
+ if (record.owner.taskId === undefined || !state.taskStatusById.has(record.owner.taskId)) {
57
+ refs.push("owner-unproven");
58
+ }
59
+ else if (!isTerminalTaskStatus(state.taskStatusById.get(record.owner.taskId))) {
60
+ refs.push("owner-not-terminal");
61
+ }
62
+ const paths = [
63
+ ...state.managedWorkspaces.flatMap(workspace => [workspace.root, ...workspace.entries.map(entry => entry.path)]),
64
+ ...state.activeWorkspaceOwnerPaths
65
+ ];
66
+ if (paths.some(path => within(path, record.path) || within(record.path, path)))
67
+ refs.push("durable-owner");
68
+ const custody = readSessionOwnerClaims(state.sessionOwners);
69
+ if (custody.diagnostics.some(diagnostic => diagnostic.severity === "error"))
70
+ refs.push("session-owner-unproven");
71
+ if (custody.claims.some(claim => within(claim.path, record.path) || within(record.path, claim.path))) {
72
+ refs.push("live-session-owner");
73
+ }
74
+ return refs;
75
+ }
30
76
  /**
31
77
  * Plan a GC pass: discover resources, scan live references, and classify each
32
78
  * record. Planning is strictly read-only — it never writes the registry,
@@ -34,7 +80,7 @@ export const DEFAULT_QUARANTINE_TTL_HOURS = 24;
34
80
  */
35
81
  export async function planResourceGc(input) {
36
82
  const home = resolve(input.home);
37
- const registryStore = input.registryStore ?? createResourceRegistryStore(home);
83
+ const registry = withResourceRegistry(home, input.registryStore, store => store.load());
38
84
  const discovered = await discoverResources({
39
85
  home,
40
86
  projects: input.projects,
@@ -45,10 +91,10 @@ export async function planResourceGc(input) {
45
91
  const paths = discovered.map(({ record }) => record.path);
46
92
  // Also scan original paths of quarantined records so a new reference is
47
93
  // visible in the plan (restore itself happens in apply/purge/restore-all).
48
- const registry = registryStore.load();
49
94
  appendRegistryScanPaths(registry, paths, home);
50
95
  const scan = await scanLiveReferences({
51
96
  home,
97
+ sessionOwners: input.sessionOwners,
52
98
  paths,
53
99
  environment: input.environment,
54
100
  tmuxServerName: input.tmuxServerName,
@@ -211,11 +257,11 @@ function dirtyWorktreeSuggestion(record) {
211
257
  * Apply a GC plan: quarantine every releasable resource. In `report` mode this
212
258
  * is a shadow pass — the plan is returned unchanged and nothing is mutated.
213
259
  *
214
- * Before each mutation the engine re-discovers and re-scans live references,
215
- * so a plan that went stale after generation cannot release a resource that
216
- * gained a reference.
260
+ * Physical discovery is refreshed, each subtree gets a live-reference scan,
261
+ * and current durable ownership is checked under the existing writer fence.
262
+ * No registry connection is held across an asynchronous scan.
217
263
  */
218
- export async function applyResourceGc(input, plan) {
264
+ export async function applyResourceGc(input, plan, store) {
219
265
  if (plan.mode === "report") {
220
266
  return Object.freeze({
221
267
  planned: plan,
@@ -226,95 +272,113 @@ export async function applyResourceGc(input, plan) {
226
272
  });
227
273
  }
228
274
  const home = resolve(input.home);
275
+ if (resolve(store.rootDirectory()) !== home || resolve(plan.home) !== home) {
276
+ throw new Error("GC plan, ownership Store and resource Home must match.");
277
+ }
229
278
  const now = input.now;
230
- const registryStore = input.registryStore ?? createResourceRegistryStore(home);
231
- let registry = registryStore.load();
232
279
  const applied = [];
233
280
  const failed = [];
234
281
  const restored = [];
235
- // Re-discover and re-scan with fresh state so the plan is validated against
236
- // the world as it exists right now, not when the plan was generated.
237
- const discovered = await discoverResources({
238
- home,
239
- projects: input.projects,
240
- managedWorkspaces: input.managedWorkspaces,
241
- taskStatusById: input.taskStatusById,
242
- now
243
- });
244
- const freshPaths = discovered.map(({ record }) => record.path);
245
- appendRegistryScanPaths(registry, freshPaths, home);
246
- const freshScan = await scanLiveReferences({
247
- home,
248
- paths: freshPaths,
249
- environment: input.environment,
250
- tmuxServerName: input.tmuxServerName,
251
- ports: {
252
- ...input.liveReferencePorts,
253
- managedWorkspaces: () => input.managedWorkspaces,
254
- activeWorkspaceOwners: () => collectActiveWorkspaceOwners(input)
255
- }
282
+ const fresh = await planResourceGc({ ...input, ...readResourceGcState(store) });
283
+ const freshById = new Map(fresh.records.map(record => [record.id, record]));
284
+ const retained = new Map(fresh.retained.map(record => [record.id, record]));
285
+ const retain = (record, reason) => retained.set(record.id, {
286
+ ...record, disposition: "retained-unproven", blocker: reason, updatedAt: now.toISOString()
256
287
  });
257
- const freshClassified = classifyDiscoveredResources(discovered, registry, freshScan, input, home);
258
- const freshById = new Map(freshClassified.map(({ record }) => [record.id, record]));
288
+ const inspectedRegistry = withResourceRegistry(home, input.registryStore, registry => registry.load());
289
+ const movedRoots = [];
259
290
  // Restore quarantined records whose owner is no longer terminal or that
260
291
  // gained a live reference. This is the apply-time counterpart of the old
261
292
  // planning-time restore: planning is read-only, so restore happens here.
262
- for (const record of Object.values(registry.records)) {
293
+ for (const record of Object.values(inspectedRegistry.records)) {
263
294
  if (record.disposition !== "quarantined" || record.quarantine === undefined)
264
295
  continue;
265
- const ownerTaskId = record.owner.taskId;
266
- const ownerNoLongerTerminal = ownerTaskId !== undefined
267
- && input.taskStatusById.has(ownerTaskId)
268
- && !isTerminalTaskStatus(input.taskStatusById.get(ownerTaskId));
269
- const originalRefs = freshScan.refsByPath.get(record.quarantine.originalPath) ?? [];
270
- if (ownerNoLongerTerminal || originalRefs.length > 0) {
271
- const restoredRecord = restoreQuarantinedRecord(record, originalRefs, now);
272
- registry = upsertResourceRecord(registry, restoredRecord);
273
- if (restoredRecord.disposition === "active") {
274
- restored.push(restoredRecord);
275
- }
276
- else {
277
- failed.push(restoredRecord);
278
- }
279
- }
296
+ withResourceRegistry(home, input.registryStore, registry => registry.transaction(() => {
297
+ const before = registry.load();
298
+ if (!isDeepStrictEqual(before.records[record.id], record))
299
+ throw new Error("Quarantine ownership changed; inspect and retry.");
300
+ const refs = [...(fresh.scan.refsByPath.get(record.quarantine.originalPath) ?? []),
301
+ ...currentDurableRefs(record, readResourceGcState(store)).filter(ref => ref !== "owner-unproven")];
302
+ if (refs.length === 0)
303
+ return;
304
+ const result = restoreQuarantinedRecord(record, refs, now);
305
+ registry.save(upsertResourceRecord(before, result), before);
306
+ (result.disposition === "active" ? restored : failed).push(result);
307
+ }));
280
308
  }
281
- for (const planned of plan.records) {
309
+ // A physical parent owns one move. Its unreferenced runtime descendants are
310
+ // redundant registry receipts, not separately movable resources.
311
+ for (const planned of [...plan.records].sort((a, b) => a.path.length - b.path.length)) {
282
312
  if (!isReleasable(planned))
283
313
  continue;
284
- // Re-validate against fresh state: the resource must still exist, still
285
- // be clean, and still have zero live references.
286
- const fresh = freshById.get(planned.id);
287
- if (fresh === undefined) {
288
- // Resource disappeared since planning; reconcile the stale record.
289
- registry = removeResourceRecord(registry, planned.id);
314
+ if (movedRoots.some(root => within(planned.path, root)))
290
315
  continue;
291
- }
292
- if (!isReleasable(fresh)) {
293
- // Fresh liveness, cleanliness, ownership, or diagnostic evidence changed
294
- // since planning: retain the resource and reconcile its registry record.
295
- registry = upsertResourceRecord(registry, fresh);
316
+ const candidate = freshById.get(planned.id);
317
+ if (!candidate || !isReleasable(candidate))
318
+ continue;
319
+ const descendants = fresh.records.filter(record => record.id !== candidate.id
320
+ && within(record.path, candidate.path) && existsSync(record.path));
321
+ // Do not move a directory around independent Git worktrees or retained
322
+ // descendants. Their own owner must release them first.
323
+ if (descendants.some(record => record.kind !== "runtime-artifact" || !isReleasable(record))) {
324
+ retain(candidate, "Directory contains independently owned or retained resources.");
296
325
  continue;
297
326
  }
298
- const result = await quarantineResource(home, fresh, now);
299
- registry = upsertResourceRecord(registry, result.record);
300
- if (result.ok) {
301
- applied.push(result.record);
302
- }
303
- else {
304
- failed.push(result.record);
327
+ const scan = await scanLiveReferences({
328
+ home, sessionOwners: store.listSessionOwners(), paths: [candidate.path, ...descendants.map(record => record.path)],
329
+ environment: input.environment, tmuxServerName: input.tmuxServerName,
330
+ ports: input.liveReferencePorts
331
+ });
332
+ if (scan.diagnostics.some(d => d.severity === "error")
333
+ || [...scan.refsByPath.values()].some(refs => refs.length > 0)) {
334
+ retain(candidate, "Current physical references prevent this subtree move.");
335
+ continue;
305
336
  }
337
+ withResourceRegistry(home, input.registryStore, registry => registry.transaction(() => {
338
+ const before = registry.load();
339
+ const subtree = Object.values(before.records).filter(record => within(record.path, candidate.path)
340
+ && existsSync(record.path));
341
+ for (const record of [candidate, ...subtree]) {
342
+ if (!isDeepStrictEqual(before.records[record.id], inspectedRegistry.records[record.id])) {
343
+ throw new Error(`Resource registry ownership changed: ${record.id}; inspect and retry.`);
344
+ }
345
+ }
346
+ const state = readResourceGcState(store);
347
+ if ([candidate, ...subtree].some(record => currentDurableRefs(record, state).length > 0)) {
348
+ retain(candidate, "Current Task or workspace ownership prevents this subtree move.");
349
+ return;
350
+ }
351
+ if (!existsSync(candidate.path))
352
+ return;
353
+ const result = quarantineResource(home, candidate, now);
354
+ let next = upsertResourceRecord(before, result.record);
355
+ if (result.ok) {
356
+ for (const record of subtree) {
357
+ if (record.id !== candidate.id)
358
+ next = removeResourceRecord(next, record.id);
359
+ }
360
+ movedRoots.push(candidate.path);
361
+ applied.push(result.record);
362
+ }
363
+ else
364
+ failed.push(result.record);
365
+ registry.save(next, before);
366
+ }));
306
367
  }
307
- registryStore.save(registry);
308
- registryStore.close();
309
368
  return Object.freeze({
310
- planned: plan,
369
+ planned: {
370
+ ...fresh,
371
+ records: fresh.records.map(record => retained.get(record.id) ?? record),
372
+ retained: [...retained.values()],
373
+ releasable: fresh.releasable.filter(record => !retained.has(record.id))
374
+ },
311
375
  applied: Object.freeze(applied),
312
376
  failed: Object.freeze(failed),
313
377
  restored: Object.freeze(restored),
314
378
  purged: Object.freeze([])
315
379
  });
316
380
  }
317
- async function quarantineResource(home, record, now) {
381
+ function quarantineResource(home, record, now) {
318
382
  const quarantineRoot = resourceQuarantineRoot(home);
319
383
  const quarantinePath = join(quarantineRoot, record.id);
320
384
  const receiptPath = `${quarantinePath}.receipt.json`;
@@ -324,7 +388,9 @@ async function quarantineResource(home, record, now) {
324
388
  // metadata, into the Home-local quarantine. Restore is the inverse
325
389
  // move, so the recorded HEAD cannot drift with a branch.
326
390
  mkdirSync(quarantineRoot, { recursive: true, mode: 0o700 });
327
- await executeFile("git", ["-C", record.path, "worktree", "move", "--", record.path, quarantinePath], { timeout: 30_000 });
391
+ // Keep the existing SQLite writer fence through this bounded move, so a
392
+ // Task reopen or new durable owner cannot commit between check and move.
393
+ execFileSync("git", ["-C", record.path, "worktree", "move", "--", record.path, quarantinePath], { timeout: 30_000 });
328
394
  writeQuarantineReceipt(home, record, now, "move", receiptPath);
329
395
  return {
330
396
  ok: true,
@@ -379,7 +445,7 @@ async function quarantineResource(home, record, now) {
379
445
  };
380
446
  }
381
447
  }
382
- function writeQuarantineReceipt(home, record, now, method, receiptPath) {
448
+ function writeQuarantineReceipt(_home, record, now, method, receiptPath) {
383
449
  try {
384
450
  mkdirSync(dirname(receiptPath), { recursive: true, mode: 0o700 });
385
451
  writeFileSync(receiptPath, `${JSON.stringify({
@@ -403,58 +469,39 @@ function isGitWorktreePath(path) {
403
469
  /**
404
470
  * Restore a quarantined resource to its original path. Move-based Git
405
471
  * quarantine is reversed with `git worktree move`, preserving the exact
406
- * checkout. Legacy remove-based receipts rebuild a detached worktree at the
407
- * recorded HEAD; a branch tip is never used as a substitute.
472
+ * checkout. A different recorded method remains audit evidence and requires
473
+ * separate recovery; it is never reinterpreted as a move or a fresh checkout.
408
474
  */
409
475
  function restoreQuarantinedRecord(record, liveRefs, now) {
410
476
  const quarantine = record.quarantine;
411
477
  if (quarantine === undefined)
412
478
  return record;
413
479
  try {
414
- if (quarantine.method === "git-worktree-remove" && quarantine.gitRestore !== undefined) {
415
- const git = quarantine.gitRestore;
416
- if (existsSync(quarantine.originalPath)) {
417
- return {
418
- ...record,
419
- disposition: "cleanup-failed",
420
- blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
421
- updatedAt: now.toISOString()
422
- };
423
- }
424
- mkdirSync(dirname(quarantine.originalPath), { recursive: true });
425
- if (git.head !== undefined) {
426
- execFileSync("git", ["-C", git.repositoryPath, "worktree", "add", "--detach", "--", quarantine.originalPath, git.head]);
427
- }
428
- else {
429
- throw new Error("no head recorded for Git worktree restore");
430
- }
480
+ if (quarantine.method !== "move" || !["worktree", "runtime-artifact"].includes(record.kind)) {
481
+ throw new Error(`Unsupported quarantine provenance: ${record.kind}/${quarantine.method}; preserve its evidence for explicit recovery.`);
482
+ }
483
+ if (!existsSync(quarantine.path)) {
484
+ return {
485
+ ...record,
486
+ disposition: "cleanup-failed",
487
+ blocker: `Restore failed: quarantine path missing: ${quarantine.path}`,
488
+ updatedAt: now.toISOString()
489
+ };
490
+ }
491
+ if (existsSync(quarantine.originalPath)) {
492
+ return {
493
+ ...record,
494
+ disposition: "cleanup-failed",
495
+ blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
496
+ updatedAt: now.toISOString()
497
+ };
498
+ }
499
+ mkdirSync(dirname(quarantine.originalPath), { recursive: true });
500
+ if (isGitWorktreePath(quarantine.path)) {
501
+ execFileSync("git", ["-C", quarantine.path, "worktree", "move", "--", quarantine.path, quarantine.originalPath], { timeout: 30_000 });
431
502
  }
432
503
  else {
433
- // Move-based restore: rename the quarantine directory back.
434
- if (!existsSync(quarantine.path)) {
435
- return {
436
- ...record,
437
- disposition: "cleanup-failed",
438
- blocker: `Restore failed: quarantine path missing: ${quarantine.path}`,
439
- updatedAt: now.toISOString()
440
- };
441
- }
442
- if (existsSync(quarantine.originalPath)) {
443
- return {
444
- ...record,
445
- disposition: "cleanup-failed",
446
- blocker: `Restore failed: original path already exists: ${quarantine.originalPath}`,
447
- updatedAt: now.toISOString()
448
- };
449
- }
450
- if (isGitWorktreePath(quarantine.path)) {
451
- mkdirSync(dirname(quarantine.originalPath), { recursive: true });
452
- execFileSync("git", ["-C", quarantine.path, "worktree", "move", "--", quarantine.path, quarantine.originalPath], { timeout: 30_000 });
453
- }
454
- else {
455
- mkdirSync(dirname(quarantine.originalPath), { recursive: true });
456
- renameSync(quarantine.path, quarantine.originalPath);
457
- }
504
+ renameSync(quarantine.path, quarantine.originalPath);
458
505
  }
459
506
  }
460
507
  catch (error) {
@@ -479,12 +526,13 @@ function restoreQuarantinedRecord(record, liveRefs, now) {
479
526
  * elapsed. A resource with a new live reference — on either the original or
480
527
  * the quarantine path — is restored instead.
481
528
  */
482
- export async function purgeResourceQuarantine(home, options) {
529
+ export async function purgeResourceQuarantine(home, options, store) {
483
530
  const resolvedHome = resolve(home);
531
+ if (resolve(store.rootDirectory()) !== resolvedHome)
532
+ throw new Error("GC ownership Store belongs to another Home.");
484
533
  const ttlHours = options.ttlHours ?? DEFAULT_QUARANTINE_TTL_HOURS;
485
534
  const ttlMs = ttlHours * 3_600_000;
486
- const registryStore = createResourceRegistryStore(resolvedHome);
487
- const registry = registryStore.load();
535
+ const registry = withResourceRegistry(resolvedHome, undefined, store => store.load());
488
536
  const quarantined = Object.values(registry.records)
489
537
  .filter((record) => record.quarantine !== undefined
490
538
  && (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
@@ -499,6 +547,7 @@ export async function purgeResourceQuarantine(home, options) {
499
547
  }
500
548
  const scan = await scanLiveReferences({
501
549
  home: resolvedHome,
550
+ sessionOwners: store.listSessionOwners(),
502
551
  paths,
503
552
  environment: options.environment,
504
553
  tmuxServerName: options.tmuxServerName,
@@ -508,7 +557,6 @@ export async function purgeResourceQuarantine(home, options) {
508
557
  activeWorkspaceOwners: () => []
509
558
  }
510
559
  });
511
- let state = registry;
512
560
  const purged = [];
513
561
  const restored = [];
514
562
  const failed = [];
@@ -524,58 +572,64 @@ export async function purgeResourceQuarantine(home, options) {
524
572
  // resource quarantined instead of permanently deleting it.
525
573
  if (scanUntrusted)
526
574
  continue;
527
- const originalRefs = scan.refsByPath.get(quarantine.originalPath) ?? [];
528
- const quarantineRefs = scan.refsByPath.get(quarantine.path) ?? [];
529
- const allRefs = [...originalRefs, ...quarantineRefs];
530
- if (allRefs.length > 0) {
531
- const restoredRecord = restoreQuarantinedRecord(record, allRefs, options.now);
532
- state = upsertResourceRecord(state, restoredRecord);
533
- if (restoredRecord.disposition === "active") {
534
- restored.push(restoredRecord);
575
+ withResourceRegistry(resolvedHome, undefined, registryStore => registryStore.transaction(() => {
576
+ const before = registryStore.load();
577
+ if (!isDeepStrictEqual(before.records[record.id], record))
578
+ throw new Error("Quarantine ownership changed; inspect and retry.");
579
+ const save = (next) => registryStore.save(upsertResourceRecord(before, next), before);
580
+ if (quarantine.method !== "move" || !["worktree", "runtime-artifact"].includes(record.kind)) {
581
+ const retained = { ...record, disposition: "cleanup-failed",
582
+ blocker: `Unsupported quarantine provenance: ${record.kind}/${quarantine.method}; preserve its evidence for explicit recovery.`,
583
+ updatedAt: options.now.toISOString() };
584
+ save(retained);
585
+ failed.push(retained);
586
+ return;
535
587
  }
536
- else {
537
- failed.push(restoredRecord);
588
+ const state = readResourceGcState(store);
589
+ if (record.owner.taskId === undefined || !state.taskStatusById.has(record.owner.taskId)) {
590
+ save({ ...record, blocker: "Current Task ownership cannot be proven; quarantine retained.", updatedAt: options.now.toISOString() });
591
+ return;
538
592
  }
539
- continue;
540
- }
541
- try {
542
- if (existsSync(quarantine.path)) {
543
- if (isGitWorktreePath(quarantine.path)) {
544
- await executeFile("git", ["-C", quarantine.path, "worktree", "remove", "--force", "--", quarantine.path], { timeout: 30_000 });
593
+ const allRefs = [
594
+ ...(scan.refsByPath.get(quarantine.originalPath) ?? []),
595
+ ...(scan.refsByPath.get(quarantine.path) ?? []),
596
+ ...currentDurableRefs(record, state)
597
+ ];
598
+ if (allRefs.length > 0) {
599
+ const result = restoreQuarantinedRecord(record, allRefs, options.now);
600
+ save(result);
601
+ (result.disposition === "active" ? restored : failed).push(result);
602
+ return;
603
+ }
604
+ try {
605
+ if (existsSync(quarantine.path)) {
606
+ if (isGitWorktreePath(quarantine.path)) {
607
+ execFileSync("git", ["-C", quarantine.path, "worktree", "remove", "--force", "--", quarantine.path], { timeout: 30_000 });
608
+ }
609
+ rmSync(quarantine.path, { recursive: true, force: true });
545
610
  }
546
- rmSync(quarantine.path, { recursive: true, force: true });
611
+ const receiptPath = `${quarantine.path}.receipt.json`;
612
+ if (existsSync(receiptPath))
613
+ rmSync(receiptPath, { force: true });
614
+ const deleted = {
615
+ ...record, disposition: "deleted", quarantine: undefined,
616
+ cleanupReceipt: { removedAt: options.now.toISOString(), method: "quarantine-purge" },
617
+ updatedAt: options.now.toISOString()
618
+ };
619
+ save(deleted);
620
+ purged.push(deleted);
547
621
  }
548
- // Also remove the receipt sibling.
549
- const receiptPath = `${quarantine.path}.receipt.json`;
550
- if (existsSync(receiptPath)) {
551
- rmSync(receiptPath, { force: true });
622
+ catch (error) {
623
+ const failedRecord = {
624
+ ...record, disposition: "cleanup-failed",
625
+ blocker: `Purge failed: ${error instanceof Error ? error.message : "unknown error"}`,
626
+ updatedAt: options.now.toISOString()
627
+ };
628
+ save(failedRecord);
629
+ failed.push(failedRecord);
552
630
  }
553
- const deleted = {
554
- ...record,
555
- disposition: "deleted",
556
- quarantine: undefined,
557
- cleanupReceipt: {
558
- removedAt: options.now.toISOString(),
559
- method: "quarantine-purge"
560
- },
561
- updatedAt: options.now.toISOString()
562
- };
563
- state = upsertResourceRecord(state, deleted);
564
- purged.push(deleted);
565
- }
566
- catch (error) {
567
- const failedRecord = {
568
- ...record,
569
- disposition: "cleanup-failed",
570
- blocker: `Purge failed: ${error instanceof Error ? error.message : "unknown error"}`,
571
- updatedAt: options.now.toISOString()
572
- };
573
- state = upsertResourceRecord(state, failedRecord);
574
- failed.push(failedRecord);
575
- }
631
+ }));
576
632
  }
577
- registryStore.save(state);
578
- registryStore.close();
579
633
  return Object.freeze({
580
634
  planned: Object.freeze({
581
635
  home: resolvedHome,
@@ -601,8 +655,7 @@ export async function purgeResourceQuarantine(home, options) {
601
655
  */
602
656
  export async function restoreAllResourceGc(home, options) {
603
657
  const resolvedHome = resolve(home);
604
- const registryStore = createResourceRegistryStore(resolvedHome);
605
- const registry = registryStore.load();
658
+ const registry = withResourceRegistry(resolvedHome, undefined, store => store.load());
606
659
  const quarantined = Object.values(registry.records)
607
660
  .filter((record) => record.quarantine !== undefined
608
661
  && (record.disposition === "quarantined" || record.disposition === "cleanup-failed"));
@@ -619,8 +672,7 @@ export async function restoreAllResourceGc(home, options) {
619
672
  failed.push(restoredRecord);
620
673
  }
621
674
  }
622
- registryStore.save(state);
623
- registryStore.close();
675
+ withResourceRegistry(resolvedHome, undefined, store => store.save(state, registry));
624
676
  return Object.freeze({
625
677
  planned: Object.freeze({
626
678
  home: resolvedHome,
@@ -9,8 +9,8 @@
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { existsSync } from "node:fs";
11
11
  import { basename, dirname, resolve } from "node:path";
12
- import { createResourceRegistryStore } from "./resourceRegistryStore.js";
13
12
  import { upsertResourceRecord } from "./resourceRegistry.js";
13
+ import { createResourceRegistryStore } from "./resourceRegistryStore.js";
14
14
  import { createResourceRecord, isReleaseNamespacePath, resourceId } from "./resourceTypes.js";
15
15
  export class ResourceRegistrar {
16
16
  #home;
@@ -142,7 +142,7 @@ export class ResourceRegistrar {
142
142
  });
143
143
  }
144
144
  if (next !== state)
145
- store.save(next);
145
+ store.save(next, state);
146
146
  }
147
147
  finally {
148
148
  store.close();
@@ -175,10 +175,11 @@ export class ResourceRegistrar {
175
175
  return;
176
176
  const store = createResourceRegistryStore(this.#home);
177
177
  try {
178
- let state = store.load();
178
+ const previous = store.load();
179
+ let state = previous;
179
180
  for (const record of records)
180
181
  state = upsertResourceRecord(state, record);
181
- store.save(state);
182
+ store.save(state, previous);
182
183
  }
183
184
  finally {
184
185
  store.close();