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
@@ -39,7 +39,8 @@ export type LeaseRecoveryStep =
39
39
  | "claim_created"
40
40
  | "stale_pinned"
41
41
  | "current_removed"
42
- | "new_current_linked";
42
+ | "new_current_linked"
43
+ | "recovery_retired";
43
44
 
44
45
  export interface AcquireNoClobberLeaseInput {
45
46
  path: string;
@@ -51,6 +52,8 @@ export interface AcquireNoClobberLeaseInput {
51
52
  now?: () => number;
52
53
  pid?: number;
53
54
  host?: string;
55
+ /** Probe dependency for deterministic liveness-failure tests. */
56
+ probePid?: (pid: number) => void;
54
57
  /** Fault-injection hook immediately before the lease directory is created. */
55
58
  onLeaseDirectoryCreate?: () => void;
56
59
  /** Fault-injection hook after contention is observed and before owner inspection. */
@@ -137,8 +140,10 @@ export function acquireNoClobberLease(input: AcquireNoClobberLeaseInput): NoClob
137
140
  if (observed === undefined) {
138
141
  throw new Error(`lease ${input.scope} changed repeatedly during contention`);
139
142
  }
140
- if (leaseIsLive(observed.owner, now(), input.staleAfterMs)) {
141
- throw new Error(`lease ${input.scope} is held by a live or unexpired owner`);
143
+ if (leaseIsLive(observed.owner, now(), input.staleAfterMs, input.probePid)) {
144
+ throw new Error(
145
+ `lease ${input.scope} is held by a live or unexpired owner, or its liveness is unknown`,
146
+ );
142
147
  }
143
148
  if (
144
149
  observed.owner.scope !== input.scope ||
@@ -156,7 +161,7 @@ export function acquireNoClobberLease(input: AcquireNoClobberLeaseInput): NoClob
156
161
  linkSync(currentPath, pinPath);
157
162
  assertSameInodeAndBytes(currentPath, pinPath, observed.bytes);
158
163
  } catch {
159
- abortUnchangedRecoveryClaim(leaseDir, recoveryPath, pinPath, owner);
164
+ abortUnchangedRecoveryClaim(leaseDir, recoveryPath, owner);
160
165
  recoveryClaimed = false;
161
166
  throw new Error(`lease ${input.scope} recovery raced with another contender`);
162
167
  }
@@ -174,10 +179,9 @@ export function acquireNoClobberLease(input: AcquireNoClobberLeaseInput): NoClob
174
179
  input.onRecoveryStep?.("new_current_linked");
175
180
 
176
181
  removePinnedOwner(leaseDir, pinPath, observed.owner);
177
- unlinkSync(pinPath);
178
- unlinkSync(join(recoveryPath, "claim.json"));
179
- rmdirSync(recoveryPath);
180
- fsyncDirectory(leaseDir);
182
+ retireRecoveryClaim(leaseDir, recoveryPath, owner, () =>
183
+ input.onRecoveryStep?.("recovery_retired"),
184
+ );
181
185
  return {
182
186
  ...leaseHandle(leaseDir, ownerPath, ownerBytes, owner),
183
187
  recovered_owner: observed.owner,
@@ -266,7 +270,7 @@ function reclaimAbandonedRecovery(
266
270
  now: number,
267
271
  ): void {
268
272
  const claim = readRecoveryClaim(recoveryPath);
269
- if (leaseIsLive(claim.claimant, now, input.staleAfterMs)) {
273
+ if (leaseIsLive(claim.claimant, now, input.staleAfterMs, input.probePid)) {
270
274
  throw new Error(`lease ${input.scope} recovery is already in progress`);
271
275
  }
272
276
  if (
@@ -326,16 +330,34 @@ function reclaimAbandonedRecovery(
326
330
  function abortUnchangedRecoveryClaim(
327
331
  leaseDir: string,
328
332
  recoveryPath: string,
329
- pinPath: string,
330
333
  claimant: NoClobberLeaseOwner,
331
334
  ): void {
332
335
  const claim = readRecoveryClaim(recoveryPath);
333
336
  if (claim.claimant.owner_id !== claimant.owner_id) {
334
337
  throw new Error(`lease ${claimant.scope} recovery ownership changed`);
335
338
  }
336
- safeUnlink(pinPath);
337
- unlinkSync(join(recoveryPath, "claim.json"));
338
- rmdirSync(recoveryPath);
339
+ retireRecoveryClaim(leaseDir, recoveryPath, claimant);
340
+ }
341
+
342
+ /**
343
+ * Move this claimant's recovery directory to a private name before emptying
344
+ * it. Renaming a directory onto an existing empty directory replaces it, so
345
+ * emptying `recovery` in place would let a contender's claim land there and
346
+ * fail this claimant's final rmdir after it already holds the lease.
347
+ */
348
+ function retireRecoveryClaim(
349
+ leaseDir: string,
350
+ recoveryPath: string,
351
+ claimant: NoClobberLeaseOwner,
352
+ onRetired?: () => void,
353
+ ): void {
354
+ const retired = join(leaseDir, `recovery-retired-${claimant.owner_id}`);
355
+ renameSync(recoveryPath, retired);
356
+ fsyncDirectory(leaseDir);
357
+ onRetired?.();
358
+ safeUnlink(join(retired, "stale-pin"));
359
+ unlinkSync(join(retired, "claim.json"));
360
+ rmdirSync(retired);
339
361
  fsyncDirectory(leaseDir);
340
362
  }
341
363
 
@@ -466,7 +488,14 @@ function isStringRecord(value: unknown): value is Record<string, string> {
466
488
  );
467
489
  }
468
490
 
469
- function leaseIsLive(owner: NoClobberLeaseOwner, now: number, staleAfterMs: number): boolean {
491
+ function leaseIsLive(
492
+ owner: NoClobberLeaseOwner,
493
+ now: number,
494
+ staleAfterMs: number,
495
+ probePid: (pid: number) => void = (pid) => {
496
+ process.kill(pid, 0);
497
+ },
498
+ ): boolean {
470
499
  const created = Date.parse(owner.created_at);
471
500
  const expires = Date.parse(owner.expires_at);
472
501
  if (
@@ -478,12 +507,14 @@ function leaseIsLive(owner: NoClobberLeaseOwner, now: number, staleAfterMs: numb
478
507
  ) {
479
508
  return true;
480
509
  }
481
- if (owner.host !== hostname()) return false;
510
+ // Expiry only makes a same-host owner eligible for a process check. Neither
511
+ // a remote owner nor an unreadable probe proves that its work has stopped.
512
+ if (owner.host !== hostname()) return true;
482
513
  try {
483
- process.kill(owner.pid, 0);
514
+ probePid(owner.pid);
484
515
  return true;
485
516
  } catch (error) {
486
- return (error as NodeJS.ErrnoException).code === "EPERM";
517
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
487
518
  }
488
519
  }
489
520
 
@@ -9,7 +9,9 @@ import {
9
9
  } from "node:fs";
10
10
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
11
11
  import { stateDirMode, stateFileMode } from "../../storage/modes.ts";
12
+ import { acquireWorkflowResumeLease } from "../run-state.ts";
12
13
  import { WorkspaceAttestationError } from "./attestation-error.ts";
14
+ import { cleanupWorkspace } from "./cleanup.ts";
13
15
  import {
14
16
  git,
15
17
  gitMaybe,
@@ -22,6 +24,11 @@ import {
22
24
  worktreeInventory,
23
25
  } from "./git.ts";
24
26
  import { acquireNoClobberLease } from "./leases.ts";
27
+ import {
28
+ claimOverlapsWorkspace,
29
+ reserveWorkspaceReclaim,
30
+ type WorkspaceReclaimReservation,
31
+ } from "./ownership.ts";
25
32
  import {
26
33
  assertContainedExisting,
27
34
  assertPathIdentity,
@@ -36,6 +43,16 @@ import {
36
43
  type ValidatedRoot,
37
44
  validateConfiguredRoot,
38
45
  } from "./paths.ts";
46
+ import {
47
+ prepareReclaim,
48
+ type ReclaimLeaseHolder,
49
+ type ReclaimMode,
50
+ type ReclaimPreparation,
51
+ WorkflowReclaimError,
52
+ type WorkspaceReclaimOptions,
53
+ type WorkspaceReclaimPreview,
54
+ type WorkspaceReclaimResult,
55
+ } from "./reclaim.ts";
39
56
  import { readWorkflowLiveness } from "./recovery.ts";
40
57
  import {
41
58
  appendWorkspaceEvent,
@@ -43,6 +60,7 @@ import {
43
60
  readWorkflowSupplement,
44
61
  readWorkspaceBinding,
45
62
  readWorkspaceClaim,
63
+ readWorkspaceClaims,
46
64
  readWorkspaceEvents,
47
65
  stableDigest,
48
66
  type WorkspaceClaim,
@@ -86,11 +104,18 @@ export interface LocalGitWorktreeProviderOptions {
86
104
  descriptorPathsSupported?: () => boolean;
87
105
  }
88
106
 
107
+ export interface LocalGitWorktreeProvider extends WorkspaceProvider {
108
+ reclaim(
109
+ binding: WorkspaceBinding,
110
+ options: WorkspaceReclaimOptions,
111
+ ): Promise<WorkspaceReclaimResult>;
112
+ }
113
+
89
114
  export function createLocalGitWorktreeProvider(
90
115
  options: LocalGitWorktreeProviderOptions,
91
- ): WorkspaceProvider {
116
+ ): LocalGitWorktreeProvider {
92
117
  const coordRoot = resolve(options.coordRoot);
93
- return {
118
+ const provider: LocalGitWorktreeProvider = {
94
119
  probe: (input) => probe(input, options.descriptorPathsSupported),
95
120
  allocate: (request) => allocate(coordRoot, request),
96
121
  readBinding: async (binding) => {
@@ -103,7 +128,9 @@ export function createLocalGitWorktreeProvider(
103
128
  previewIntegration: (input) => previewIntegration(coordRoot, input),
104
129
  applyAuthorizedIntegration: (input) => applyAuthorizedIntegration(coordRoot, input),
105
130
  cleanup: (binding, intent) => cleanup(coordRoot, binding, intent),
131
+ reclaim: (binding, reclaimOptions) => reclaim(coordRoot, provider, binding, reclaimOptions),
106
132
  };
133
+ return provider;
107
134
  }
108
135
 
109
136
  export async function probe(
@@ -783,6 +810,258 @@ function cancelUnderLease(
783
810
  return { ...base, status: "cancelled" };
784
811
  }
785
812
 
813
+ function reclaimPreview(
814
+ coordRoot: string,
815
+ binding: WorkspaceBinding,
816
+ mode: ReclaimMode,
817
+ ): WorkspaceReclaimPreview {
818
+ const preview: WorkspaceReclaimPreview = {
819
+ action: "preview",
820
+ run_id: binding.run_id,
821
+ mode,
822
+ liveness: readWorkflowLiveness(coordRoot, binding.run_id),
823
+ ownership: "unproven",
824
+ dirty_paths: null,
825
+ lease_holder: reclaimLeaseHolders(coordRoot, binding),
826
+ };
827
+ try {
828
+ const current = attestSync(coordRoot, binding);
829
+ if (current.status === "ok") {
830
+ preview.ownership = "ok";
831
+ preview.dirty_paths = [...(current.repository?.dirty_paths ?? [])];
832
+ } else {
833
+ preview.ownership_detail =
834
+ current.provider_drift.join("; ") || "ownership attestation failed";
835
+ }
836
+ } catch (error) {
837
+ preview.ownership_detail = (error as Error).message;
838
+ }
839
+ return preview;
840
+ }
841
+
842
+ function assertReclaimLiveness(coordRoot: string, binding: WorkspaceBinding): void {
843
+ const liveness = readWorkflowLiveness(coordRoot, binding.run_id);
844
+ if (liveness === "inactive") return;
845
+ throw new WorkflowReclaimError(
846
+ liveness === "active" ? "workflow_reclaim_run_live" : "workflow_reclaim_liveness_unknown",
847
+ liveness === "active"
848
+ ? `run ${binding.run_id} is active; its workspace was left untouched`
849
+ : `run ${binding.run_id} has missing, corrupt, or ambiguous liveness evidence; its workspace was left untouched`,
850
+ );
851
+ }
852
+
853
+ function assertReclaimOwnership(coordRoot: string, binding: WorkspaceBinding): WorkspaceClaim {
854
+ try {
855
+ const claim = requiredClaim(coordRoot, binding);
856
+ const current = attestSync(coordRoot, binding);
857
+ if (current.status !== "ok") {
858
+ throw new Error(current.provider_drift.join("; ") || "ownership attestation failed");
859
+ }
860
+ return claim;
861
+ } catch (error) {
862
+ throw new WorkflowReclaimError("workflow_reclaim_ownership_unproven", (error as Error).message);
863
+ }
864
+ }
865
+
866
+ function assertPeerWorkspaceAuthority(coordRoot: string, claim: WorkspaceClaim): void {
867
+ try {
868
+ for (const peer of readWorkspaceClaims(coordRoot, PROVIDER_ID)) {
869
+ if (peer.binding_id === claim.binding_id) continue;
870
+ const binding = readWorkspaceBinding(coordRoot, PROVIDER_ID, peer.binding_id);
871
+ const events = readWorkspaceEvents(coordRoot, PROVIDER_ID, peer.binding_id);
872
+ if (binding) requiredClaim(coordRoot, binding);
873
+ else if (events.some((event) => event.event === "bound")) {
874
+ throw new Error("retained peer binding authority is missing");
875
+ }
876
+ if (!claimOverlapsWorkspace(peer, claim.workspace_root)) continue;
877
+ // Process termination, archival and an expired reservation do not release
878
+ // a resource. Require the provider's release record AND current absence.
879
+ if (binding && events.some((event) => event.event === "released")) {
880
+ const current = attestSync(coordRoot, binding);
881
+ if (current.status === "ok" && current.resource_state === "released") continue;
882
+ }
883
+ throw new Error(`retained peer workspace ${peer.binding_id} overlaps the reclaim path`);
884
+ }
885
+ } catch (error) {
886
+ throw new WorkflowReclaimError("workflow_reclaim_ownership_unproven", (error as Error).message);
887
+ }
888
+ }
889
+
890
+ function reclaimAuthorityGuard(
891
+ coordRoot: string,
892
+ binding: WorkspaceBinding,
893
+ claim: WorkspaceClaim,
894
+ reservation: WorkspaceReclaimReservation,
895
+ ): void {
896
+ try {
897
+ reservation.assertCurrent();
898
+ assertReclaimLiveness(coordRoot, binding);
899
+ assertReclaimOwnership(coordRoot, binding);
900
+ assertPeerWorkspaceAuthority(coordRoot, claim);
901
+ } catch (error) {
902
+ if (error instanceof WorkflowReclaimError) throw error;
903
+ throw new WorkflowReclaimError("workflow_reclaim_ownership_unproven", (error as Error).message);
904
+ }
905
+ }
906
+
907
+ async function reclaim(
908
+ coordRoot: string,
909
+ provider: WorkspaceProvider,
910
+ binding: WorkspaceBinding,
911
+ options: WorkspaceReclaimOptions,
912
+ ): Promise<WorkspaceReclaimResult> {
913
+ if (options.mode !== "salvage" && options.mode !== "discard") {
914
+ throw new Error("unsupported workspace reclaim mode");
915
+ }
916
+ if (!options.yes) return reclaimPreview(coordRoot, binding, options.mode);
917
+
918
+ // Liveness comes before terminal-proof cleanup helpers. A live run may not
919
+ // have a proof yet, and a missing proof must never be discovered after reset.
920
+ assertReclaimLiveness(coordRoot, binding);
921
+ const claim = assertReclaimOwnership(coordRoot, binding);
922
+ let reservation: WorkspaceReclaimReservation;
923
+ try {
924
+ reservation = reserveWorkspaceReclaim(coordRoot, claim);
925
+ } catch (error) {
926
+ throw new WorkflowReclaimError("workflow_reclaim_lease_held", (error as Error).message);
927
+ }
928
+ try {
929
+ reclaimAuthorityGuard(coordRoot, binding, claim, reservation);
930
+ let releaseResume: () => void;
931
+ try {
932
+ releaseResume = acquireWorkflowResumeLease(coordRoot, binding.run_id);
933
+ } catch (error) {
934
+ throw new WorkflowReclaimError("workflow_reclaim_lease_held", (error as Error).message);
935
+ }
936
+ try {
937
+ let releaseBinding: () => void;
938
+ try {
939
+ releaseBinding = acquireLease(
940
+ coordRoot,
941
+ workspaceLockPath(coordRoot, PROVIDER_ID, binding.binding_id),
942
+ claim,
943
+ "binding",
944
+ );
945
+ } catch (error) {
946
+ throw new WorkflowReclaimError("workflow_reclaim_lease_held", (error as Error).message);
947
+ }
948
+ try {
949
+ let releaseRepository: () => void;
950
+ try {
951
+ releaseRepository = acquireRepositoryLease(coordRoot, claim);
952
+ } catch (error) {
953
+ throw new WorkflowReclaimError("workflow_reclaim_lease_held", (error as Error).message);
954
+ }
955
+ try {
956
+ assertReclaimLiveness(coordRoot, binding);
957
+ assertReclaimOwnership(coordRoot, binding);
958
+ const preview = reclaimPreview(coordRoot, binding, options.mode);
959
+ let preparation: ReclaimPreparation | undefined;
960
+ // Keep ordinary cleanup's terminal-evidence checks, intent and receipts.
961
+ // Its provider callback uses the leases already held here instead of
962
+ // trying to acquire them again. Preparation occurs only after all of
963
+ // cleanup's path, ownership, ref and registration checks have succeeded.
964
+ const cleanupResult = await cleanupWorkspace({
965
+ coordRoot,
966
+ runId: binding.run_id,
967
+ provider: {
968
+ ...provider,
969
+ cleanup: async (candidate, intent) => {
970
+ assertReclaimLiveness(coordRoot, candidate);
971
+ assertReclaimOwnership(coordRoot, candidate);
972
+ return cleanupUnderLease(coordRoot, candidate, claim, intent, {
973
+ mode: options.mode,
974
+ beforeEffect: () =>
975
+ reclaimAuthorityGuard(coordRoot, candidate, claim, reservation),
976
+ prepared: (value) => {
977
+ preparation = value;
978
+ },
979
+ });
980
+ },
981
+ },
982
+ });
983
+ if (
984
+ !preparation &&
985
+ cleanupResult.status !== "released" &&
986
+ cleanupResult.status !== "already_released"
987
+ ) {
988
+ throw new WorkflowReclaimError(
989
+ "workflow_reclaim_ownership_unproven",
990
+ ("reason" in cleanupResult ? cleanupResult.reason : undefined) ??
991
+ "workspace cleanup preconditions could not be proved",
992
+ );
993
+ }
994
+ return {
995
+ ...preview,
996
+ action: "reclaimed",
997
+ lease_holder: [],
998
+ preparation: preparation ?? {
999
+ action: "already_gone",
1000
+ detail: "the workspace is already released",
1001
+ },
1002
+ cleanup: cleanupResult,
1003
+ };
1004
+ } finally {
1005
+ releaseRepository();
1006
+ }
1007
+ } finally {
1008
+ releaseBinding();
1009
+ }
1010
+ } finally {
1011
+ releaseResume();
1012
+ }
1013
+ } finally {
1014
+ reservation.release();
1015
+ }
1016
+ }
1017
+
1018
+ function reclaimLeaseHolders(coordRoot: string, binding: WorkspaceBinding): ReclaimLeaseHolder[] {
1019
+ const paths: Array<[ReclaimLeaseHolder["scope"], string]> = [
1020
+ ["binding", workspaceLockPath(coordRoot, PROVIDER_ID, binding.binding_id)],
1021
+ ];
1022
+ if (binding.repository) {
1023
+ const key = stableDigest({ common: binding.repository.common_dir.identity });
1024
+ paths.push([
1025
+ "repository",
1026
+ join(coordRoot, ".harnery", "workspaces", PROVIDER_ID, ".leases", `repository-${key}.lock`),
1027
+ ]);
1028
+ }
1029
+ const holders: ReclaimLeaseHolder[] = [];
1030
+ try {
1031
+ const owner = JSON.parse(
1032
+ readFileSync(join(coordRoot, ".harnery", "workflows", binding.run_id, "resume.lock"), "utf8"),
1033
+ ) as Record<string, unknown>;
1034
+ holders.push({
1035
+ scope: "resume",
1036
+ pid: typeof owner.pid === "number" ? owner.pid : undefined,
1037
+ host: typeof owner.host === "string" ? owner.host : undefined,
1038
+ });
1039
+ } catch (error) {
1040
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1041
+ holders.push({ scope: "resume", detail: "lease holder could not be read" });
1042
+ }
1043
+ }
1044
+ for (const [scope, path] of paths) {
1045
+ try {
1046
+ const owner = JSON.parse(readFileSync(join(path, "current"), "utf8")) as Record<
1047
+ string,
1048
+ unknown
1049
+ >;
1050
+ holders.push({
1051
+ scope,
1052
+ owner_id: typeof owner.owner_id === "string" ? owner.owner_id : undefined,
1053
+ pid: typeof owner.pid === "number" ? owner.pid : undefined,
1054
+ host: typeof owner.host === "string" ? owner.host : undefined,
1055
+ });
1056
+ } catch (error) {
1057
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1058
+ holders.push({ scope, detail: "lease holder could not be read" });
1059
+ }
1060
+ }
1061
+ }
1062
+ return holders;
1063
+ }
1064
+
786
1065
  async function cleanup(
787
1066
  coordRoot: string,
788
1067
  binding: WorkspaceBinding,
@@ -799,7 +1078,17 @@ async function cleanup(
799
1078
  try {
800
1079
  const releaseRepository = acquireRepositoryLease(coordRoot, claim);
801
1080
  try {
802
- return cleanupUnderLease(coordRoot, binding, claim, intent);
1081
+ const reservation = reserveWorkspaceReclaim(coordRoot, claim);
1082
+ try {
1083
+ const authorityGuard = () => {
1084
+ reservation.assertCurrent();
1085
+ assertPeerWorkspaceAuthority(coordRoot, claim);
1086
+ };
1087
+ authorityGuard();
1088
+ return cleanupUnderLease(coordRoot, binding, claim, intent, undefined, authorityGuard);
1089
+ } finally {
1090
+ reservation.release();
1091
+ }
803
1092
  } finally {
804
1093
  releaseRepository();
805
1094
  }
@@ -813,6 +1102,12 @@ function cleanupUnderLease(
813
1102
  binding: WorkspaceBinding,
814
1103
  claim: WorkspaceClaim,
815
1104
  intent: WorkspaceCleanupIntent,
1105
+ reclaimRequest?: {
1106
+ mode: ReclaimMode;
1107
+ beforeEffect: () => void;
1108
+ prepared: (value: ReclaimPreparation) => void;
1109
+ },
1110
+ beforeRemoval?: () => void,
816
1111
  ): WorkspaceCleanupResult {
817
1112
  const repository = binding.repository;
818
1113
  if (
@@ -871,11 +1166,11 @@ function cleanupUnderLease(
871
1166
  return { ...base, status: "blocked", reason: "ownership attestation failed" };
872
1167
  }
873
1168
  if (
874
- (before.repository?.dirty_paths.length ?? 0) > 0 ||
1169
+ (!reclaimRequest && (before.repository?.dirty_paths.length ?? 0) > 0) ||
875
1170
  (before.repository?.conflicts.length ?? 0) > 0 ||
876
1171
  (before.repository?.operation_in_progress.length ?? 0) > 0
877
1172
  ) {
878
- appendWorkspaceEvent(coordRoot, claim, "preserved_dirty");
1173
+ if (!reclaimRequest) appendWorkspaceEvent(coordRoot, claim, "preserved_dirty");
879
1174
  return {
880
1175
  ...base,
881
1176
  status: "preserved_dirty",
@@ -949,6 +1244,19 @@ function cleanupUnderLease(
949
1244
  };
950
1245
  }
951
1246
 
1247
+ if (reclaimRequest) {
1248
+ assertReclaimLiveness(coordRoot, binding);
1249
+ assertReclaimOwnership(coordRoot, binding);
1250
+ revalidateClaimResources(claim, root);
1251
+ reclaimRequest.prepared(
1252
+ prepareReclaim({
1253
+ worktreePath: binding.workspace_root,
1254
+ mode: reclaimRequest.mode,
1255
+ runId: binding.run_id,
1256
+ beforeEffect: reclaimRequest.beforeEffect,
1257
+ }),
1258
+ );
1259
+ }
952
1260
  appendWorkspaceEvent(coordRoot, claim, "cleanup_pending", {
953
1261
  worktree_path: binding.workspace_root,
954
1262
  gitdir: repository.gitdir,
@@ -958,6 +1266,8 @@ function cleanupUnderLease(
958
1266
  target_ref_oid: targetRef.out,
959
1267
  });
960
1268
  if (before.workspace_exists) {
1269
+ reclaimRequest?.beforeEffect();
1270
+ beforeRemoval?.();
961
1271
  revalidateClaimResources(claim, root);
962
1272
  assertContainedExisting(root, binding.workspace_root, "workspace cleanup path");
963
1273
  git(repository.source_root.realpath, ["worktree", "remove", binding.workspace_root]);