@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,269 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { requireSafeIdentity, requireText, requireTimestamp } from "./validation.js";
3
+ /** Validates and freezes one physical owner record; fails closed on malformed input. */
4
+ export function createSessionOwnerIdentity(input) {
5
+ const owner = input.owner;
6
+ if (owner.scope !== "task" && owner.scope !== "global") {
7
+ throw new TypeError("Session owner scope is invalid.");
8
+ }
9
+ if (owner.scope === "task" && owner.taskId === undefined) {
10
+ throw new TypeError("Task session owner requires a task id.");
11
+ }
12
+ if (owner.scope === "global" && owner.taskId !== undefined) {
13
+ throw new TypeError("Global session owner must not carry a task id.");
14
+ }
15
+ const providerRoot = input.providerRoot;
16
+ if (!Number.isSafeInteger(providerRoot.pid) || providerRoot.pid <= 0) {
17
+ throw new TypeError("Session owner provider root pid is invalid.");
18
+ }
19
+ if (!/^[0-9]{1,32}$/u.test(providerRoot.startIdentity)) {
20
+ throw new TypeError("Session owner provider start identity is invalid.");
21
+ }
22
+ if (providerRoot.processGroupId !== undefined
23
+ && (!Number.isSafeInteger(providerRoot.processGroupId) || providerRoot.processGroupId <= 0)) {
24
+ throw new TypeError("Session owner process group id is invalid.");
25
+ }
26
+ if (providerRoot.processSessionId !== undefined
27
+ && (!Number.isSafeInteger(providerRoot.processSessionId) || providerRoot.processSessionId <= 0)) {
28
+ throw new TypeError("Session owner process session id is invalid.");
29
+ }
30
+ const tmux = input.tmux;
31
+ return Object.freeze({
32
+ schemaVersion: 1,
33
+ kind: "yui-session-owner",
34
+ owner: Object.freeze({
35
+ scope: owner.scope,
36
+ ...(owner.taskId === undefined ? {} : { taskId: requireSafeIdentity(owner.taskId, "Task id") }),
37
+ roleName: requireSafeIdentity(owner.roleName, "Role name")
38
+ }),
39
+ agentId: requireSafeIdentity(input.agentId, "Agent id"),
40
+ adapterId: requireText(input.adapterId, "Adapter id"),
41
+ launchId: requireSafeIdentity(input.launchId, "Launch id"),
42
+ ...(input.nativeSessionId === undefined
43
+ ? {}
44
+ : { nativeSessionId: requireText(input.nativeSessionId, "Native session id") }),
45
+ tmux: Object.freeze({
46
+ serverName: requireText(tmux.serverName, "tmux server name"),
47
+ socketPath: requireText(tmux.socketPath, "tmux socket path"),
48
+ sessionName: requireText(tmux.sessionName, "tmux session name"),
49
+ windowName: requireText(tmux.windowName, "tmux window name"),
50
+ ...(tmux.panePid === undefined ? {} : { panePid: tmux.panePid })
51
+ }),
52
+ providerRoot: Object.freeze({
53
+ pid: providerRoot.pid,
54
+ startIdentity: providerRoot.startIdentity,
55
+ ...(providerRoot.processGroupId === undefined
56
+ ? {}
57
+ : { processGroupId: providerRoot.processGroupId }),
58
+ ...(providerRoot.processSessionId === undefined
59
+ ? {}
60
+ : { processSessionId: providerRoot.processSessionId }),
61
+ attribution: providerRoot.attribution
62
+ }),
63
+ ...(input.runtimeRoot === undefined ? {} : { runtimeRoot: input.runtimeRoot }),
64
+ recordedAt: requireTimestamp(input.recordedAt, "Recorded at")
65
+ });
66
+ }
67
+ /**
68
+ * Reads one Linux process identity from /proc. Returns undefined when /proc is
69
+ * unavailable, unreadable, or the platform does not expose it — the caller
70
+ * must treat that as a verification gap, never as proof of absence.
71
+ */
72
+ export function readLinuxProcessIdentity(pid) {
73
+ if (!Number.isSafeInteger(pid) || pid <= 0)
74
+ return undefined;
75
+ try {
76
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
77
+ const closing = stat.lastIndexOf(")");
78
+ if (closing < 0)
79
+ return undefined;
80
+ const fields = stat.slice(closing + 1).trim().split(/\s+/u);
81
+ const state = fields[0];
82
+ const processGroupId = Number(fields[2]);
83
+ const processSessionId = Number(fields[3]);
84
+ const startIdentity = fields[19];
85
+ if (startIdentity === undefined || !/^[0-9]{1,32}$/u.test(startIdentity)) {
86
+ return undefined;
87
+ }
88
+ const rssBytes = readLinuxProcessRssBytes(pid) ?? 0;
89
+ return {
90
+ pid,
91
+ startIdentity,
92
+ ...(typeof state === "string" && state.length > 0 ? { state } : {}),
93
+ ...(Number.isSafeInteger(processGroupId) && processGroupId > 0
94
+ ? { processGroupId }
95
+ : {}),
96
+ ...(Number.isSafeInteger(processSessionId) && processSessionId > 0
97
+ ? { processSessionId }
98
+ : {}),
99
+ rssBytes
100
+ };
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ function readLinuxProcessRssBytes(pid) {
107
+ try {
108
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
109
+ for (const line of status.split("\n")) {
110
+ if (line.startsWith("VmRSS:")) {
111
+ const value = Number(line.slice("VmRSS:".length).trim().split(/\s+/u)[0]);
112
+ if (Number.isFinite(value) && value >= 0)
113
+ return Math.round(value) * 1024;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ /**
123
+ * True only when /proc still holds the exact process identity. A reused PID
124
+ * with a different start identity is absent, not live.
125
+ */
126
+ export function isLinuxProcessLive(pid, startIdentity) {
127
+ const current = readLinuxProcessIdentity(pid);
128
+ if (current === undefined)
129
+ return false;
130
+ // A zombie has already exited; only the task struct remains for the parent
131
+ // to reap. It cannot execute or hold resources, so it is not live.
132
+ if (current.state === "Z")
133
+ return false;
134
+ return current.startIdentity === startIdentity;
135
+ }
136
+ /**
137
+ * Discovers the Provider root for one launch by the exact YUI_LAUNCH_ID
138
+ * environment fence. Returns undefined when no same-user process carries the
139
+ * fence — the caller must not guess from a process name or cwd.
140
+ */
141
+ export function discoverProviderRootByLaunchEnv(launchId) {
142
+ const matches = listLaunchFencedProcesses(launchId);
143
+ for (const pid of matches) {
144
+ const identity = readLinuxProcessIdentity(pid);
145
+ if (identity !== undefined)
146
+ return { pid, identity };
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Lists every live process whose initial environment carries the exact
152
+ * `YUI_LAUNCH_ID=<launchId>` fence. The fence is inherited by Provider
153
+ * children, so this is the exact, PID-reuse-safe attribution for a surviving
154
+ * child after its root has exited: a recycled PID never carries the fence.
155
+ * Processes without the fence are unattributed and must never be signaled.
156
+ */
157
+ export function listLaunchFencedProcesses(launchId) {
158
+ if (!Number.isSafeInteger(launchId) && !/^[A-Za-z0-9_.:-]+$/u.test(launchId)) {
159
+ return [];
160
+ }
161
+ let entries;
162
+ try {
163
+ entries = readdirSync("/proc", { encoding: "utf8" });
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ const needle = `YUI_LAUNCH_ID=${launchId}`;
169
+ const matches = [];
170
+ for (const entry of entries) {
171
+ if (!/^[0-9]+$/u.test(entry))
172
+ continue;
173
+ const pid = Number(entry);
174
+ if (!Number.isSafeInteger(pid) || pid <= 0)
175
+ continue;
176
+ let environment;
177
+ try {
178
+ environment = readFileSync(`/proc/${pid}/environ`, "utf8");
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ if (!environment.includes(needle))
184
+ continue;
185
+ matches.push(pid);
186
+ }
187
+ return matches;
188
+ }
189
+ /**
190
+ * Lists the process tree owned by one Provider root: the root itself plus
191
+ * descendants proven by process-group membership or an ancestor chain. A
192
+ * process that merely shares a name or cwd is never included.
193
+ */
194
+ export function listOwnedProcessTree(rootPid, rootProcessGroupId) {
195
+ let entries;
196
+ try {
197
+ entries = readdirSync("/proc", { encoding: "utf8" });
198
+ }
199
+ catch {
200
+ return [];
201
+ }
202
+ const processes = new Map();
203
+ for (const entry of entries) {
204
+ if (!/^[0-9]+$/u.test(entry))
205
+ continue;
206
+ const pid = Number(entry);
207
+ if (!Number.isSafeInteger(pid) || pid <= 0)
208
+ continue;
209
+ try {
210
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
211
+ const closing = stat.lastIndexOf(")");
212
+ if (closing < 0)
213
+ continue;
214
+ const fields = stat.slice(closing + 1).trim().split(/\s+/u);
215
+ const ppid = Number(fields[1]);
216
+ const processGroupId = Number(fields[2]);
217
+ const startIdentity = fields[19];
218
+ if (!Number.isSafeInteger(ppid)
219
+ || ppid < 0
220
+ || startIdentity === undefined
221
+ || !/^[0-9]{1,32}$/u.test(startIdentity)) {
222
+ continue;
223
+ }
224
+ if (rootProcessGroupId !== undefined
225
+ && Number.isSafeInteger(processGroupId)
226
+ && processGroupId === rootProcessGroupId) {
227
+ const identity = readLinuxProcessIdentity(pid);
228
+ if (identity !== undefined) {
229
+ processes.set(pid, { ppid, identity });
230
+ }
231
+ continue;
232
+ }
233
+ processes.set(pid, {
234
+ ppid,
235
+ identity: {
236
+ pid,
237
+ startIdentity,
238
+ ...(Number.isSafeInteger(processGroupId) && processGroupId > 0
239
+ ? { processGroupId }
240
+ : {}),
241
+ rssBytes: readLinuxProcessRssBytes(pid) ?? 0
242
+ }
243
+ });
244
+ }
245
+ catch {
246
+ // A process exiting mid-scan is simply omitted from this point-in-time
247
+ // tree; the termination guard re-verifies the root identity afterwards.
248
+ }
249
+ }
250
+ const owned = new Map();
251
+ const root = processes.get(rootPid);
252
+ if (root === undefined)
253
+ return [];
254
+ owned.set(rootPid, root.identity);
255
+ // Ancestry walk: any process whose parent chain reaches the root is owned.
256
+ let grew = true;
257
+ while (grew) {
258
+ grew = false;
259
+ for (const [pid, process] of processes) {
260
+ if (owned.has(pid))
261
+ continue;
262
+ if (owned.has(process.ppid)) {
263
+ owned.set(pid, process.identity);
264
+ grew = true;
265
+ }
266
+ }
267
+ }
268
+ return [...owned.values()];
269
+ }
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { createSessionOwnerIdentity } from "./sessionOwnerIdentity.js";
4
+ /**
5
+ * Durable, enumerable physical owner records for runtime generations.
6
+ *
7
+ * One JSON file per launch id under `<home>/runtime/session-owners/`. The
8
+ * directory is runtime state (like the tmux socket), not aggregate domain
9
+ * state, so it needs no schema migration; it survives a Controller restart and
10
+ * stays enumerable after the durable Session map or history is cleared, which
11
+ * is exactly the gap the audit proved (durable pointers empty, physical
12
+ * processes still live).
13
+ */
14
+ export class FileSessionOwnerRegistry {
15
+ #directory;
16
+ constructor(home) {
17
+ this.#directory = join(resolve(home), "runtime", "session-owners");
18
+ }
19
+ get directory() {
20
+ return this.#directory;
21
+ }
22
+ /** Atomically records (or replaces) one generation's owner identity. */
23
+ record(identity) {
24
+ mkdirSync(this.#directory, { recursive: true, mode: 0o700 });
25
+ const target = this.#path(identity.launchId);
26
+ const temporary = `${target}.tmp-${process.pid}`;
27
+ writeFileSync(temporary, JSON.stringify(identity, null, 2) + "\n", { mode: 0o600 });
28
+ renameSync(temporary, target);
29
+ }
30
+ get(launchId) {
31
+ if (!isSafeLaunchId(launchId))
32
+ return null;
33
+ try {
34
+ return parseOwnerRecord(readFileSync(this.#path(launchId), "utf8"));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ list() {
41
+ let entries;
42
+ try {
43
+ entries = readdirSync(this.#directory, { encoding: "utf8" });
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ const records = [];
49
+ for (const entry of entries) {
50
+ if (!entry.endsWith(".json"))
51
+ continue;
52
+ try {
53
+ records.push(parseOwnerRecord(readFileSync(join(this.#directory, entry), "utf8")));
54
+ }
55
+ catch {
56
+ // A malformed record must not hide the exact candidates beside it.
57
+ }
58
+ }
59
+ records.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
60
+ return records;
61
+ }
62
+ listForOwner(owner) {
63
+ return this.list().filter((record) => (record.owner.scope === owner.scope
64
+ && (owner.scope === "global"
65
+ || record.owner.taskId === owner.taskId)
66
+ && record.owner.roleName === owner.roleName));
67
+ }
68
+ /** Removes a record whose physical resources were proven absent. */
69
+ remove(launchId) {
70
+ if (!isSafeLaunchId(launchId))
71
+ return;
72
+ try {
73
+ rmSync(this.#path(launchId), { force: true });
74
+ }
75
+ catch {
76
+ // A stale record is harmless: reconciliation re-verifies physical
77
+ // identity before acting on it.
78
+ }
79
+ }
80
+ exists() {
81
+ return existsSync(this.#directory);
82
+ }
83
+ #path(launchId) {
84
+ return join(this.#directory, `${launchId}.json`);
85
+ }
86
+ }
87
+ function isSafeLaunchId(launchId) {
88
+ return /^[A-Za-z0-9_.:-]+$/u.test(launchId);
89
+ }
90
+ function parseOwnerRecord(raw) {
91
+ const parsed = JSON.parse(raw);
92
+ if (typeof parsed !== "object" || parsed === null) {
93
+ throw new Error("Session owner record is invalid.");
94
+ }
95
+ const record = parsed;
96
+ const owner = record.owner;
97
+ const tmux = record.tmux;
98
+ const providerRoot = record.providerRoot;
99
+ return createSessionOwnerIdentity({
100
+ owner: {
101
+ scope: owner.scope === "global" ? "global" : "task",
102
+ ...(owner.taskId === undefined ? {} : { taskId: String(owner.taskId) }),
103
+ roleName: String(owner.roleName)
104
+ },
105
+ agentId: String(record.agentId),
106
+ adapterId: String(record.adapterId),
107
+ launchId: String(record.launchId),
108
+ ...(record.nativeSessionId === undefined
109
+ ? {}
110
+ : { nativeSessionId: String(record.nativeSessionId) }),
111
+ tmux: {
112
+ serverName: String(tmux.serverName),
113
+ socketPath: String(tmux.socketPath),
114
+ sessionName: String(tmux.sessionName),
115
+ windowName: String(tmux.windowName),
116
+ ...(tmux.panePid === undefined ? {} : { panePid: Number(tmux.panePid) })
117
+ },
118
+ providerRoot: {
119
+ pid: Number(providerRoot.pid),
120
+ startIdentity: String(providerRoot.startIdentity),
121
+ ...(providerRoot.processGroupId === undefined
122
+ ? {}
123
+ : { processGroupId: Number(providerRoot.processGroupId) }),
124
+ ...(providerRoot.processSessionId === undefined
125
+ ? {}
126
+ : { processSessionId: Number(providerRoot.processSessionId) }),
127
+ attribution: providerRoot.attribution === "pane-pid" ? "pane-pid" : "launch-env"
128
+ },
129
+ ...(record.runtimeRoot === undefined ? {} : { runtimeRoot: String(record.runtimeRoot) }),
130
+ recordedAt: new Date(String(record.recordedAt))
131
+ });
132
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Bidirectional durable <-> physical reconciliation for Session generations.
3
+ *
4
+ * Durable -> physical: every owner record's Provider root must be absent once
5
+ * its durable Session is terminal; a live root with a terminal durable state
6
+ * is the exact leak the audit found. Physical -> durable: the owner registry
7
+ * stays enumerable after the durable Session map or history is cleared, so a
8
+ * live generation can always be re-attributed and reported.
9
+ *
10
+ * Pure: all I/O is injected. Unknown owners are reported, never cleaned.
11
+ * An entry is archive-blocking when its durable state is terminal but the
12
+ * physical Provider root is still live, and the Task is terminal/archived.
13
+ */
14
+ export function reconcileSessionOwners(input) {
15
+ const entries = input.records.map((record) => reconcileOne(record, input));
16
+ const summary = {
17
+ owners: entries.length,
18
+ livePhysicalRoots: entries.filter((entry) => entry.physical?.alive === true).length,
19
+ archiveBlockers: entries.filter((entry) => entry.archiveBlocked).length,
20
+ verificationGaps: entries.filter((entry) => entry.verificationGap !== undefined).length
21
+ };
22
+ return {
23
+ schemaVersion: 1,
24
+ observedAt: input.now.toISOString(),
25
+ entries,
26
+ summary
27
+ };
28
+ }
29
+ function reconcileOne(record, input) {
30
+ const durable = matchDurable(record, input.durable);
31
+ const physical = input.observe(record);
32
+ const owner = record.owner;
33
+ const taskStatus = owner.scope === "task" && owner.taskId !== undefined
34
+ ? input.taskStatus(owner.taskId)
35
+ : undefined;
36
+ const tmuxPane = input.inspectPane(owner.taskId, owner.roleName);
37
+ const lastStopOutcome = input.lastStopOutcome(owner.taskId, owner.roleName, record.launchId);
38
+ let mismatch;
39
+ let verificationGap;
40
+ if (physical?.identityConflict === true) {
41
+ mismatch = "identity-conflict";
42
+ }
43
+ else if (physical?.alive === true) {
44
+ if (durable === undefined || durable.status === "stopped" || durable.status === "broken") {
45
+ mismatch = "durable-terminal-physical-live";
46
+ }
47
+ }
48
+ else if (physical === undefined) {
49
+ verificationGap = "/proc identity unavailable";
50
+ }
51
+ else if (durable !== undefined && durable.status === "running") {
52
+ mismatch = "durable-live-physical-absent";
53
+ }
54
+ const terminalTask = taskStatus === "completed"
55
+ || taskStatus === "retired"
56
+ || taskStatus === "archived";
57
+ const archiveBlocked = mismatch === "durable-terminal-physical-live"
58
+ && terminalTask;
59
+ return {
60
+ owner: {
61
+ scope: owner.scope,
62
+ ...(owner.taskId === undefined ? {} : { taskId: owner.taskId }),
63
+ roleName: owner.roleName
64
+ },
65
+ agentId: record.agentId,
66
+ adapterId: record.adapterId,
67
+ launchId: record.launchId,
68
+ ...(record.nativeSessionId === undefined
69
+ ? {}
70
+ : { nativeSessionId: record.nativeSessionId }),
71
+ ...(taskStatus === undefined ? {} : { taskStatus }),
72
+ durableStatus: durable?.status ?? "absent",
73
+ ...(tmuxPane === undefined ? {} : { tmuxPane }),
74
+ ...(physical === undefined ? {} : { physical }),
75
+ ...(lastStopOutcome === undefined ? {} : { lastStopOutcome }),
76
+ ...(mismatch === undefined ? {} : { mismatch }),
77
+ archiveBlocked,
78
+ ...(verificationGap === undefined ? {} : { verificationGap })
79
+ };
80
+ }
81
+ function matchDurable(record, durable) {
82
+ const candidates = durable.filter((fact) => (fact.scope === record.owner.scope
83
+ && fact.roleName === record.owner.roleName
84
+ && (record.owner.scope === "global" || fact.taskId === record.owner.taskId)
85
+ && fact.agentId === record.agentId));
86
+ const byLaunch = candidates.find((fact) => fact.launchId !== undefined && fact.launchId === record.launchId);
87
+ if (byLaunch !== undefined)
88
+ return byLaunch;
89
+ const byNative = candidates.find((fact) => fact.nativeSessionId !== undefined
90
+ && record.nativeSessionId !== undefined
91
+ && fact.nativeSessionId === record.nativeSessionId);
92
+ return byNative;
93
+ }