@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,116 @@
1
+ /**
2
+ * Automatic Resource GC for terminal Tasks (Issue 10).
3
+ *
4
+ * The Controller may call this on a low-frequency timer to quarantine
5
+ * resources owned by terminal Tasks. It is always opt-in: the
6
+ * `resources.gcAutoQuarantine` config defaults to false, and permanent
7
+ * deletion remains a manual, delayed step.
8
+ *
9
+ * The auto-GC pass reuses the same plan/apply engine as the manual command,
10
+ * so every safety guarantee (live-ref re-scan, cleanliness proof, fail-closed
11
+ * sources) applies identically.
12
+ */
13
+ import { applyResourceGc, planResourceGc } from "./resourceGc.js";
14
+ import { resolveResourcesGcAutoQuarantine, resolveResourcesGcMode } from "../config/yuiConfig.js";
15
+ /**
16
+ * Create the Controller's automatic Resource GC hook. The hook self-skips
17
+ * unless `resourcesGcMode=quarantine` and `resourcesGcAutoQuarantine=true`.
18
+ */
19
+ export function createResourceAutoGc(options) {
20
+ const { home, store, environment } = options;
21
+ return async () => {
22
+ const config = store.getConfig();
23
+ const autoQuarantine = resolveResourcesGcAutoQuarantine(config.resourcesGcAutoQuarantine);
24
+ if (!autoQuarantine || resolveResourcesGcMode(config.resourcesGcMode) !== "quarantine") {
25
+ return { skipped: true, applied: 0, failed: 0, restored: 0 };
26
+ }
27
+ const now = new Date();
28
+ const projects = store.listProjects();
29
+ const managedWorkspaces = collectManagedWorkspaces(store);
30
+ const taskStatusById = collectTaskStatuses(store);
31
+ const input = {
32
+ home,
33
+ projects,
34
+ managedWorkspaces,
35
+ taskStatusById,
36
+ mode: "quarantine",
37
+ now,
38
+ environment,
39
+ activeWorkspaceOwnerPaths: collectActiveWorkspaceOwnerPaths(store)
40
+ };
41
+ const plan = await planResourceGc(input);
42
+ const result = await applyResourceGc(input, plan);
43
+ return {
44
+ skipped: false,
45
+ applied: result.applied.length,
46
+ failed: result.failed.length,
47
+ restored: result.restored.length
48
+ };
49
+ };
50
+ }
51
+ /**
52
+ * Run one automatic GC pass. Returns `ran: false` when auto-GC is disabled or
53
+ * the GC mode is not `quarantine`. The pass never purges: permanent deletion
54
+ * is always manual.
55
+ */
56
+ export async function runAutoResourceGc(store, options = {}) {
57
+ const config = store.getConfig();
58
+ const autoQuarantine = resolveResourcesGcAutoQuarantine(config.resourcesGcAutoQuarantine);
59
+ if (!autoQuarantine) {
60
+ return { ran: false, reason: "auto-quarantine is disabled" };
61
+ }
62
+ const gcMode = resolveResourcesGcMode(config.resourcesGcMode);
63
+ if (gcMode !== "quarantine") {
64
+ return { ran: false, reason: "resources.gcMode is not quarantine" };
65
+ }
66
+ const now = options.now ?? new Date();
67
+ const home = store.rootDirectory();
68
+ const projects = store.listProjects();
69
+ const managedWorkspaces = collectManagedWorkspaces(store);
70
+ const taskStatusById = collectTaskStatuses(store);
71
+ const input = {
72
+ home,
73
+ projects,
74
+ managedWorkspaces,
75
+ taskStatusById,
76
+ mode: "quarantine",
77
+ now,
78
+ activeWorkspaceOwnerPaths: collectActiveWorkspaceOwnerPaths(store)
79
+ };
80
+ const plan = await planResourceGc(input);
81
+ const result = await applyResourceGc(input, plan);
82
+ return { ran: true, result };
83
+ }
84
+ function collectManagedWorkspaces(store) {
85
+ const workspaces = [];
86
+ for (const task of store.listTasks()) {
87
+ workspaces.push(...store.listManagedWorkspaces(task.id));
88
+ }
89
+ return workspaces;
90
+ }
91
+ function collectTaskStatuses(store) {
92
+ const statuses = new Map();
93
+ for (const task of store.listTasks()) {
94
+ statuses.set(task.id, task.status);
95
+ }
96
+ return statuses;
97
+ }
98
+ /**
99
+ * Workspace paths claimed by active durable Jobs. An active AgentRun still
100
+ * holds its workspace even after the managed workspace record is gone, so
101
+ * those paths stay protected until the Run finishes.
102
+ */
103
+ function collectActiveWorkspaceOwnerPaths(store) {
104
+ const paths = [];
105
+ for (const task of store.listTasks()) {
106
+ for (const run of store.listAgentRuns(task.id)) {
107
+ if (run.status !== "active")
108
+ continue;
109
+ const workspace = run.workspace;
110
+ if (workspace === undefined)
111
+ continue;
112
+ paths.push(workspace.root, ...workspace.entries.map((entry) => entry.path));
113
+ }
114
+ }
115
+ return paths;
116
+ }
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Live-reference detection for Resource GC (Issue 10).
3
+ *
4
+ * GC proves liveness itself, every apply, from OS/Git/durable facts — it never
5
+ * consumes another Issue's completion flag. Any source that cannot be read
6
+ * fails closed: the resource is treated as referenced and retained.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { parseExactTaskRuntimeDescriptor } from "../runtime/exactControlPlane.js";
14
+ import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
15
+ const executeFile = promisify(execFile);
16
+ /**
17
+ * Collect every live reference for the given paths. The scan is fail-closed:
18
+ * an unreadable /proc entry or an unavailable tmux is reported as a reference
19
+ * rather than silently ignored.
20
+ */
21
+ export async function scanLiveReferences(input) {
22
+ const home = resolve(input.home);
23
+ const paths = input.paths.map((path) => resolve(path));
24
+ const refs = new Map();
25
+ const protectedPaths = new Set();
26
+ const diagnostics = [];
27
+ const addRef = (path, token) => {
28
+ const set = refs.get(path) ?? new Set();
29
+ set.add(token);
30
+ refs.set(path, set);
31
+ };
32
+ // 1. Physical process inventory: cwd and open file descriptors.
33
+ let processResult;
34
+ if (input.ports?.processCwdRefs !== undefined) {
35
+ try {
36
+ processResult = Object.freeze({
37
+ refs: input.ports.processCwdRefs(paths),
38
+ diagnostics: Object.freeze([])
39
+ });
40
+ }
41
+ catch (error) {
42
+ processResult = Object.freeze({
43
+ refs: new Map(),
44
+ diagnostics: Object.freeze([{
45
+ source: "proc",
46
+ severity: "error",
47
+ message: `process cwd scan failed: ${error instanceof Error ? error.message : "unknown error"}`
48
+ }])
49
+ });
50
+ }
51
+ }
52
+ else {
53
+ processResult = scanProcessPathRefs(paths);
54
+ }
55
+ for (const [path, tokens] of processResult.refs) {
56
+ for (const token of tokens)
57
+ addRef(path, token);
58
+ }
59
+ diagnostics.push(...processResult.diagnostics);
60
+ // 2. tmux pane working directories (best effort; tmux may be absent).
61
+ let tmuxResult;
62
+ if (input.ports?.tmuxPaneCwds !== undefined) {
63
+ try {
64
+ tmuxResult = Object.freeze({
65
+ cwds: Object.freeze(await input.ports.tmuxPaneCwds()),
66
+ diagnostics: Object.freeze([])
67
+ });
68
+ }
69
+ catch (error) {
70
+ tmuxResult = Object.freeze({
71
+ cwds: Object.freeze([]),
72
+ diagnostics: Object.freeze([{
73
+ source: "tmux",
74
+ severity: "error",
75
+ message: `tmux scan failed: ${error instanceof Error ? error.message : "unknown error"}`
76
+ }])
77
+ });
78
+ }
79
+ }
80
+ else {
81
+ tmuxResult = await scanTmuxPaneCwds(home, input.tmuxServerName, input.environment ?? process.env);
82
+ }
83
+ for (const cwd of tmuxResult.cwds) {
84
+ for (const path of paths) {
85
+ if (isPathWithin(cwd, path))
86
+ addRef(path, `tmux-pane:${cwd}`);
87
+ }
88
+ }
89
+ diagnostics.push(...tmuxResult.diagnostics);
90
+ // 3. Controller discovery: a running Controller protects only its own
91
+ // discovery record. Its cwd and open files are covered by the /proc
92
+ // scan above, so a live Controller does not blanket-protect legacy
93
+ // deployments and worktrees that no descriptor references — automatic
94
+ // GC (which runs inside the Controller) would otherwise be a no-op.
95
+ const discovery = readControllerDiscovery(home);
96
+ if (discovery.protects) {
97
+ for (const path of paths) {
98
+ if (discovery.protectedPaths.some((claim) => isPathWithin(claim, path) || isPathWithin(path, claim))) {
99
+ addRef(path, discovery.token);
100
+ }
101
+ }
102
+ }
103
+ if (discovery.diagnostic !== undefined)
104
+ diagnostics.push(discovery.diagnostic);
105
+ // 4. Runtime identity and exact Task descriptors are durable live-ownership
106
+ // claims. Resolved runtime paths and descriptor workspaces stay protected
107
+ // while their owning process identity is alive.
108
+ const runtimeClaims = readRuntimeIdentityClaims(home);
109
+ diagnostics.push(...runtimeClaims.diagnostics);
110
+ const descriptorClaims = readExactTaskRuntimeClaims(home);
111
+ diagnostics.push(...descriptorClaims.diagnostics);
112
+ const activeReleaseClaims = readActiveReleaseClaims(home);
113
+ diagnostics.push(...activeReleaseClaims.diagnostics);
114
+ const sessionOwnerClaims = readSessionOwnerClaims(home);
115
+ diagnostics.push(...sessionOwnerClaims.diagnostics);
116
+ for (const claim of [
117
+ ...runtimeClaims.claims,
118
+ ...descriptorClaims.claims,
119
+ ...activeReleaseClaims.claims,
120
+ ...sessionOwnerClaims.claims
121
+ ]) {
122
+ protectedPaths.add(claim.path);
123
+ for (const path of paths) {
124
+ if (isPathWithin(claim.path, path) || isPathWithin(path, claim.path)) {
125
+ addRef(path, claim.token);
126
+ }
127
+ }
128
+ }
129
+ // 5. Durable managed workspaces: their roots and entries are claimed while
130
+ // the durable record exists, regardless of Task terminal state.
131
+ const workspaces = input.ports?.managedWorkspaces !== undefined
132
+ ? input.ports.managedWorkspaces()
133
+ : [];
134
+ for (const workspace of workspaces) {
135
+ const claimed = [workspace.root, ...workspace.entries.map((entry) => entry.path)];
136
+ for (const claim of claimed) {
137
+ protectedPaths.add(claim);
138
+ for (const path of paths) {
139
+ if (isPathWithin(claim, path) || isPathWithin(path, claim)) {
140
+ addRef(path, `durable-managed-workspace:${workspace.owner.type}`);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ // 6. Active durable workspace owners (e.g. an active AgentRun launch).
146
+ const activeOwners = input.ports?.activeWorkspaceOwners !== undefined
147
+ ? input.ports.activeWorkspaceOwners()
148
+ : [];
149
+ for (const owner of activeOwners) {
150
+ for (const path of paths) {
151
+ if (path.includes(owner))
152
+ addRef(path, `active-owner:${owner}`);
153
+ }
154
+ }
155
+ const refsByPath = new Map();
156
+ for (const path of paths) {
157
+ const tokens = refs.get(path);
158
+ refsByPath.set(path, tokens === undefined ? Object.freeze([]) : Object.freeze([...tokens]));
159
+ }
160
+ return Object.freeze({
161
+ refsByPath,
162
+ protectedPaths: Object.freeze([...protectedPaths]),
163
+ diagnostics: Object.freeze(diagnostics)
164
+ });
165
+ }
166
+ function readRuntimeIdentityClaims(home) {
167
+ try {
168
+ const identity = readRuntimeIdentity(home);
169
+ if (identity === null) {
170
+ return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
171
+ }
172
+ if (!isProcessAlive(identity.pid)) {
173
+ return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
174
+ }
175
+ const currentIdentity = readLinuxProcessStartIdentity(identity.pid);
176
+ if (currentIdentity === undefined || currentIdentity !== identity.processStartIdentity) {
177
+ return Object.freeze({
178
+ claims: Object.freeze([]),
179
+ diagnostics: Object.freeze([{
180
+ source: "controller",
181
+ severity: "error",
182
+ message: `runtime-identity pid ${identity.pid} start identity mismatch (PID reuse?)`
183
+ }])
184
+ });
185
+ }
186
+ const claims = [
187
+ { path: resolve(identity.cliRealpath), token: `runtime-identity:cli:${identity.pid}` },
188
+ { path: resolve(identity.controllerRealpath), token: `runtime-identity:controller:${identity.pid}` }
189
+ ];
190
+ if (identity.activeRelease !== null) {
191
+ claims.push({
192
+ path: join(releasesDirectory(home), identity.activeRelease.releaseId),
193
+ token: `runtime-identity:active-release:${identity.pid}`
194
+ });
195
+ }
196
+ return Object.freeze({ claims: Object.freeze(claims), diagnostics: Object.freeze([]) });
197
+ }
198
+ catch (error) {
199
+ return Object.freeze({
200
+ claims: Object.freeze([]),
201
+ diagnostics: Object.freeze([{
202
+ source: "controller",
203
+ severity: "error",
204
+ message: `runtime-identity is unreadable: ${error instanceof Error ? error.message : "unknown error"}`
205
+ }])
206
+ });
207
+ }
208
+ }
209
+ function readExactTaskRuntimeClaims(home) {
210
+ const directory = join(home, "runtime", "exact-task-runtime");
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(directory, { withFileTypes: true });
214
+ }
215
+ catch (error) {
216
+ if (error.code === "ENOENT") {
217
+ return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
218
+ }
219
+ return Object.freeze({
220
+ claims: Object.freeze([]),
221
+ diagnostics: Object.freeze([{
222
+ source: "controller",
223
+ severity: "error",
224
+ message: `exact Task runtime descriptors are unreadable: ${error instanceof Error ? error.message : "unknown error"}`
225
+ }])
226
+ });
227
+ }
228
+ const claims = [];
229
+ const diagnostics = [];
230
+ for (const entry of entries) {
231
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
232
+ continue;
233
+ const path = join(directory, entry.name);
234
+ try {
235
+ const descriptor = parseExactTaskRuntimeDescriptor(readFileSync(path, "utf8"));
236
+ claims.push({ path, token: `exact-task-runtime:${descriptor.taskId}` });
237
+ claims.push({
238
+ path: resolve(descriptor.workspace),
239
+ token: `exact-task-runtime:workspace:${descriptor.taskId}`
240
+ });
241
+ }
242
+ catch (error) {
243
+ diagnostics.push({
244
+ source: "controller",
245
+ severity: "error",
246
+ message: `exact Task runtime descriptor is unreadable at ${path}: ${error instanceof Error ? error.message : "unknown error"}`
247
+ });
248
+ }
249
+ }
250
+ return Object.freeze({
251
+ claims: Object.freeze(claims),
252
+ diagnostics: Object.freeze(diagnostics)
253
+ });
254
+ }
255
+ function readActiveReleaseClaims(home) {
256
+ try {
257
+ const active = resolveActiveRelease(home);
258
+ if (active === null) {
259
+ return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
260
+ }
261
+ return Object.freeze({
262
+ claims: Object.freeze([{
263
+ path: active.releaseDir,
264
+ token: "active-release"
265
+ }]),
266
+ diagnostics: Object.freeze([])
267
+ });
268
+ }
269
+ catch (error) {
270
+ return Object.freeze({
271
+ claims: Object.freeze([]),
272
+ diagnostics: Object.freeze([{
273
+ source: "controller",
274
+ severity: "error",
275
+ message: `active release is unreadable: ${error instanceof Error ? error.message : "unknown error"}`
276
+ }])
277
+ });
278
+ }
279
+ }
280
+ /**
281
+ * Read Session owner records (`runtime/session-owners/<launchId>.json`). A
282
+ * record whose Provider root is physically alive with a matching start
283
+ * identity protects its runtime root. A dead PID is stale (reconciliation
284
+ * removes those records); an identity conflict or unreadable record fails
285
+ * closed because liveness can no longer be proven.
286
+ */
287
+ function readSessionOwnerClaims(home) {
288
+ const directory = join(home, "runtime", "session-owners");
289
+ let entries;
290
+ try {
291
+ entries = readdirSync(directory, { withFileTypes: true });
292
+ }
293
+ catch (error) {
294
+ if (error.code === "ENOENT") {
295
+ return Object.freeze({ claims: Object.freeze([]), diagnostics: Object.freeze([]) });
296
+ }
297
+ return Object.freeze({
298
+ claims: Object.freeze([]),
299
+ diagnostics: Object.freeze([{
300
+ source: "controller",
301
+ severity: "error",
302
+ message: `session owner records are unreadable: ${error instanceof Error ? error.message : "unknown error"}`
303
+ }])
304
+ });
305
+ }
306
+ const claims = [];
307
+ const diagnostics = [];
308
+ for (const entry of entries) {
309
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
310
+ continue;
311
+ const path = join(directory, entry.name);
312
+ let record;
313
+ try {
314
+ record = JSON.parse(readFileSync(path, "utf8"));
315
+ }
316
+ catch (error) {
317
+ diagnostics.push({
318
+ source: "controller",
319
+ severity: "error",
320
+ message: `session owner record is unreadable at ${path}: ${error instanceof Error ? error.message : "unknown error"}`
321
+ });
322
+ continue;
323
+ }
324
+ const pid = record.providerRoot?.pid;
325
+ const startIdentity = record.providerRoot?.startIdentity;
326
+ if (typeof pid !== "number" || pid <= 0 || typeof startIdentity !== "string") {
327
+ diagnostics.push({
328
+ source: "controller",
329
+ severity: "error",
330
+ message: `session owner record at ${path} has no valid provider root identity`
331
+ });
332
+ continue;
333
+ }
334
+ if (!isProcessAlive(pid)) {
335
+ // Dead Provider root: the record is stale; reconciliation removes it.
336
+ continue;
337
+ }
338
+ const currentIdentity = readLinuxProcessStartIdentity(pid);
339
+ if (currentIdentity === undefined || currentIdentity !== startIdentity) {
340
+ diagnostics.push({
341
+ source: "controller",
342
+ severity: "error",
343
+ message: `session owner record pid ${pid} start identity mismatch (PID reuse?)`
344
+ });
345
+ continue;
346
+ }
347
+ if (typeof record.runtimeRoot === "string" && record.runtimeRoot.length > 0) {
348
+ claims.push({
349
+ path: resolve(record.runtimeRoot),
350
+ token: `session-owner:${typeof record.launchId === "string" ? record.launchId : entry.name}`
351
+ });
352
+ }
353
+ }
354
+ return Object.freeze({
355
+ claims: Object.freeze(claims),
356
+ diagnostics: Object.freeze(diagnostics)
357
+ });
358
+ }
359
+ /**
360
+ * Scan /proc for processes whose cwd or open fd is within one of the paths.
361
+ * The current process is included: an operator running GC from inside a
362
+ * candidate directory must protect it. When /proc itself cannot be read the
363
+ * scan fails closed: every candidate path receives a protective ref.
364
+ */
365
+ export function scanProcessPathRefs(paths) {
366
+ const refs = new Map();
367
+ const diagnostics = [];
368
+ const add = (path, token) => {
369
+ const set = refs.get(path) ?? new Set();
370
+ set.add(token);
371
+ refs.set(path, set);
372
+ };
373
+ let entries;
374
+ try {
375
+ entries = readdirSync("/proc");
376
+ }
377
+ catch (error) {
378
+ // /proc is unreadable: fail closed — protect every candidate path.
379
+ const message = `/proc cannot be read: ${error instanceof Error ? error.message : "unknown error"}`;
380
+ diagnostics.push({ source: "proc", severity: "error", message });
381
+ for (const path of paths)
382
+ add(path, "proc:unreadable");
383
+ return freezeResult(refs, diagnostics);
384
+ }
385
+ for (const entry of entries) {
386
+ if (!/^\d+$/.test(entry))
387
+ continue;
388
+ const pid = Number(entry);
389
+ let cwd;
390
+ try {
391
+ cwd = readlinkSync(`/proc/${pid}/cwd`);
392
+ }
393
+ catch {
394
+ cwd = undefined;
395
+ }
396
+ if (cwd !== undefined) {
397
+ for (const path of paths) {
398
+ if (isPathWithin(cwd, path))
399
+ add(path, `proc:cwd:${pid}`);
400
+ }
401
+ }
402
+ // Open file descriptors. A resource with an open fd is in use even when
403
+ // no process has it as cwd.
404
+ let fds = [];
405
+ try {
406
+ fds = readdirSync(`/proc/${pid}/fd`);
407
+ }
408
+ catch {
409
+ fds = [];
410
+ }
411
+ for (const fd of fds) {
412
+ let target;
413
+ try {
414
+ target = readlinkSync(`/proc/${pid}/fd/${fd}`);
415
+ }
416
+ catch {
417
+ continue;
418
+ }
419
+ if (target.startsWith("socket:") || target.startsWith("pipe:")
420
+ || target.startsWith("anon_inode:")) {
421
+ continue;
422
+ }
423
+ for (const path of paths) {
424
+ if (isPathWithin(target, path))
425
+ add(path, `proc:fd:${pid}`);
426
+ }
427
+ }
428
+ }
429
+ return freezeResult(refs, diagnostics);
430
+ }
431
+ function freezeResult(refs, diagnostics) {
432
+ const frozen = new Map();
433
+ for (const [path, tokens] of refs)
434
+ frozen.set(path, Object.freeze([...tokens]));
435
+ return Object.freeze({
436
+ refs: frozen,
437
+ diagnostics: Object.freeze(diagnostics)
438
+ });
439
+ }
440
+ /** Enumerate tmux pane cwds for the Home's namespace. */
441
+ export async function scanTmuxPaneCwds(home, tmuxServerName, environment) {
442
+ const server = tmuxServerName ?? defaultTmuxServerName(home);
443
+ try {
444
+ const { stdout } = await executeFile("tmux", ["-L", server, "list-panes", "-a", "-F", "#{pane_current_path}"], { env: environment, timeout: 5_000 });
445
+ return Object.freeze({
446
+ cwds: Object.freeze(stdout.split("\n").map((line) => line.trim()).filter(Boolean)),
447
+ diagnostics: Object.freeze([])
448
+ });
449
+ }
450
+ catch (error) {
451
+ const code = error.code;
452
+ // tmux binary not found or no server: the namespace is genuinely absent.
453
+ if (code === "ENOENT" || isNoServerError(error)) {
454
+ return Object.freeze({ cwds: Object.freeze([]), diagnostics: Object.freeze([]) });
455
+ }
456
+ // Any other failure (permission, timeout, unexpected exit) is untrusted.
457
+ const message = `tmux scan failed: ${error instanceof Error ? error.message : "unknown error"}`;
458
+ return Object.freeze({
459
+ cwds: Object.freeze([]),
460
+ diagnostics: Object.freeze([{ source: "tmux", severity: "error", message }])
461
+ });
462
+ }
463
+ }
464
+ function isNoServerError(error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ return message.includes("no server") || message.includes("error connecting to");
467
+ }
468
+ function defaultTmuxServerName(home) {
469
+ return `yui-${createHash("sha256").update(resolve(home)).digest("hex").slice(0, 24)}`;
470
+ }
471
+ /**
472
+ * Read the Controller discovery record. A live Controller process protects
473
+ * only its own discovery record; the process's cwd and open files are proven
474
+ * by the /proc scan. When the record exists but the PID is dead the record is
475
+ * stale. A live PID without a provable start identity is fail-closed.
476
+ */
477
+ export function readControllerDiscovery(home) {
478
+ const path = join(resolve(home), "runtime", "controller.json");
479
+ if (!existsSync(path)) {
480
+ return Object.freeze({ protects: false, token: "", protectedPaths: Object.freeze([]) });
481
+ }
482
+ let record;
483
+ try {
484
+ record = JSON.parse(readFileSync(path, "utf8"));
485
+ }
486
+ catch (error) {
487
+ return Object.freeze({
488
+ protects: false,
489
+ token: "",
490
+ protectedPaths: Object.freeze([]),
491
+ diagnostic: {
492
+ source: "controller",
493
+ severity: "error",
494
+ message: `controller.json is unreadable: ${error instanceof Error ? error.message : "unknown error"}`
495
+ }
496
+ });
497
+ }
498
+ if (typeof record.pid !== "number" || record.pid <= 0) {
499
+ return Object.freeze({
500
+ protects: false,
501
+ token: "",
502
+ protectedPaths: Object.freeze([]),
503
+ diagnostic: { source: "controller", severity: "error", message: "controller.json has no valid pid" }
504
+ });
505
+ }
506
+ try {
507
+ process.kill(record.pid, 0);
508
+ }
509
+ catch {
510
+ // PID is dead: the Controller is not running; no protection, no diagnostic.
511
+ return Object.freeze({ protects: false, token: "", protectedPaths: Object.freeze([]) });
512
+ }
513
+ if (typeof record.processStartIdentity !== "string") {
514
+ return Object.freeze({
515
+ protects: false,
516
+ token: "",
517
+ protectedPaths: Object.freeze([]),
518
+ diagnostic: {
519
+ source: "controller",
520
+ severity: "error",
521
+ message: `controller.json pid ${record.pid} has no processStartIdentity`
522
+ }
523
+ });
524
+ }
525
+ // PID is alive. When a start identity is recorded, verify it to guard
526
+ // against PID reuse.
527
+ const currentIdentity = readLinuxProcessStartIdentity(record.pid);
528
+ if (currentIdentity === undefined || currentIdentity !== record.processStartIdentity) {
529
+ return Object.freeze({
530
+ protects: false,
531
+ token: "",
532
+ protectedPaths: Object.freeze([]),
533
+ diagnostic: {
534
+ source: "controller",
535
+ severity: "error",
536
+ message: `controller.json pid ${record.pid} start identity mismatch (PID reuse?)`
537
+ }
538
+ });
539
+ }
540
+ return Object.freeze({
541
+ protects: true,
542
+ token: `controller:${record.pid}`,
543
+ protectedPaths: Object.freeze([path])
544
+ });
545
+ }
546
+ function isProcessAlive(pid) {
547
+ try {
548
+ process.kill(pid, 0);
549
+ return true;
550
+ }
551
+ catch {
552
+ return false;
553
+ }
554
+ }
555
+ /** Read /proc/<pid>/stat start time (field 22), the Linux process identity. */
556
+ function readLinuxProcessStartIdentity(pid) {
557
+ try {
558
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
559
+ // The comm field (2) can contain spaces and parentheses, so find the last ")".
560
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
561
+ const fields = afterComm.split(" ");
562
+ return fields[19]; // field 22 (1-indexed) = index 20 after comm; fields[19]
563
+ }
564
+ catch {
565
+ return undefined;
566
+ }
567
+ }
568
+ function isPathWithin(child, parent) {
569
+ const resolvedChild = resolve(child);
570
+ const resolvedParent = resolve(parent);
571
+ if (resolvedChild === resolvedParent)
572
+ return true;
573
+ return resolvedChild.startsWith(`${resolvedParent}/`);
574
+ }