harnery 0.45.0 → 0.45.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 (235) hide show
  1. package/dist/commander.js +0 -1
  2. package/dist/commands/files.d.ts +2 -2
  3. package/dist/commands/files.d.ts.map +1 -1
  4. package/dist/commands/files.js +4 -7
  5. package/dist/commands/servers.d.ts.map +1 -1
  6. package/dist/commands/servers.js +17 -11
  7. package/dist/commands/tunnel.d.ts +0 -15
  8. package/dist/commands/tunnel.d.ts.map +1 -1
  9. package/dist/commands/tunnel.js +153 -453
  10. package/dist/commands/workflow.d.ts.map +1 -1
  11. package/dist/commands/workflow.js +6 -45
  12. package/dist/core/agents/coord-client.d.ts.map +1 -1
  13. package/dist/core/agents/coord-client.js +0 -18
  14. package/dist/core/agents/live-authority-v3.d.ts +0 -1
  15. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  16. package/dist/core/agents/live-authority-v3.js +0 -11
  17. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
  18. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  19. package/dist/core/agents/live-lifecycle-v3.js +7 -28
  20. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  21. package/dist/core/agents/state/heartbeat-writer.js +14 -25
  22. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  23. package/dist/core/agents/state/live-coordination-writer.js +0 -5
  24. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  25. package/dist/core/agents/state/pidmap.js +2 -7
  26. package/dist/core/agents/state/proc-start.d.ts +20 -31
  27. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  28. package/dist/core/agents/state/proc-start.js +37 -70
  29. package/dist/core/agents/state/service-process.d.ts +61 -0
  30. package/dist/core/agents/state/service-process.d.ts.map +1 -0
  31. package/dist/core/agents/state/service-process.js +143 -0
  32. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  33. package/dist/core/agents/state/stale-sweep.js +70 -180
  34. package/dist/core/artifacts/activity.d.ts +4 -1
  35. package/dist/core/artifacts/activity.d.ts.map +1 -1
  36. package/dist/core/artifacts/activity.js +8 -2
  37. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  38. package/dist/core/artifacts/delivery-card.js +1 -2
  39. package/dist/core/artifacts/index.d.ts.map +1 -1
  40. package/dist/core/artifacts/index.js +38 -12
  41. package/dist/core/context/index.d.ts +6 -4
  42. package/dist/core/context/index.d.ts.map +1 -1
  43. package/dist/core/context/index.js +194 -256
  44. package/dist/core/context/observation.d.ts +9 -9
  45. package/dist/core/context/observation.d.ts.map +1 -1
  46. package/dist/core/context/observation.js +37 -6
  47. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  48. package/dist/core/events/v3/authority-outbox.js +0 -29
  49. package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
  50. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  51. package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
  52. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  53. package/dist/core/events/v3/producers/recorder.js +64 -77
  54. package/dist/core/events/v3/runtime-build.d.ts +14 -0
  55. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  56. package/dist/core/events/v3/runtime-build.js +44 -0
  57. package/dist/core/governor/index.d.ts +2 -1
  58. package/dist/core/governor/index.d.ts.map +1 -1
  59. package/dist/core/governor/index.js +2 -1
  60. package/dist/core/governor/service-read.d.ts +3 -10
  61. package/dist/core/governor/service-read.d.ts.map +1 -1
  62. package/dist/core/governor/service-read.js +10 -94
  63. package/dist/core/governor/service.d.ts +3 -7
  64. package/dist/core/governor/service.d.ts.map +1 -1
  65. package/dist/core/governor/service.js +17 -182
  66. package/dist/core/hooks/cli.js +78 -48
  67. package/dist/core/hooks/resolve/owner.js +2 -2
  68. package/dist/core/resources/sampler.js +2 -2
  69. package/dist/core/resources/service-status.d.ts +2 -14
  70. package/dist/core/resources/service-status.d.ts.map +1 -1
  71. package/dist/core/resources/service-status.js +3 -23
  72. package/dist/core/resources/service.d.ts +3 -7
  73. package/dist/core/resources/service.d.ts.map +1 -1
  74. package/dist/core/resources/service.js +12 -101
  75. package/dist/core/resources/status.d.ts.map +1 -1
  76. package/dist/core/resources/status.js +3 -2
  77. package/dist/core/semantic/service.d.ts.map +1 -1
  78. package/dist/core/semantic/service.js +9 -2
  79. package/dist/core/servers/index.d.ts +11 -10
  80. package/dist/core/servers/index.d.ts.map +1 -1
  81. package/dist/core/servers/index.js +82 -192
  82. package/dist/core/servers/tunnels.d.ts +3 -5
  83. package/dist/core/servers/tunnels.d.ts.map +1 -1
  84. package/dist/core/servers/tunnels.js +2 -45
  85. package/dist/core/storage/durable-history.js +56 -22
  86. package/dist/core/storage/segments.d.ts.map +1 -1
  87. package/dist/core/storage/segments.js +73 -17
  88. package/dist/core/supervisor/service.d.ts +4 -7
  89. package/dist/core/supervisor/service.d.ts.map +1 -1
  90. package/dist/core/supervisor/service.js +23 -122
  91. package/dist/core/supervisor/services.js +2 -2
  92. package/dist/core/supervisor/status.d.ts +3 -17
  93. package/dist/core/supervisor/status.d.ts.map +1 -1
  94. package/dist/core/supervisor/status.js +7 -70
  95. package/dist/core/work/state.js +7 -7
  96. package/dist/core/workflow/engine.d.ts.map +1 -1
  97. package/dist/core/workflow/engine.js +23 -66
  98. package/dist/core/workflow/index.d.ts +3 -4
  99. package/dist/core/workflow/index.d.ts.map +1 -1
  100. package/dist/core/workflow/index.js +2 -2
  101. package/dist/core/workflow/live-session-v3.d.ts +0 -6
  102. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  103. package/dist/core/workflow/live-session-v3.js +5 -126
  104. package/dist/core/workflow/proof.d.ts +4 -4
  105. package/dist/core/workflow/proof.d.ts.map +1 -1
  106. package/dist/core/workflow/proof.js +34 -86
  107. package/dist/core/workflow/resume-lock.d.ts +17 -0
  108. package/dist/core/workflow/resume-lock.d.ts.map +1 -0
  109. package/dist/core/workflow/resume-lock.js +158 -0
  110. package/dist/core/workflow/run-state.d.ts +0 -21
  111. package/dist/core/workflow/run-state.d.ts.map +1 -1
  112. package/dist/core/workflow/run-state.js +3 -174
  113. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  114. package/dist/core/workflow/spawn-claude.js +0 -19
  115. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  116. package/dist/core/workflow/spawn-codex.js +0 -13
  117. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  118. package/dist/core/workflow/spawn-cursor.js +0 -19
  119. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-opencode.js +0 -14
  121. package/dist/core/workflow/types.d.ts +11 -54
  122. package/dist/core/workflow/types.d.ts.map +1 -1
  123. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  124. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  125. package/dist/core/workflow/workspaces/leases.js +1 -1
  126. package/dist/core/workflow/workspaces/local-git.js +91 -210
  127. package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
  128. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
  129. package/dist/core/workflow/workspaces/ownership.js +3 -130
  130. package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
  131. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  132. package/dist/core/workflow/workspaces/reclaim.js +0 -4
  133. package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
  134. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  135. package/dist/core/workflow/workspaces/recovery.js +1 -1
  136. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/state.js +1 -4
  138. package/dist/lib/identities/assume.d.ts.map +1 -1
  139. package/dist/lib/identities/assume.js +0 -4
  140. package/dist/lib/tunnel/state.d.ts +6 -13
  141. package/dist/lib/tunnel/state.d.ts.map +1 -1
  142. package/dist/lib/tunnel/state.js +12 -58
  143. package/package.json +1 -6
  144. package/src/commander.ts +0 -10
  145. package/src/commands/files.ts +5 -13
  146. package/src/commands/servers.ts +23 -12
  147. package/src/commands/tunnel.ts +166 -533
  148. package/src/commands/workflow.ts +8 -58
  149. package/src/core/agents/coord-client.ts +0 -20
  150. package/src/core/agents/live-authority-v3.ts +0 -17
  151. package/src/core/agents/live-lifecycle-v3.ts +14 -52
  152. package/src/core/agents/state/heartbeat-writer.ts +12 -29
  153. package/src/core/agents/state/live-coordination-writer.ts +0 -29
  154. package/src/core/agents/state/pidmap.ts +2 -15
  155. package/src/core/agents/state/proc-start.ts +41 -69
  156. package/src/core/agents/state/service-process.ts +217 -0
  157. package/src/core/agents/state/stale-sweep.ts +82 -247
  158. package/src/core/artifacts/activity.ts +9 -2
  159. package/src/core/artifacts/delivery-card.ts +1 -7
  160. package/src/core/artifacts/index.ts +45 -12
  161. package/src/core/context/index.ts +207 -272
  162. package/src/core/context/observation.ts +40 -9
  163. package/src/core/events/v3/authority-outbox.ts +0 -56
  164. package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
  165. package/src/core/events/v3/producers/recorder.ts +68 -94
  166. package/src/core/events/v3/runtime-build.ts +62 -0
  167. package/src/core/governor/index.ts +1 -2
  168. package/src/core/governor/service-read.ts +15 -114
  169. package/src/core/governor/service.ts +36 -220
  170. package/src/core/hooks/cli.ts +85 -47
  171. package/src/core/hooks/resolve/owner.ts +2 -2
  172. package/src/core/resources/sampler.ts +3 -2
  173. package/src/core/resources/service-status.ts +6 -35
  174. package/src/core/resources/service.ts +23 -133
  175. package/src/core/resources/status.ts +3 -2
  176. package/src/core/semantic/service.ts +8 -2
  177. package/src/core/servers/index.ts +86 -228
  178. package/src/core/servers/tunnels.ts +3 -55
  179. package/src/core/storage/durable-history.ts +52 -20
  180. package/src/core/storage/segments.ts +79 -17
  181. package/src/core/supervisor/service.ts +33 -155
  182. package/src/core/supervisor/services.ts +2 -2
  183. package/src/core/supervisor/status.ts +10 -85
  184. package/src/core/work/state.ts +7 -7
  185. package/src/core/workflow/engine.ts +22 -74
  186. package/src/core/workflow/index.ts +2 -13
  187. package/src/core/workflow/live-session-v3.ts +5 -146
  188. package/src/core/workflow/proof.ts +40 -104
  189. package/src/core/workflow/resume-lock.ts +179 -0
  190. package/src/core/workflow/run-state.ts +3 -207
  191. package/src/core/workflow/spawn-claude.ts +0 -18
  192. package/src/core/workflow/spawn-codex.ts +0 -13
  193. package/src/core/workflow/spawn-cursor.ts +0 -18
  194. package/src/core/workflow/spawn-opencode.ts +0 -14
  195. package/src/core/workflow/types.ts +11 -41
  196. package/src/core/workflow/workspaces/leases.ts +1 -1
  197. package/src/core/workflow/workspaces/local-git.ts +120 -239
  198. package/src/core/workflow/workspaces/ownership.ts +3 -158
  199. package/src/core/workflow/workspaces/reclaim.ts +3 -9
  200. package/src/core/workflow/workspaces/recovery.ts +1 -1
  201. package/src/core/workflow/workspaces/state.ts +1 -5
  202. package/src/lib/identities/assume.ts +0 -4
  203. package/src/lib/tunnel/state.ts +15 -70
  204. package/dist/commands/turn-rollback.d.ts +0 -5
  205. package/dist/commands/turn-rollback.d.ts.map +0 -1
  206. package/dist/commands/turn-rollback.js +0 -76
  207. package/dist/core/agents/state/process-handle.d.ts +0 -26
  208. package/dist/core/agents/state/process-handle.d.ts.map +0 -1
  209. package/dist/core/agents/state/process-handle.js +0 -121
  210. package/dist/core/agents/state/unchanged-file.d.ts +0 -44
  211. package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
  212. package/dist/core/agents/state/unchanged-file.js +0 -390
  213. package/dist/core/events/v3/generation-custody.d.ts +0 -5
  214. package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
  215. package/dist/core/events/v3/generation-custody.js +0 -50
  216. package/dist/core/turn-rollback/content.d.ts +0 -53
  217. package/dist/core/turn-rollback/content.d.ts.map +0 -1
  218. package/dist/core/turn-rollback/content.js +0 -396
  219. package/dist/core/turn-rollback/index.d.ts +0 -72
  220. package/dist/core/turn-rollback/index.d.ts.map +0 -1
  221. package/dist/core/turn-rollback/index.js +0 -443
  222. package/dist/core/workflow/failure-evidence.d.ts +0 -16
  223. package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
  224. package/dist/core/workflow/failure-evidence.js +0 -114
  225. package/dist/core/workflow/recovery-audit.d.ts +0 -30
  226. package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
  227. package/dist/core/workflow/recovery-audit.js +0 -116
  228. package/src/commands/turn-rollback.ts +0 -111
  229. package/src/core/agents/state/process-handle.ts +0 -165
  230. package/src/core/agents/state/unchanged-file.ts +0 -456
  231. package/src/core/events/v3/generation-custody.ts +0 -59
  232. package/src/core/turn-rollback/content.ts +0 -499
  233. package/src/core/turn-rollback/index.ts +0 -580
  234. package/src/core/workflow/failure-evidence.ts +0 -141
  235. package/src/core/workflow/recovery-audit.ts +0 -181
@@ -20,7 +20,7 @@
20
20
  * automatically.
21
21
  */
22
22
 
23
- import { createHash, randomUUID } from "node:crypto";
23
+ import { createHash } from "node:crypto";
24
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
25
25
  import { hostname } from "node:os";
26
26
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
@@ -41,7 +41,6 @@ import {
41
41
  import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
42
42
  import { resolveOwner } from "../hooks/resolve/owner.ts";
43
43
  import { writePrivateJsonAtomic } from "../storage/atomic-json.ts";
44
- import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
45
44
  import {
46
45
  ancestry,
47
46
  establishedConnectionCounts,
@@ -53,6 +52,7 @@ import {
53
52
  scanSupport,
54
53
  } from "./net.ts";
55
54
 
55
+ // Host supervisors verify and signal their own children with the same checks.
56
56
  export {
57
57
  processStartToken,
58
58
  signalIfSameGeneration,
@@ -74,9 +74,9 @@ export {
74
74
  readProcessInfo,
75
75
  scanSupport,
76
76
  } from "./net.ts";
77
- export { createTunnelReadinessVerifier, tunnelServerId, tunnelServerInput } from "./tunnels.ts";
77
+ export { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
78
78
 
79
- export const SERVER_RECORD_SCHEMA_VERSION = 2;
79
+ export const SERVER_RECORD_SCHEMA_VERSION = 1;
80
80
  export const SERVER_DEFAULT_IDLE_HOURS = 2;
81
81
  export const SERVER_DEFAULT_OWNER_STALE_HOURS = 24;
82
82
  const TOUCH_INTERVAL_MS = 60_000;
@@ -86,7 +86,6 @@ export type ServerKind = "service" | "session";
86
86
  export interface ServerRecord {
87
87
  schema_version: typeof SERVER_RECORD_SCHEMA_VERSION;
88
88
  id: string;
89
- registration_id: string;
90
89
  kind: ServerKind;
91
90
  /** Free-form family name: `preview`, `tunnel`, `dashboard`, ... */
92
91
  type: string;
@@ -217,57 +216,39 @@ export function registerServer(
217
216
  )
218
217
  throw new Error(`invalid server port: ${input.port}`);
219
218
  const id = input.id ?? serverId(input.type, input.scope);
220
- return withServerMutation(coordRoot, id, () => {
221
- const path = recordPath(coordRoot, id);
222
- const now = new Date().toISOString();
223
- const startToken = input.start_token ?? processStartToken(pid);
224
- const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
225
- const pidsStartTokens: Record<string, string> = {};
226
- for (const auxiliaryPid of pids) {
227
- const token = processStartToken(auxiliaryPid);
228
- if (token) pidsStartTokens[auxiliaryPid] = token;
229
- }
230
- const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
231
- const record: ServerRecord = {
232
- schema_version: SERVER_RECORD_SCHEMA_VERSION,
233
- id,
234
- registration_id: randomUUID(),
235
- kind: input.kind,
236
- type: input.type,
237
- label: input.label ?? id,
238
- ...(input.url ? { url: input.url } : {}),
239
- ...(input.port ? { port: input.port } : {}),
240
- pid,
241
- ...(startToken ? { start_token: startToken } : {}),
242
- ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
243
- host: hostname(),
244
- ...(input.scope ? { scope: resolve(input.scope) } : {}),
245
- ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
246
- ...(input.log ? { log: resolve(input.log) } : {}),
247
- ...(owner ? { owner } : {}),
248
- started_at: input.started_at ?? now,
249
- registered_at: now,
250
- last_active_at: now,
251
- };
252
- if (
253
- input.start_token !== undefined &&
254
- verifyProcessGeneration(pid, input.start_token) !== "same"
255
- )
256
- throw new Error(`pid ${pid} generation could not be verified before registration`);
257
- if (existsSync(path)) {
258
- const existing = readRecord(path);
259
- if (!existing)
260
- throw new Error(
261
- `registration ${id} custody is unknown: invalid or older server record retained`,
262
- );
263
- if (sameProcessCustody(existing, record) && serverState(existing) === "running")
264
- return existing;
265
- if (existing.host !== hostname() || !allProcessesAbsent(existing))
266
- throw new Error(`registration ${id} custody is held by a live or unverifiable server`);
267
- }
268
- writePrivateJsonAtomic(path, record);
269
- return record;
270
- });
219
+ const now = new Date().toISOString();
220
+ const startToken = input.start_token ?? processStartToken(pid);
221
+ const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
222
+ const pidsStartTokens: Record<string, string> = {};
223
+ for (const auxiliaryPid of pids) {
224
+ const token = processStartToken(auxiliaryPid);
225
+ if (token) pidsStartTokens[auxiliaryPid] = token;
226
+ }
227
+ const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
228
+ const record: ServerRecord = {
229
+ schema_version: SERVER_RECORD_SCHEMA_VERSION,
230
+ id,
231
+ kind: input.kind,
232
+ type: input.type,
233
+ label: input.label ?? id,
234
+ ...(input.url ? { url: input.url } : {}),
235
+ ...(input.port ? { port: input.port } : {}),
236
+ pid,
237
+ ...(startToken ? { start_token: startToken } : {}),
238
+ ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
239
+ host: hostname(),
240
+ ...(input.scope ? { scope: resolve(input.scope) } : {}),
241
+ ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
242
+ ...(input.log ? { log: resolve(input.log) } : {}),
243
+ ...(owner ? { owner } : {}),
244
+ started_at: input.started_at ?? now,
245
+ registered_at: now,
246
+ last_active_at: now,
247
+ };
248
+ if (input.start_token !== undefined && verifyProcessGeneration(pid, input.start_token) !== "same")
249
+ throw new Error(`pid ${pid} generation could not be verified before registration`);
250
+ writePrivateJsonAtomic(recordPath(coordRoot, id), record);
251
+ return record;
271
252
  }
272
253
 
273
254
  /** Best-effort registration that never throws (for server start paths). */
@@ -293,132 +274,39 @@ function callingOwner(coordRoot: string): ServerRecord["owner"] {
293
274
  }
294
275
  }
295
276
 
296
- /** Remove only the exact registration receipt observed by the caller. */
297
- export function unregisterServer(record: ServerRecord, options?: ServerOptions): boolean {
298
- const coordRoot = rootOf(options);
299
- if (!coordRoot || !record || !isValidServerId(record.id)) return false;
300
- return withServerMutation(coordRoot, record.id, () => removeServerReceipt(coordRoot, record));
301
- }
302
-
303
- /** Hold the exact registration receipt until a synchronous or asynchronous effect settles. */
304
- export function withServerRegistrationCustody<T>(
305
- record: ServerRecord,
306
- options: ServerOptions,
307
- effect: () => PromiseLike<T>,
308
- ): Promise<T>;
309
- export function withServerRegistrationCustody<T>(
310
- record: ServerRecord,
311
- options: ServerOptions,
312
- effect: () => T,
313
- ): T;
314
- export function withServerRegistrationCustody<T>(
315
- record: ServerRecord,
316
- options: ServerOptions,
317
- effect: () => T | PromiseLike<T>,
318
- ): T | Promise<T> {
277
+ /**
278
+ * Remove a server's record. With `pid`, only a record still naming that pid
279
+ * is removed, so a replacement that re-registered the same id survives.
280
+ */
281
+ export function unregisterServer(id: string, options?: ServerOptions & { pid?: number }): boolean {
319
282
  const coordRoot = rootOf(options);
320
- if (!coordRoot) throw new Error("registration custody root is unavailable");
321
- const lease = serverMutationLease(coordRoot, record.id);
322
- try {
323
- const current = readServer(record.id, { coordRoot });
324
- if (
325
- !current ||
326
- current.registration_id !== record.registration_id ||
327
- !sameProcessCustody(current, record)
328
- )
329
- throw new Error(`registration ${record.id} custody changed`);
330
- const result = effect();
331
- if (
332
- result !== null &&
333
- (typeof result === "object" || typeof result === "function") &&
334
- typeof (result as PromiseLike<T>).then === "function"
335
- )
336
- return Promise.resolve(result).finally(() => lease.release());
337
- lease.release();
338
- return result as T;
339
- } catch (error) {
340
- lease.release();
341
- throw error;
283
+ if (!coordRoot || !isValidServerId(id)) return false;
284
+ const path = recordPath(coordRoot, id);
285
+ if (options?.pid !== undefined) {
286
+ const record = readRecord(path);
287
+ if (record && record.pid !== options.pid) return false;
342
288
  }
343
- }
344
-
345
- function removeServerReceipt(coordRoot: string, record: ServerRecord): boolean {
346
- const path = recordPath(coordRoot, record.id);
347
- const current = readRecord(path);
348
- if (
349
- !current ||
350
- current.registration_id !== record.registration_id ||
351
- !sameProcessCustody(current, record)
352
- )
353
- return false;
354
- rmSync(path);
289
+ if (!existsSync(path)) return false;
290
+ rmSync(path, { force: true });
355
291
  return true;
356
292
  }
357
293
 
358
- function serverMutationLease(coordRoot: string, id: string) {
359
- recordPath(coordRoot, id);
360
- const scope = `server-registration-${id}`;
361
- return acquireNoClobberLease({
362
- path: join(serversDir(coordRoot), ".custody", id),
363
- scope,
364
- authoritySha256: createHash("sha256").update(scope).digest("hex"),
365
- staleAfterMs: 1,
366
- });
367
- }
368
-
369
- function withServerMutation<T>(coordRoot: string, id: string, operation: () => T): T {
370
- const lease = serverMutationLease(coordRoot, id);
371
- try {
372
- return operation();
373
- } finally {
374
- lease.release();
375
- }
376
- }
377
-
378
- function sameProcessCustody(a: ServerRecord, b: ServerRecord): boolean {
379
- const helpers = (record: ServerRecord) =>
380
- [...new Set(record.pids ?? [])]
381
- .sort((x, y) => x - y)
382
- .map((pid) => [pid, record.pids_start_tokens?.[pid] ?? null]);
383
- return (
384
- a.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
385
- b.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
386
- a.id === b.id &&
387
- a.pid === b.pid &&
388
- a.start_token === b.start_token &&
389
- a.host === b.host &&
390
- a.kind === b.kind &&
391
- a.type === b.type &&
392
- a.scope === b.scope &&
393
- a.owner?.instance_id === b.owner?.instance_id &&
394
- JSON.stringify(helpers(a)) === JSON.stringify(helpers(b))
395
- );
396
- }
397
-
398
294
  export function readServer(id: string, options?: ServerOptions): ServerRecord | null {
399
295
  const coordRoot = rootOf(options);
400
296
  if (!coordRoot || !isValidServerId(id)) return null;
401
- const path = recordPath(coordRoot, id);
402
- const record = readRecord(path);
403
- if (!record && existsSync(path))
404
- throw new Error(
405
- `server record ${id} is invalid or uses an older schema; retained without custody`,
406
- );
407
- return record;
297
+ return readRecord(recordPath(coordRoot, id));
408
298
  }
409
299
 
410
300
  /** Mark a server as used now. */
411
301
  export function touchServer(id: string, options?: ServerOptions & { now?: Date }): void {
412
302
  const coordRoot = rootOf(options);
413
303
  if (!coordRoot || !isValidServerId(id)) return;
414
- withServerMutation(coordRoot, id, () => {
415
- const path = recordPath(coordRoot, id);
416
- const record = readRecord(path);
417
- if (!record) return;
418
- writePrivateJsonAtomic(path, {
419
- ...record,
420
- last_active_at: (options?.now ?? new Date()).toISOString(),
421
- });
304
+ const path = recordPath(coordRoot, id);
305
+ const record = readRecord(path);
306
+ if (!record) return;
307
+ writePrivateJsonAtomic(path, {
308
+ ...record,
309
+ last_active_at: (options?.now ?? new Date()).toISOString(),
422
310
  });
423
311
  }
424
312
 
@@ -447,8 +335,6 @@ function readRecord(path: string): ServerRecord | null {
447
335
  if (
448
336
  value?.schema_version !== SERVER_RECORD_SCHEMA_VERSION ||
449
337
  typeof value.id !== "string" ||
450
- typeof value.registration_id !== "string" ||
451
- !/^[0-9a-f-]{36}$/.test(value.registration_id) ||
452
338
  !Number.isSafeInteger(value.pid) ||
453
339
  (value.kind !== "service" && value.kind !== "session")
454
340
  )
@@ -466,14 +352,8 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
466
352
  if (!existsSync(dir)) return [];
467
353
  return readdirSync(dir)
468
354
  .filter((name) => name.endsWith(".json"))
469
- .map((name) => {
470
- const record = readRecord(join(dir, name));
471
- if (!record)
472
- throw new Error(
473
- `server record ${name} is invalid or uses an older schema; retained without custody`,
474
- );
475
- return record;
476
- })
355
+ .map((name) => readRecord(join(dir, name)))
356
+ .filter((record): record is ServerRecord => record !== null)
477
357
  .sort(
478
358
  (a, b) =>
479
359
  a.kind.localeCompare(b.kind) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
@@ -567,7 +447,7 @@ export function listServers(options?: ListServersOptions): ServersReport {
567
447
  records.forEach((record, i) => {
568
448
  const state = states[i]!;
569
449
  if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
570
- unregisterServer(record, { coordRoot });
450
+ unregisterServer(record.id, { coordRoot, pid: record.pid });
571
451
  pruned.push(record.id);
572
452
  return;
573
453
  }
@@ -722,8 +602,8 @@ export interface ServerProcessDependencies extends ProcessGenerationSignalDepend
722
602
 
723
603
  /**
724
604
  * Stop a verified server with SIGTERM, then SIGKILL after the grace period.
725
- * Every signal targets one retained process handle, including auxiliary processes.
726
- * Each target requires a fresh generation check. Unknown or different generations retain the record.
605
+ * Each signal, including the process group and auxiliary processes, requires
606
+ * a fresh generation check. Unknown or different generations retain the record.
727
607
  */
728
608
  export async function stopServer(
729
609
  record: ServerRecord,
@@ -733,47 +613,6 @@ export async function stopServer(
733
613
  },
734
614
  ): Promise<StopResult> {
735
615
  const coordRoot = rootOf(options);
736
- if (!coordRoot)
737
- return {
738
- id: record.id,
739
- stopped: false,
740
- method: "refused",
741
- generation: "unknown",
742
- detail: "registration custody root is unavailable",
743
- };
744
- let lease: ReturnType<typeof serverMutationLease>;
745
- try {
746
- lease = serverMutationLease(coordRoot, record.id);
747
- } catch {
748
- return {
749
- id: record.id,
750
- stopped: false,
751
- method: "refused",
752
- generation: "unknown",
753
- detail: "registration custody is unavailable",
754
- };
755
- }
756
- try {
757
- const current = readServer(record.id, { coordRoot });
758
- if (!current || current.registration_id !== record.registration_id)
759
- return {
760
- id: record.id,
761
- stopped: false,
762
- method: "refused",
763
- generation: "unknown",
764
- detail: "registration custody changed; retained current server",
765
- };
766
- return await stopServerWithCustody(record, coordRoot, options);
767
- } finally {
768
- lease.release();
769
- }
770
- }
771
-
772
- async function stopServerWithCustody(
773
- record: ServerRecord,
774
- coordRoot: string,
775
- options?: ServerOptions & { graceMs?: number; signalDependencies?: ServerProcessDependencies },
776
- ): Promise<StopResult> {
777
616
  const graceMs = options?.graceMs ?? 5_000;
778
617
  const dependencies = options?.signalDependencies ?? {};
779
618
  const targets = [
@@ -790,7 +629,8 @@ async function stopServerWithCustody(
790
629
  detail: `pid ${pid} generation is ${generation}; left the server record in place`,
791
630
  });
792
631
  const finish = (result: StopResult): StopResult => {
793
- if (result.stopped || result.method === "not-running") removeServerReceipt(coordRoot, record);
632
+ if (result.stopped || result.method === "not-running")
633
+ unregisterServer(record.id, { coordRoot, pid: record.pid });
794
634
  return result;
795
635
  };
796
636
  if (record.host !== hostname())
@@ -812,16 +652,16 @@ async function stopServerWithCustody(
812
652
  if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
813
653
  return finish({ id: record.id, stopped: false, method: "not-running" });
814
654
 
815
- const current = readServer(record.id, { coordRoot });
816
- if (!current || !sameProcessCustody(current, record)) return refuse(record.pid, "unknown");
817
655
  for (const name of ["SIGTERM", "SIGKILL"] as const) {
818
656
  for (const target of targets) {
819
657
  const presence = processPresence(target.pid, dependencies.probePid);
820
658
  if (presence === "absent") continue;
821
659
  if (presence === "unknown") return refuse(target.pid, "unknown");
822
- const result = signalIfSameGeneration(target.pid, target.token, name, dependencies);
823
- if (!result.sent || result.verdict !== "same")
824
- return refuse(target.pid, result.verdict === "same" ? "unknown" : result.verdict);
660
+ const result =
661
+ target.pid === record.pid
662
+ ? signalServerLeader(target.pid, target.token, name, dependencies)
663
+ : signalIfSameGeneration(target.pid, target.token, name, dependencies);
664
+ if (result.verdict !== "same") return refuse(target.pid, result.verdict);
825
665
  }
826
666
  const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
827
667
  if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
@@ -837,6 +677,24 @@ async function stopServerWithCustody(
837
677
  };
838
678
  }
839
679
 
680
+ function signalServerLeader(
681
+ pid: number,
682
+ token: string | undefined,
683
+ name: NodeJS.Signals,
684
+ dependencies: ServerProcessDependencies,
685
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
686
+ // The leader's generation authorizes its group. A failed group signal must
687
+ // not reuse that observation for the direct-PID fallback.
688
+ const kill = dependencies.kill ?? process.kill;
689
+ const group = signalIfSameGeneration(pid, token, name, {
690
+ readToken: dependencies.readToken,
691
+ kill: (leader, signal) => kill(-leader, signal),
692
+ });
693
+ return group.sent || group.verdict !== "same"
694
+ ? group
695
+ : signalIfSameGeneration(pid, token, name, dependencies);
696
+ }
697
+
840
698
  async function waitForExit(
841
699
  targets: Array<{ pid: number; token: string | undefined }>,
842
700
  waitMs: number,
@@ -2,59 +2,12 @@
2
2
  * Tunnels as registry services.
3
3
  *
4
4
  * `tunnel up` and `tunnel reload` register the new gate; `tunnel down` removes
5
- * its record. Missing original witnesses remain unknown and cannot authorize actions.
5
+ * its record. Older tunnels need an explicit adoption of an observed listener.
6
6
  */
7
7
 
8
- import { hostname } from "node:os";
9
8
  import { resolve } from "node:path";
10
- import {
11
- gateLogFile,
12
- type TunnelReadinessVerifier,
13
- type TunnelState,
14
- } from "../../lib/tunnel/state.ts";
15
- import {
16
- type RegisterServerInput,
17
- readServer,
18
- type ServerOptions,
19
- verifyProcessGeneration,
20
- withServerRegistrationCustody,
21
- } from "./index.ts";
22
-
23
- /** Verify only witnesses saved by the original tunnel registration. */
24
- export function createTunnelReadinessVerifier(options: ServerOptions): TunnelReadinessVerifier {
25
- return {
26
- observeProcess(pid, originalToken) {
27
- try {
28
- process.kill(pid, 0);
29
- } catch (error) {
30
- const code = (error as NodeJS.ErrnoException).code;
31
- if (code === "ESRCH") return "absent";
32
- return "unknown";
33
- }
34
- return verifyProcessGeneration(pid, originalToken);
35
- },
36
- withRegistration(state, inspect) {
37
- try {
38
- const original = readServer(tunnelServerId(state.name), options);
39
- if (
40
- !original ||
41
- original.host !== hostname() ||
42
- original.kind !== "service" ||
43
- original.type !== "tunnel" ||
44
- original.registration_id !== state.registration_id ||
45
- original.pid !== state.gate_pid ||
46
- original.start_token !== state.gate_start_token ||
47
- original.port !== state.gate_port ||
48
- original.url !== state.url
49
- )
50
- return "unknown";
51
- return withServerRegistrationCustody(original, options, inspect);
52
- } catch {
53
- return "unknown";
54
- }
55
- },
56
- };
57
- }
9
+ import { gateLogFile, type TunnelState } from "../../lib/tunnel/state.ts";
10
+ import type { RegisterServerInput } from "./index.ts";
58
11
 
59
12
  export function tunnelServerId(name: string): string {
60
13
  return `tunnel-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
@@ -62,10 +15,6 @@ export function tunnelServerId(name: string): string {
62
15
 
63
16
  /** Register the new gate; saved helper PIDs have no generation witnesses. */
64
17
  export function tunnelServerInput(state: TunnelState, coordRoot: string): RegisterServerInput {
65
- if (Object.hasOwn(state, "provider_pid") || Object.hasOwn(state, "provider_start_token"))
66
- throw new Error("obsolete tunnel provider authority is unknown; state retained");
67
- if (!state.gate_start_token)
68
- throw new Error("tunnel gate generation witness is unavailable; state retained");
69
18
  return {
70
19
  id: tunnelServerId(state.name),
71
20
  kind: "service",
@@ -74,7 +23,6 @@ export function tunnelServerInput(state: TunnelState, coordRoot: string): Regist
74
23
  url: state.url,
75
24
  port: state.gate_port,
76
25
  pid: state.gate_pid,
77
- start_token: state.gate_start_token,
78
26
  log: resolve(coordRoot, ".cache", "tunnel", gateLogFile(state.name)),
79
27
  cwd: coordRoot,
80
28
  started_at: state.started_at,
@@ -521,28 +521,49 @@ function acquireLease(lease: string): LeaseOwner {
521
521
  pid: process.pid,
522
522
  acquired_at: new Date().toISOString(),
523
523
  };
524
- let claimed: DirectoryIdentity | undefined;
524
+ if (claimLease(lease, parent, owner)) return owner;
525
+ if (recoverStaleLease(lease, DURABLE_HISTORY_LEASE_STALE_MS)) continue;
526
+ throw new Error(`durable history lease busy: ${lease}`);
527
+ }
528
+ throw new Error(`durable history lease busy: ${lease}`);
529
+ }
530
+
531
+ /**
532
+ * Publish a complete lease in one rename. The owner record is written and synced
533
+ * in a private claim directory first, so a writer stopped at any point leaves
534
+ * either no lease or one with an owner. A held lease always has an owner record,
535
+ * here and in the earlier create-then-write protocol, so a rename that replaces
536
+ * an empty lease directory never takes a held lease. A lease with any entry makes
537
+ * the rename fail and the caller reports it busy.
538
+ */
539
+ function claimLease(lease: string, parent: DirectoryIdentity, owner: LeaseOwner): boolean {
540
+ const claim = `${lease}.claim-${process.pid}-${owner.owner_id}`;
541
+ try {
542
+ mkdirSync(claim, { mode: stateDirMode() });
543
+ assertDirectoryWithinParent(claim, parent);
544
+ const claimed = captureDirectoryIdentity(claim);
545
+ writeOwner(join(claim, "owner.json"), owner);
546
+ syncDirectory(claimed);
547
+ renameSync(claim, lease);
548
+ } catch {
549
+ return false;
550
+ } finally {
525
551
  try {
526
- mkdirSync(lease, { mode: stateDirMode() });
527
- assertDirectoryWithinParent(lease, parent);
528
- claimed = captureDirectoryIdentity(lease);
529
- writeOwner(join(lease, "owner.json"), owner);
530
- syncDirectory(claimed);
531
- return owner;
552
+ rmSync(claim, { recursive: true, force: true });
532
553
  } catch {
533
- if (claimed) {
534
- try {
535
- assertDirectoryIdentity(claimed);
536
- rmSync(lease, { recursive: true, force: false });
537
- } catch {
538
- // Never remove a lease after its directory identity changes.
539
- }
540
- }
541
- if (recoverStaleLease(lease, DURABLE_HISTORY_LEASE_STALE_MS)) continue;
542
- throw new Error(`durable history lease busy: ${lease}`);
554
+ // A leftover claim directory never blocks a lease.
543
555
  }
544
556
  }
545
- throw new Error(`durable history lease busy: ${lease}`);
557
+ try {
558
+ assertDirectoryWithinParent(lease, parent);
559
+ const current = JSON.parse(completeJsonl(join(lease, "owner.json"))) as LeaseOwner;
560
+ if (current.owner_id !== owner.owner_id) return false;
561
+ syncDirectory(parent);
562
+ return true;
563
+ } catch {
564
+ releaseOwnedLease(lease, owner);
565
+ return false;
566
+ }
546
567
  }
547
568
 
548
569
  function writeOwner(path: string, owner: LeaseOwner): void {
@@ -595,7 +616,7 @@ function recoverStaleLease(lease: string, staleMs: number): boolean {
595
616
  try {
596
617
  assertDirectoryIdentity(leaseIdentity);
597
618
  if (completeJsonl(ownerPath) !== raw) return false;
598
- rmSync(lease, { recursive: true, force: false });
619
+ retireLease(lease, randomUUID());
599
620
  return true;
600
621
  } catch {
601
622
  return false;
@@ -606,12 +627,23 @@ function releaseOwnedLease(lease: string, owner: LeaseOwner): void {
606
627
  try {
607
628
  const current = JSON.parse(completeJsonl(join(lease, "owner.json"))) as LeaseOwner;
608
629
  if (current.owner_id !== owner.owner_id) return;
609
- rmSync(lease, { recursive: true, force: false });
630
+ retireLease(lease, owner.owner_id);
610
631
  } catch {
611
632
  // A missing or replaced lease does not belong to this operation.
612
633
  }
613
634
  }
614
635
 
636
+ /**
637
+ * Move a lease aside before deleting it. Its path goes from held to absent in
638
+ * one rename and is never an empty directory that a concurrent claim could
639
+ * replace while a recursive removal is still running.
640
+ */
641
+ function retireLease(lease: string, tag: string): void {
642
+ const retired = `${lease}.retired-${process.pid}-${tag}`;
643
+ renameSync(lease, retired);
644
+ rmSync(retired, { recursive: true, force: true });
645
+ }
646
+
615
647
  function processIsAlive(pid: number): boolean {
616
648
  try {
617
649
  process.kill(pid, 0);