harnery 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +41 -48
  4. package/dist/commands/browse.d.ts.map +1 -1
  5. package/dist/commands/browse.js +38 -11
  6. package/dist/commands/diagnostics.d.ts.map +1 -1
  7. package/dist/commands/diagnostics.js +10 -0
  8. package/dist/commands/doctor.d.ts +12 -0
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +22 -7
  11. package/dist/commands/governor.js +22 -10
  12. package/dist/commands/inbox.d.ts +1 -1
  13. package/dist/commands/inbox.d.ts.map +1 -1
  14. package/dist/commands/inbox.js +55 -2
  15. package/dist/commands/rm.d.ts.map +1 -1
  16. package/dist/commands/rm.js +28 -4
  17. package/dist/commands/servers.d.ts.map +1 -1
  18. package/dist/commands/servers.js +16 -1
  19. package/dist/commands/supervisor.d.ts.map +1 -1
  20. package/dist/commands/supervisor.js +7 -2
  21. package/dist/commands/toc.d.ts +14 -0
  22. package/dist/commands/toc.d.ts.map +1 -1
  23. package/dist/commands/toc.js +7 -4
  24. package/dist/commands/workflow.js +24 -30
  25. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  26. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  27. package/dist/core/agents/live-authority-v3.js +11 -0
  28. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  29. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  30. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  31. package/dist/core/agents/mailbox.d.ts +9 -8
  32. package/dist/core/agents/mailbox.d.ts.map +1 -1
  33. package/dist/core/agents/mailbox.js +24 -17
  34. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  35. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  36. package/dist/core/agents/render/prompt-context.js +8 -12
  37. package/dist/core/agents/render/session-context.d.ts +2 -8
  38. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  39. package/dist/core/agents/render/session-context.js +7 -30
  40. package/dist/core/agents/rules/stop-hook.js +3 -3
  41. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  42. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  43. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  44. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  45. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  46. package/dist/core/agents/state/pidmap.js +5 -0
  47. package/dist/core/agents/state/proc-start.d.ts +19 -0
  48. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  49. package/dist/core/agents/state/proc-start.js +41 -0
  50. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  51. package/dist/core/agents/state/stale-sweep.js +180 -70
  52. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  53. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  54. package/dist/core/agents/state/unchanged-file.js +390 -0
  55. package/dist/core/context/index.d.ts +22 -10
  56. package/dist/core/context/index.d.ts.map +1 -1
  57. package/dist/core/context/index.js +381 -119
  58. package/dist/core/context/observation.d.ts +26 -0
  59. package/dist/core/context/observation.d.ts.map +1 -0
  60. package/dist/core/context/observation.js +11 -0
  61. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  62. package/dist/core/diagnostics/bundle.js +25 -14
  63. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  64. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  65. package/dist/core/diagnostics/path-identity.js +12 -0
  66. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  67. package/dist/core/diagnostics/sanitize.js +4 -3
  68. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  69. package/dist/core/events/v3/authority-outbox.js +29 -0
  70. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  71. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  72. package/dist/core/events/v3/generation-custody.js +50 -0
  73. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  74. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  75. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  76. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  77. package/dist/core/events/v3/producers/recorder.js +77 -64
  78. package/dist/core/governor/planning.d.ts.map +1 -1
  79. package/dist/core/governor/planning.js +14 -1
  80. package/dist/core/governor/service-read.d.ts +10 -2
  81. package/dist/core/governor/service-read.d.ts.map +1 -1
  82. package/dist/core/governor/service-read.js +105 -22
  83. package/dist/core/governor/service.d.ts +20 -2
  84. package/dist/core/governor/service.d.ts.map +1 -1
  85. package/dist/core/governor/service.js +72 -47
  86. package/dist/core/hooks/cli.js +33 -5
  87. package/dist/core/inbox/contract.d.ts +2 -1
  88. package/dist/core/inbox/contract.d.ts.map +1 -1
  89. package/dist/core/inbox/index.d.ts +2 -0
  90. package/dist/core/inbox/index.d.ts.map +1 -1
  91. package/dist/core/inbox/index.js +2 -0
  92. package/dist/core/inbox/peer-messages.d.ts +37 -0
  93. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  94. package/dist/core/inbox/peer-messages.js +72 -0
  95. package/dist/core/inbox/service.d.ts +8 -1
  96. package/dist/core/inbox/service.d.ts.map +1 -1
  97. package/dist/core/inbox/service.js +67 -21
  98. package/dist/core/inbox/targets.d.ts +22 -0
  99. package/dist/core/inbox/targets.d.ts.map +1 -0
  100. package/dist/core/inbox/targets.js +108 -0
  101. package/dist/core/inbox/watcher.js +7 -3
  102. package/dist/core/journal/index.d.ts.map +1 -1
  103. package/dist/core/journal/index.js +17 -2
  104. package/dist/core/resources/contract.d.ts +3 -0
  105. package/dist/core/resources/contract.d.ts.map +1 -1
  106. package/dist/core/resources/service-status.d.ts +14 -1
  107. package/dist/core/resources/service-status.d.ts.map +1 -1
  108. package/dist/core/resources/service-status.js +23 -13
  109. package/dist/core/resources/service.d.ts +9 -1
  110. package/dist/core/resources/service.d.ts.map +1 -1
  111. package/dist/core/resources/service.js +47 -27
  112. package/dist/core/resources/status.d.ts +2 -5
  113. package/dist/core/resources/status.d.ts.map +1 -1
  114. package/dist/core/resources/status.js +36 -7
  115. package/dist/core/servers/index.d.ts +24 -13
  116. package/dist/core/servers/index.d.ts.map +1 -1
  117. package/dist/core/servers/index.js +132 -92
  118. package/dist/core/servers/tunnels.d.ts +3 -5
  119. package/dist/core/servers/tunnels.d.ts.map +1 -1
  120. package/dist/core/servers/tunnels.js +3 -9
  121. package/dist/core/supervisor/contract.d.ts +12 -0
  122. package/dist/core/supervisor/contract.d.ts.map +1 -1
  123. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  124. package/dist/core/supervisor/explanations.js +2 -1
  125. package/dist/core/supervisor/findings.d.ts.map +1 -1
  126. package/dist/core/supervisor/findings.js +54 -9
  127. package/dist/core/supervisor/service.d.ts +9 -1
  128. package/dist/core/supervisor/service.d.ts.map +1 -1
  129. package/dist/core/supervisor/service.js +59 -29
  130. package/dist/core/supervisor/services.d.ts.map +1 -1
  131. package/dist/core/supervisor/services.js +22 -1
  132. package/dist/core/supervisor/status.d.ts +17 -2
  133. package/dist/core/supervisor/status.d.ts.map +1 -1
  134. package/dist/core/supervisor/status.js +70 -12
  135. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  136. package/dist/core/supervisor/timeline.js +2 -1
  137. package/dist/core/work/state.js +7 -7
  138. package/dist/core/workflow/engine.d.ts.map +1 -1
  139. package/dist/core/workflow/engine.js +105 -32
  140. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  141. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  142. package/dist/core/workflow/failure-evidence.js +114 -0
  143. package/dist/core/workflow/index.d.ts +1 -1
  144. package/dist/core/workflow/index.d.ts.map +1 -1
  145. package/dist/core/workflow/index.js +1 -1
  146. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  147. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  148. package/dist/core/workflow/live-session-v3.js +126 -5
  149. package/dist/core/workflow/proof.d.ts +5 -5
  150. package/dist/core/workflow/proof.d.ts.map +1 -1
  151. package/dist/core/workflow/proof.js +115 -20
  152. package/dist/core/workflow/run-state.d.ts +3 -1
  153. package/dist/core/workflow/run-state.d.ts.map +1 -1
  154. package/dist/core/workflow/run-state.js +31 -27
  155. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  156. package/dist/core/workflow/spawn-claude.js +19 -0
  157. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  158. package/dist/core/workflow/spawn-codex.js +13 -0
  159. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  160. package/dist/core/workflow/spawn-cursor.js +19 -0
  161. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  162. package/dist/core/workflow/spawn-opencode.js +14 -0
  163. package/dist/core/workflow/types.d.ts +64 -17
  164. package/dist/core/workflow/types.d.ts.map +1 -1
  165. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  166. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  167. package/dist/core/workflow/workspaces/index.js +0 -1
  168. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  169. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  170. package/dist/core/workflow/workspaces/leases.js +30 -16
  171. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  172. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  173. package/dist/core/workflow/workspaces/local-git.js +270 -6
  174. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  175. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  176. package/dist/core/workflow/workspaces/ownership.js +194 -0
  177. package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
  178. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  179. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  180. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  181. package/dist/core/workflow/workspaces/recovery.js +21 -13
  182. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  183. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  184. package/dist/core/workflow/workspaces/state.js +21 -1
  185. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  186. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  187. package/dist/lib/browser/windows-chrome.js +262 -73
  188. package/dist/lib/docs-links.d.ts.map +1 -1
  189. package/dist/lib/docs-links.js +45 -2
  190. package/dist/lib/guarded-remove.d.ts +7 -0
  191. package/dist/lib/guarded-remove.d.ts.map +1 -1
  192. package/dist/lib/guarded-remove.js +106 -12
  193. package/dist/lib/identities/assume.d.ts.map +1 -1
  194. package/dist/lib/identities/assume.js +4 -0
  195. package/dist/lib/zip/index.d.ts +41 -0
  196. package/dist/lib/zip/index.d.ts.map +1 -0
  197. package/dist/lib/zip/index.js +398 -0
  198. package/package.json +7 -1
  199. package/src/commands/agents.ts +54 -63
  200. package/src/commands/browse.ts +50 -14
  201. package/src/commands/diagnostics.ts +10 -0
  202. package/src/commands/doctor.ts +26 -7
  203. package/src/commands/governor.ts +23 -10
  204. package/src/commands/inbox.ts +52 -2
  205. package/src/commands/rm.ts +43 -4
  206. package/src/commands/servers.ts +16 -4
  207. package/src/commands/supervisor.ts +7 -2
  208. package/src/commands/toc.ts +14 -6
  209. package/src/commands/workflow.ts +30 -30
  210. package/src/core/agents/live-authority-v3.ts +17 -0
  211. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  212. package/src/core/agents/mailbox.ts +26 -17
  213. package/src/core/agents/render/prompt-context.ts +10 -12
  214. package/src/core/agents/render/session-context.ts +9 -32
  215. package/src/core/agents/rules/stop-hook.ts +3 -3
  216. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  217. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  218. package/src/core/agents/state/pidmap.ts +13 -0
  219. package/src/core/agents/state/proc-start.ts +57 -0
  220. package/src/core/agents/state/stale-sweep.ts +247 -82
  221. package/src/core/agents/state/unchanged-file.ts +456 -0
  222. package/src/core/context/index.ts +446 -136
  223. package/src/core/context/observation.ts +29 -0
  224. package/src/core/diagnostics/bundle.ts +23 -18
  225. package/src/core/diagnostics/path-identity.ts +15 -0
  226. package/src/core/diagnostics/sanitize.ts +4 -3
  227. package/src/core/events/v3/authority-outbox.ts +56 -0
  228. package/src/core/events/v3/generation-custody.ts +59 -0
  229. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  230. package/src/core/events/v3/producers/recorder.ts +94 -68
  231. package/src/core/governor/planning.ts +20 -8
  232. package/src/core/governor/service-read.ts +128 -20
  233. package/src/core/governor/service.ts +119 -42
  234. package/src/core/hooks/cli.ts +33 -5
  235. package/src/core/inbox/contract.ts +2 -1
  236. package/src/core/inbox/index.ts +2 -0
  237. package/src/core/inbox/peer-messages.ts +94 -0
  238. package/src/core/inbox/service.ts +88 -22
  239. package/src/core/inbox/targets.ts +148 -0
  240. package/src/core/inbox/watcher.ts +8 -9
  241. package/src/core/journal/index.ts +19 -2
  242. package/src/core/resources/contract.ts +4 -0
  243. package/src/core/resources/service-status.ts +35 -12
  244. package/src/core/resources/service.ts +71 -24
  245. package/src/core/resources/status.ts +57 -8
  246. package/src/core/servers/index.ts +177 -91
  247. package/src/core/servers/tunnels.ts +3 -11
  248. package/src/core/supervisor/contract.ts +13 -0
  249. package/src/core/supervisor/explanations.ts +4 -1
  250. package/src/core/supervisor/findings.ts +68 -10
  251. package/src/core/supervisor/service.ts +88 -26
  252. package/src/core/supervisor/services.ts +26 -8
  253. package/src/core/supervisor/status.ts +89 -12
  254. package/src/core/supervisor/timeline.ts +2 -1
  255. package/src/core/work/state.ts +7 -7
  256. package/src/core/workflow/engine.ts +131 -49
  257. package/src/core/workflow/failure-evidence.ts +141 -0
  258. package/src/core/workflow/index.ts +0 -1
  259. package/src/core/workflow/live-session-v3.ts +146 -5
  260. package/src/core/workflow/proof.ts +134 -26
  261. package/src/core/workflow/run-state.ts +31 -29
  262. package/src/core/workflow/spawn-claude.ts +18 -0
  263. package/src/core/workflow/spawn-codex.ts +13 -0
  264. package/src/core/workflow/spawn-cursor.ts +18 -0
  265. package/src/core/workflow/spawn-opencode.ts +14 -0
  266. package/src/core/workflow/types.ts +51 -17
  267. package/src/core/workflow/workspaces/index.ts +0 -1
  268. package/src/core/workflow/workspaces/leases.ts +48 -17
  269. package/src/core/workflow/workspaces/local-git.ts +315 -5
  270. package/src/core/workflow/workspaces/ownership.ts +232 -0
  271. package/src/core/workflow/workspaces/reclaim.ts +58 -0
  272. package/src/core/workflow/workspaces/recovery.ts +25 -13
  273. package/src/core/workflow/workspaces/state.ts +20 -0
  274. package/src/lib/browser/windows-chrome.ts +346 -86
  275. package/src/lib/docs-links.ts +49 -2
  276. package/src/lib/guarded-remove.ts +132 -14
  277. package/src/lib/identities/assume.ts +4 -0
  278. package/src/lib/zip/index.ts +483 -0
@@ -14,21 +14,25 @@
14
14
  * for `gc` once its starting agent has ended or been unobserved for a long
15
15
  * time AND nobody has used the server for the idle window.
16
16
  *
17
- * Fail-closed rules: a record whose pid was recycled is treated as dead, never
18
- * signalled; an unreadable coordination view makes every owner `unknown`,
17
+ * Fail-closed rules: a record whose pid was recycled is never signalled;
18
+ * an unreadable start token or coordination view is `unknown`,
19
19
  * which `gc` never stops; a record without an owner is never stopped
20
20
  * automatically.
21
21
  */
22
22
 
23
- import { spawnSync } from "node:child_process";
24
23
  import { createHash } from "node:crypto";
25
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
26
25
  import { hostname } from "node:os";
27
26
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
28
- import { listStates } from "../../lib/tunnel/state.ts";
29
27
  import { resolveCoordRoot } from "../agents/coord-client.ts";
30
28
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
31
- import { checkPidToken, processStartToken } from "../agents/state/proc-start.ts";
29
+ import {
30
+ type ProcessGenerationSignalDependencies,
31
+ type ProcessGenerationVerdict,
32
+ processStartToken,
33
+ signalIfSameGeneration,
34
+ verifyProcessGeneration,
35
+ } from "../agents/state/proc-start.ts";
32
36
  import { serverPortRange } from "../config.ts";
33
37
  import {
34
38
  readCoordinationViewV3,
@@ -47,7 +51,6 @@ import {
47
51
  type ScanSupport,
48
52
  scanSupport,
49
53
  } from "./net.ts";
50
- import { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
51
54
 
52
55
  export {
53
56
  ancestry,
@@ -87,13 +90,13 @@ export interface ServerRecord {
87
90
  start_token?: string;
88
91
  /** Further processes that belong to this server (helpers, children). */
89
92
  pids?: number[];
93
+ /** Generation witnesses captured when the further processes registered. */
94
+ pids_start_tokens?: Record<string, string>;
90
95
  host: string;
91
96
  /** What the server serves, usually a directory. */
92
97
  scope?: string;
93
98
  cwd?: string;
94
99
  log?: string;
95
- /** Graceful stop command; signals are the fallback. */
96
- stop_argv?: string[];
97
100
  owner?: { instance_id: string; name?: string };
98
101
  started_at: string;
99
102
  registered_at: string;
@@ -108,11 +111,12 @@ export interface RegisterServerInput {
108
111
  url?: string;
109
112
  port?: number;
110
113
  pid?: number;
114
+ /** Preserve an observed witness when adopting, rather than probing a new generation. */
115
+ start_token?: string;
111
116
  pids?: number[];
112
117
  scope?: string;
113
118
  cwd?: string;
114
119
  log?: string;
115
- stop_argv?: string[];
116
120
  started_at?: string;
117
121
  /** `null` records no owner; omitted resolves the calling agent session. */
118
122
  owner?: { instance_id: string; name?: string } | null;
@@ -122,7 +126,7 @@ export interface ServerOptions {
122
126
  coordRoot?: string | null;
123
127
  }
124
128
 
125
- export type ServerState = "running" | "dead" | "other-host";
129
+ export type ServerState = "running" | "dead" | "other-host" | "unknown";
126
130
  export type OwnerState = "live" | "abandoned" | "ended" | "unknown" | "none";
127
131
 
128
132
  export interface ServerView {
@@ -136,6 +140,8 @@ export interface ServerView {
136
140
 
137
141
  export interface UnregisteredListener {
138
142
  pid: number;
143
+ /** Captured during the scan, before a stop can be authorized. */
144
+ start_token?: string;
139
145
  port: number;
140
146
  address: string;
141
147
  command: string;
@@ -204,7 +210,13 @@ export function registerServer(
204
210
  throw new Error(`invalid server port: ${input.port}`);
205
211
  const id = input.id ?? serverId(input.type, input.scope);
206
212
  const now = new Date().toISOString();
207
- const startToken = processStartToken(pid);
213
+ const startToken = input.start_token ?? processStartToken(pid);
214
+ const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
215
+ const pidsStartTokens: Record<string, string> = {};
216
+ for (const auxiliaryPid of pids) {
217
+ const token = processStartToken(auxiliaryPid);
218
+ if (token) pidsStartTokens[auxiliaryPid] = token;
219
+ }
208
220
  const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
209
221
  const record: ServerRecord = {
210
222
  schema_version: SERVER_RECORD_SCHEMA_VERSION,
@@ -216,19 +228,18 @@ export function registerServer(
216
228
  ...(input.port ? { port: input.port } : {}),
217
229
  pid,
218
230
  ...(startToken ? { start_token: startToken } : {}),
219
- ...(input.pids?.length
220
- ? { pids: input.pids.filter((p) => Number.isSafeInteger(p) && p > 0) }
221
- : {}),
231
+ ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
222
232
  host: hostname(),
223
233
  ...(input.scope ? { scope: resolve(input.scope) } : {}),
224
234
  ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
225
235
  ...(input.log ? { log: resolve(input.log) } : {}),
226
- ...(input.stop_argv?.length ? { stop_argv: input.stop_argv } : {}),
227
236
  ...(owner ? { owner } : {}),
228
237
  started_at: input.started_at ?? now,
229
238
  registered_at: now,
230
239
  last_active_at: now,
231
240
  };
241
+ if (input.start_token !== undefined && verifyProcessGeneration(pid, input.start_token) !== "same")
242
+ throw new Error(`pid ${pid} generation could not be verified before registration`);
232
243
  writePrivateJsonAtomic(recordPath(coordRoot, id), record);
233
244
  return record;
234
245
  }
@@ -343,18 +354,40 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
343
354
  }
344
355
 
345
356
  export function pidAlive(pid: number): boolean {
357
+ return processPresence(pid) !== "absent";
358
+ }
359
+
360
+ function processPresence(
361
+ pid: number,
362
+ probePid?: (pid: number) => boolean,
363
+ ): "present" | "absent" | "unknown" {
346
364
  try {
365
+ if (probePid) return probePid(pid) ? "present" : "absent";
347
366
  process.kill(pid, 0);
348
- return true;
367
+ return "present";
349
368
  } catch (error) {
350
- return (error as NodeJS.ErrnoException).code === "EPERM";
369
+ const code = (error as NodeJS.ErrnoException).code;
370
+ return code === "ESRCH" ? "absent" : code === "EPERM" ? "present" : "unknown";
351
371
  }
352
372
  }
353
373
 
354
- export function serverState(record: ServerRecord): ServerState {
374
+ function allProcessesAbsent(record: ServerRecord, probePid?: (pid: number) => boolean): boolean {
375
+ return [record.pid, ...(record.pids ?? [])].every(
376
+ (pid) => processPresence(pid, probePid) === "absent",
377
+ );
378
+ }
379
+
380
+ export function serverState(
381
+ record: ServerRecord,
382
+ readToken?: (pid: number) => string | null,
383
+ probePid?: (pid: number) => boolean,
384
+ ): ServerState {
355
385
  if (record.host !== hostname()) return "other-host";
356
- if (!pidAlive(record.pid)) return "dead";
357
- return checkPidToken(record.pid, record.start_token) === "mismatch" ? "dead" : "running";
386
+ const presence = processPresence(record.pid, probePid);
387
+ if (presence === "absent") return allProcessesAbsent(record, probePid) ? "dead" : "unknown";
388
+ if (presence === "unknown") return "unknown";
389
+ const verdict = verifyProcessGeneration(record.pid, record.start_token, readToken);
390
+ return verdict === "same" ? "running" : verdict === "different" ? "dead" : "unknown";
358
391
  }
359
392
 
360
393
  type OwnerReader = (instanceId: string) => OwnerState;
@@ -395,7 +428,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
395
428
  const now = options?.now ?? new Date();
396
429
  const nowMs = now.getTime();
397
430
  const ownerStaleMs = hours(options?.ownerStaleHours ?? SERVER_DEFAULT_OWNER_STALE_HOURS);
398
- adoptRunningTunnels(coordRoot);
399
431
  const records = readServerRecords({ coordRoot });
400
432
  const ownerOf = ownerReader(coordRoot, ownerStaleMs, nowMs);
401
433
  const states = records.map((record) => serverState(record));
@@ -407,7 +439,7 @@ export function listServers(options?: ListServersOptions): ServersReport {
407
439
  const servers: ServerView[] = [];
408
440
  records.forEach((record, i) => {
409
441
  const state = states[i]!;
410
- if (state === "dead" && options?.prune !== false) {
442
+ if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
411
443
  unregisterServer(record.id, { coordRoot, pid: record.pid });
412
444
  pruned.push(record.id);
413
445
  return;
@@ -446,24 +478,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
446
478
  };
447
479
  }
448
480
 
449
- /** Register running tunnels that have no current record. Returns adopted ids. */
450
- export function adoptRunningTunnels(coordRoot: string): string[] {
451
- const adopted: string[] = [];
452
- let states: ReturnType<typeof listStates>;
453
- try {
454
- states = listStates(coordRoot);
455
- } catch {
456
- return adopted;
457
- }
458
- for (const state of states) {
459
- if (!state.gate_pid || !pidAlive(state.gate_pid)) continue;
460
- const id = tunnelServerId(state.name);
461
- if (readServer(id, { coordRoot })?.pid === state.gate_pid) continue;
462
- if (tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot })) adopted.push(id);
463
- }
464
- return adopted;
465
- }
466
-
467
481
  /** Processes that browser automation and editors start; never project servers. */
468
482
  const IGNORED_LISTENER_COMMANDS = [
469
483
  /(^|\/)(chrome|chromium|chrome-headless-shell|headless_shell|msedge|firefox)( |$)/i,
@@ -481,10 +495,10 @@ export function findUnregisteredListeners(
481
495
  coordRoot: string,
482
496
  servers: Array<Pick<ServerView, "record" | "state">>,
483
497
  ): UnregisteredListener[] {
484
- const running = servers.filter((server) => server.state === "running");
498
+ const registered = servers.filter((server) => server.record.host === hostname());
485
499
  const covered = new Set<number>();
486
500
  const ports = new Set<number>();
487
- for (const { record } of running) {
501
+ for (const { record } of registered) {
488
502
  covered.add(record.pid);
489
503
  for (const pid of record.pids ?? []) covered.add(pid);
490
504
  if (record.port) ports.add(record.port);
@@ -492,7 +506,8 @@ export function findUnregisteredListeners(
492
506
  const results: UnregisteredListener[] = [];
493
507
  const seenPids = new Map<number, boolean>();
494
508
  for (const socket of listListeningSockets()) {
495
- if (ports.has(socket.port) || socket.pid === process.pid) continue;
509
+ if (ports.has(socket.port) || covered.has(socket.pid) || socket.pid === process.pid) continue;
510
+ const startToken = processStartToken(socket.pid);
496
511
  let inside = seenPids.get(socket.pid);
497
512
  const info = readProcessInfo(socket.pid);
498
513
  if (!info) continue;
@@ -506,6 +521,7 @@ export function findUnregisteredListeners(
506
521
  if (!inside) continue;
507
522
  results.push({
508
523
  pid: socket.pid,
524
+ ...(startToken ? { start_token: startToken } : {}),
509
525
  port: socket.port,
510
526
  address: socket.address,
511
527
  command: info.command.slice(0, 300),
@@ -568,77 +584,134 @@ export function planServerGc(views: ServerView[], policy?: GcPolicy): GcDecision
568
584
  export interface StopResult {
569
585
  id: string;
570
586
  stopped: boolean;
571
- method: "not-running" | "stop-command" | "signal" | "refused";
587
+ method: "not-running" | "signal" | "refused";
588
+ generation?: ProcessGenerationVerdict;
572
589
  detail?: string;
573
590
  }
574
591
 
592
+ export interface ServerProcessDependencies extends ProcessGenerationSignalDependencies {
593
+ probePid?: (pid: number) => boolean;
594
+ }
595
+
575
596
  /**
576
- * Stop one server: its graceful stop command first when recorded, then
577
- * SIGTERM to its process group, then SIGKILL after the grace period. A pid
578
- * whose start token no longer matches is never signalled.
597
+ * Stop a verified server with SIGTERM, then SIGKILL after the grace period.
598
+ * Each signal, including the process group and auxiliary processes, requires
599
+ * a fresh generation check. Unknown or different generations retain the record.
579
600
  */
580
601
  export async function stopServer(
581
602
  record: ServerRecord,
582
- options?: ServerOptions & { graceMs?: number },
603
+ options?: ServerOptions & {
604
+ graceMs?: number;
605
+ signalDependencies?: ServerProcessDependencies;
606
+ },
583
607
  ): Promise<StopResult> {
584
608
  const coordRoot = rootOf(options);
585
609
  const graceMs = options?.graceMs ?? 5_000;
610
+ const dependencies = options?.signalDependencies ?? {};
611
+ const targets = [
612
+ { pid: record.pid, token: record.start_token },
613
+ ...[...new Set(record.pids ?? [])]
614
+ .filter((pid) => pid !== record.pid)
615
+ .map((pid) => ({ pid, token: record.pids_start_tokens?.[pid] })),
616
+ ];
617
+ const refuse = (pid: number, generation: ProcessGenerationVerdict): StopResult => ({
618
+ id: record.id,
619
+ stopped: false,
620
+ method: "refused",
621
+ generation,
622
+ detail: `pid ${pid} generation is ${generation}; left the server record in place`,
623
+ });
586
624
  const finish = (result: StopResult): StopResult => {
587
625
  if (result.stopped || result.method === "not-running")
588
626
  unregisterServer(record.id, { coordRoot, pid: record.pid });
589
627
  return result;
590
628
  };
591
- const state = serverState(record);
592
- if (state === "other-host")
629
+ if (record.host !== hostname())
593
630
  return { id: record.id, stopped: false, method: "refused", detail: `runs on ${record.host}` };
594
- if (state === "dead") return finish({ id: record.id, stopped: false, method: "not-running" });
595
-
596
- if (record.stop_argv?.length) {
597
- const [command, ...args] = record.stop_argv;
598
- spawnSync(command!, args, { stdio: "ignore", timeout: 30_000, cwd: record.cwd });
599
- if (await exited(record, graceMs))
600
- return finish({ id: record.id, stopped: true, method: "stop-command" });
631
+ const leaderPresence = processPresence(record.pid, dependencies.probePid);
632
+ if (
633
+ leaderPresence === "unknown" ||
634
+ (leaderPresence === "absent" && !allProcessesAbsent(record, dependencies.probePid))
635
+ )
636
+ return refuse(record.pid, "unknown");
637
+ // Check every recorded live process before sending the first signal.
638
+ for (const target of targets) {
639
+ const presence = processPresence(target.pid, dependencies.probePid);
640
+ if (presence === "absent") continue;
641
+ if (presence === "unknown") return refuse(target.pid, "unknown");
642
+ const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
643
+ if (verdict !== "same") return refuse(target.pid, verdict);
601
644
  }
602
- signal(record.pid, "SIGTERM");
603
- for (const pid of record.pids ?? []) signal(pid, "SIGTERM");
604
- if (await exited(record, graceMs))
605
- return finish({ id: record.id, stopped: true, method: "signal" });
606
- if (serverState(record) === "running") {
607
- signal(record.pid, "SIGKILL");
608
- for (const pid of record.pids ?? []) signal(pid, "SIGKILL");
645
+ if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
646
+ return finish({ id: record.id, stopped: false, method: "not-running" });
647
+
648
+ for (const name of ["SIGTERM", "SIGKILL"] as const) {
649
+ for (const target of targets) {
650
+ const presence = processPresence(target.pid, dependencies.probePid);
651
+ if (presence === "absent") continue;
652
+ if (presence === "unknown") return refuse(target.pid, "unknown");
653
+ const result =
654
+ target.pid === record.pid
655
+ ? signalServerLeader(target.pid, target.token, name, dependencies)
656
+ : signalIfSameGeneration(target.pid, target.token, name, dependencies);
657
+ if (result.verdict !== "same") return refuse(target.pid, result.verdict);
658
+ }
659
+ const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
660
+ if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
661
+ if (observed.exited)
662
+ return finish({ id: record.id, stopped: true, method: "signal", generation: "same" });
609
663
  }
610
- const stopped = await exited(record, 2_000);
611
- return finish({
664
+ return {
612
665
  id: record.id,
613
- stopped,
666
+ stopped: false,
614
667
  method: "signal",
615
- ...(stopped ? {} : { detail: "process survived SIGKILL" }),
616
- });
617
- }
618
-
619
- function signal(pid: number, name: NodeJS.Signals): void {
620
- // Detached servers lead their own process group; signal the group so a
621
- // wrapper (npm, bunx) does not orphan the real listener.
622
- try {
623
- process.kill(-pid, name);
624
- return;
625
- } catch {
626
- // Not a group leader, or already gone.
627
- }
628
- try {
629
- process.kill(pid, name);
630
- } catch {
631
- // Already gone.
632
- }
668
+ generation: "same",
669
+ detail: "process survived SIGKILL",
670
+ };
633
671
  }
634
672
 
635
- async function exited(record: ServerRecord, waitMs: number): Promise<boolean> {
673
+ function signalServerLeader(
674
+ pid: number,
675
+ token: string | undefined,
676
+ name: NodeJS.Signals,
677
+ dependencies: ServerProcessDependencies,
678
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
679
+ // The leader's generation authorizes its group. A failed group signal must
680
+ // not reuse that observation for the direct-PID fallback.
681
+ const kill = dependencies.kill ?? process.kill;
682
+ const group = signalIfSameGeneration(pid, token, name, {
683
+ readToken: dependencies.readToken,
684
+ kill: (leader, signal) => kill(-leader, signal),
685
+ });
686
+ return group.sent || group.verdict !== "same"
687
+ ? group
688
+ : signalIfSameGeneration(pid, token, name, dependencies);
689
+ }
690
+
691
+ async function waitForExit(
692
+ targets: Array<{ pid: number; token: string | undefined }>,
693
+ waitMs: number,
694
+ dependencies: ServerProcessDependencies,
695
+ ): Promise<{
696
+ exited: boolean;
697
+ refused?: { pid: number; verdict: ProcessGenerationVerdict };
698
+ }> {
636
699
  const deadline = Date.now() + waitMs;
637
- while (Date.now() < deadline) {
638
- if (serverState(record) !== "running") return true;
700
+ while (true) {
701
+ let live = false;
702
+ for (const target of targets) {
703
+ const presence = processPresence(target.pid, dependencies.probePid);
704
+ if (presence === "absent") continue;
705
+ if (presence === "unknown")
706
+ return { exited: false, refused: { pid: target.pid, verdict: "unknown" } };
707
+ const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
708
+ if (verdict !== "same") return { exited: false, refused: { pid: target.pid, verdict } };
709
+ live = true;
710
+ }
711
+ if (!live) return { exited: true };
712
+ if (Date.now() >= deadline) return { exited: false };
639
713
  await new Promise((r) => setTimeout(r, 100));
640
714
  }
641
- return serverState(record) !== "running";
642
715
  }
643
716
 
644
717
  /**
@@ -653,7 +726,8 @@ export function allocateServerPort(type: string, options?: ServerOptions): numbe
653
726
  if (!range) return null;
654
727
  const taken = listListeningPorts();
655
728
  for (const record of readServerRecords({ coordRoot })) {
656
- if (record.port && serverState(record) === "running") taken.add(record.port);
729
+ const state = serverState(record);
730
+ if (record.port && (state === "running" || state === "unknown")) taken.add(record.port);
657
731
  }
658
732
  for (let port = range[0]; port <= range[1]; port++) if (!taken.has(port)) return port;
659
733
  throw new Error(`no free port for ${type} in ${range[0]}-${range[1]}`);
@@ -666,6 +740,10 @@ export interface AdoptOptions extends ServerOptions {
666
740
  scope?: string;
667
741
  /** `self` (default) records the adopting agent as owner; `none` records no owner. */
668
742
  owner?: "self" | "none";
743
+ dependencies?: {
744
+ listServers?: typeof listServers;
745
+ readToken?: (pid: number) => string | null;
746
+ };
669
747
  }
670
748
 
671
749
  /**
@@ -675,12 +753,19 @@ export interface AdoptOptions extends ServerOptions {
675
753
  export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
676
754
  const coordRoot = rootOf(options);
677
755
  if (!coordRoot) throw new Error("no Harnery project found (coordination root did not resolve)");
678
- const report = listServers({ coordRoot, sample: false });
756
+ const report = (options?.dependencies?.listServers ?? listServers)({ coordRoot, sample: false });
679
757
  const listeners = report.unregistered.filter((item) => item.pid === pid);
680
758
  if (!listeners.length)
681
759
  throw new Error(`pid ${pid} is not an unregistered listener inside this project`);
682
760
  const ports = listeners.map((item) => item.port).sort((a, b) => a - b);
683
761
  const first = listeners[0]!;
762
+ const generation = verifyProcessGeneration(
763
+ pid,
764
+ first.start_token,
765
+ options?.dependencies?.readToken,
766
+ );
767
+ if (generation !== "same")
768
+ throw new Error(`pid ${pid} generation is ${generation}; adoption refused`);
684
769
  const type = options?.type ?? "adopted";
685
770
  const scope = options?.scope ?? first.cwd ?? undefined;
686
771
  const started = processStartedAt(pid);
@@ -693,6 +778,7 @@ export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
693
778
  url: `http://127.0.0.1:${ports[0]}/`,
694
779
  port: ports[0],
695
780
  pid,
781
+ start_token: first.start_token,
696
782
  ...(scope ? { scope } : {}),
697
783
  ...(first.cwd ? { cwd: first.cwd } : {}),
698
784
  ...(options?.owner === "none" ? { owner: null } : {}),
@@ -1,26 +1,20 @@
1
1
  /**
2
2
  * Tunnels as registry services.
3
3
  *
4
- * `tunnel up` and `tunnel reload` register the instance; `tunnel down`
5
- * removes it. Tunnels started before the registry existed are adopted the next
6
- * time the registry is read, so a long-lived tunnel never shows as an
7
- * unregistered listener.
4
+ * `tunnel up` and `tunnel reload` register the new gate; `tunnel down` removes
5
+ * its record. Older tunnels need an explicit adoption of an observed listener.
8
6
  */
9
7
 
10
8
  import { resolve } from "node:path";
11
9
  import { gateLogFile, type TunnelState } from "../../lib/tunnel/state.ts";
12
- import { resolveBinName } from "../config.ts";
13
10
  import type { RegisterServerInput } from "./index.ts";
14
11
 
15
12
  export function tunnelServerId(name: string): string {
16
13
  return `tunnel-${name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-")}`;
17
14
  }
18
15
 
19
- /** The registry input for one tunnel instance. */
16
+ /** Register the new gate; saved helper PIDs have no generation witnesses. */
20
17
  export function tunnelServerInput(state: TunnelState, coordRoot: string): RegisterServerInput {
21
- const helpers = [state.cloudflared_pid, state.provider_pid].filter(
22
- (pid): pid is number => typeof pid === "number" && pid > 0,
23
- );
24
18
  return {
25
19
  id: tunnelServerId(state.name),
26
20
  kind: "service",
@@ -29,10 +23,8 @@ export function tunnelServerInput(state: TunnelState, coordRoot: string): Regist
29
23
  url: state.url,
30
24
  port: state.gate_port,
31
25
  pid: state.gate_pid,
32
- ...(helpers.length ? { pids: helpers } : {}),
33
26
  log: resolve(coordRoot, ".cache", "tunnel", gateLogFile(state.name)),
34
27
  cwd: coordRoot,
35
- stop_argv: [resolveBinName(coordRoot), "tunnel", "down", "--name", state.name],
36
28
  started_at: state.started_at,
37
29
  owner: null,
38
30
  };
@@ -1,3 +1,4 @@
1
+ import type { ProcessGenerationVerdict } from "../agents/state/proc-start.ts";
1
2
  import type {
2
3
  PressureAssessment,
3
4
  PressureHysteresisState,
@@ -47,10 +48,16 @@ export const SUPERVISOR_RESOURCE_BUDGET = {
47
48
 
48
49
  export type SupervisorProcessState = "starting" | "running" | "stopping" | "stopped" | "error";
49
50
 
51
+ export interface SupervisorBuild {
52
+ version: string;
53
+ revision: string;
54
+ }
55
+
50
56
  export interface SupervisorServiceStatusRecord {
51
57
  schema_version: typeof SUPERVISOR_STATUS_SCHEMA_VERSION;
52
58
  pid: number;
53
59
  start_token?: string;
60
+ running_build?: SupervisorBuild;
54
61
  host: string;
55
62
  nonce: string;
56
63
  state: SupervisorProcessState;
@@ -70,6 +77,12 @@ export interface SupervisorServiceStatusRecord {
70
77
  export interface SupervisorStatus {
71
78
  running: boolean;
72
79
  stale: boolean;
80
+ generation: ProcessGenerationVerdict;
81
+ liveness: "running" | "stopped" | "unknown";
82
+ running_build: SupervisorBuild | null;
83
+ installed_build: SupervisorBuild;
84
+ restart_needed: boolean;
85
+ build_status: string;
73
86
  record?: SupervisorServiceStatusRecord;
74
87
  status_path: string;
75
88
  snapshot_path: string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { canonicalizeDiagnosticIdentity } from "../diagnostics/path-identity.ts";
2
3
  import {
3
4
  SUPERVISOR_DIAGNOSTIC_LIMITS,
4
5
  SUPERVISOR_EXPLANATION_SCHEMA_VERSION,
@@ -94,7 +95,9 @@ function statement(
94
95
  ): SupervisorExplanationStatement {
95
96
  return {
96
97
  id: `ex_${createHash("sha256")
97
- .update(`${code}\u0000${summary}\u0000${evidenceIds.join(",")}`)
98
+ .update(
99
+ canonicalizeDiagnosticIdentity(`${code}\u0000${summary}\u0000${evidenceIds.join(",")}`),
100
+ )
98
101
  .digest("hex")
99
102
  .slice(0, 24)}`,
100
103
  code,