harnery 0.43.0 → 0.45.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 (323) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.js +1 -0
  3. package/dist/commands/agents.d.ts.map +1 -1
  4. package/dist/commands/agents.js +41 -48
  5. package/dist/commands/browse.d.ts.map +1 -1
  6. package/dist/commands/browse.js +38 -11
  7. package/dist/commands/diagnostics.d.ts.map +1 -1
  8. package/dist/commands/diagnostics.js +10 -0
  9. package/dist/commands/doctor.d.ts +12 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +22 -7
  12. package/dist/commands/files.d.ts +2 -2
  13. package/dist/commands/files.d.ts.map +1 -1
  14. package/dist/commands/files.js +7 -4
  15. package/dist/commands/governor.js +22 -10
  16. package/dist/commands/inbox.d.ts +1 -1
  17. package/dist/commands/inbox.d.ts.map +1 -1
  18. package/dist/commands/inbox.js +55 -2
  19. package/dist/commands/rm.d.ts.map +1 -1
  20. package/dist/commands/rm.js +28 -4
  21. package/dist/commands/servers.d.ts.map +1 -1
  22. package/dist/commands/servers.js +26 -17
  23. package/dist/commands/supervisor.d.ts.map +1 -1
  24. package/dist/commands/supervisor.js +7 -2
  25. package/dist/commands/toc.d.ts +14 -0
  26. package/dist/commands/toc.d.ts.map +1 -1
  27. package/dist/commands/toc.js +7 -4
  28. package/dist/commands/tunnel.d.ts +15 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +453 -153
  31. package/dist/commands/turn-rollback.d.ts +5 -0
  32. package/dist/commands/turn-rollback.d.ts.map +1 -0
  33. package/dist/commands/turn-rollback.js +76 -0
  34. package/dist/commands/workflow.d.ts.map +1 -1
  35. package/dist/commands/workflow.js +68 -30
  36. package/dist/core/agents/coord-client.d.ts.map +1 -1
  37. package/dist/core/agents/coord-client.js +18 -0
  38. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  39. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  40. package/dist/core/agents/live-authority-v3.js +11 -0
  41. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  42. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  43. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  44. package/dist/core/agents/mailbox.d.ts +9 -8
  45. package/dist/core/agents/mailbox.d.ts.map +1 -1
  46. package/dist/core/agents/mailbox.js +24 -17
  47. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  48. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/prompt-context.js +8 -12
  50. package/dist/core/agents/render/session-context.d.ts +2 -8
  51. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  52. package/dist/core/agents/render/session-context.js +7 -30
  53. package/dist/core/agents/rules/stop-hook.js +3 -3
  54. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  55. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  56. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  57. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  58. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  59. package/dist/core/agents/state/pidmap.js +5 -0
  60. package/dist/core/agents/state/proc-start.d.ts +19 -1
  61. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  62. package/dist/core/agents/state/proc-start.js +46 -1
  63. package/dist/core/agents/state/process-handle.d.ts +26 -0
  64. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  65. package/dist/core/agents/state/process-handle.js +121 -0
  66. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  67. package/dist/core/agents/state/stale-sweep.js +180 -70
  68. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  69. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  70. package/dist/core/agents/state/unchanged-file.js +390 -0
  71. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  72. package/dist/core/artifacts/delivery-card.js +2 -1
  73. package/dist/core/context/index.d.ts +22 -10
  74. package/dist/core/context/index.d.ts.map +1 -1
  75. package/dist/core/context/index.js +381 -119
  76. package/dist/core/context/observation.d.ts +26 -0
  77. package/dist/core/context/observation.d.ts.map +1 -0
  78. package/dist/core/context/observation.js +11 -0
  79. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  80. package/dist/core/diagnostics/bundle.js +25 -14
  81. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  82. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  83. package/dist/core/diagnostics/path-identity.js +12 -0
  84. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  85. package/dist/core/diagnostics/sanitize.js +4 -3
  86. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  87. package/dist/core/events/v3/authority-outbox.js +29 -0
  88. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  89. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  90. package/dist/core/events/v3/generation-custody.js +50 -0
  91. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  92. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  93. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  94. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  95. package/dist/core/events/v3/producers/recorder.js +77 -64
  96. package/dist/core/governor/planning.d.ts.map +1 -1
  97. package/dist/core/governor/planning.js +14 -1
  98. package/dist/core/governor/service-read.d.ts +10 -2
  99. package/dist/core/governor/service-read.d.ts.map +1 -1
  100. package/dist/core/governor/service-read.js +105 -22
  101. package/dist/core/governor/service.d.ts +20 -2
  102. package/dist/core/governor/service.d.ts.map +1 -1
  103. package/dist/core/governor/service.js +72 -47
  104. package/dist/core/hooks/cli.js +33 -5
  105. package/dist/core/inbox/contract.d.ts +2 -1
  106. package/dist/core/inbox/contract.d.ts.map +1 -1
  107. package/dist/core/inbox/index.d.ts +2 -0
  108. package/dist/core/inbox/index.d.ts.map +1 -1
  109. package/dist/core/inbox/index.js +2 -0
  110. package/dist/core/inbox/peer-messages.d.ts +37 -0
  111. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  112. package/dist/core/inbox/peer-messages.js +72 -0
  113. package/dist/core/inbox/service.d.ts +8 -1
  114. package/dist/core/inbox/service.d.ts.map +1 -1
  115. package/dist/core/inbox/service.js +67 -21
  116. package/dist/core/inbox/targets.d.ts +22 -0
  117. package/dist/core/inbox/targets.d.ts.map +1 -0
  118. package/dist/core/inbox/targets.js +108 -0
  119. package/dist/core/inbox/watcher.js +7 -3
  120. package/dist/core/journal/index.d.ts.map +1 -1
  121. package/dist/core/journal/index.js +17 -2
  122. package/dist/core/resources/contract.d.ts +3 -0
  123. package/dist/core/resources/contract.d.ts.map +1 -1
  124. package/dist/core/resources/service-status.d.ts +14 -1
  125. package/dist/core/resources/service-status.d.ts.map +1 -1
  126. package/dist/core/resources/service-status.js +23 -13
  127. package/dist/core/resources/service.d.ts +7 -13
  128. package/dist/core/resources/service.d.ts.map +1 -1
  129. package/dist/core/resources/service.js +34 -91
  130. package/dist/core/resources/status.d.ts +2 -5
  131. package/dist/core/resources/status.d.ts.map +1 -1
  132. package/dist/core/resources/status.js +36 -7
  133. package/dist/core/semantic/service.d.ts.map +1 -1
  134. package/dist/core/semantic/service.js +2 -9
  135. package/dist/core/servers/index.d.ts +34 -22
  136. package/dist/core/servers/index.d.ts.map +1 -1
  137. package/dist/core/servers/index.js +297 -144
  138. package/dist/core/servers/tunnels.d.ts +7 -7
  139. package/dist/core/servers/tunnels.d.ts.map +1 -1
  140. package/dist/core/servers/tunnels.js +47 -10
  141. package/dist/core/supervisor/contract.d.ts +12 -0
  142. package/dist/core/supervisor/contract.d.ts.map +1 -1
  143. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  144. package/dist/core/supervisor/explanations.js +2 -1
  145. package/dist/core/supervisor/findings.d.ts.map +1 -1
  146. package/dist/core/supervisor/findings.js +54 -9
  147. package/dist/core/supervisor/service.d.ts +7 -1
  148. package/dist/core/supervisor/service.d.ts.map +1 -1
  149. package/dist/core/supervisor/service.js +59 -29
  150. package/dist/core/supervisor/services.d.ts.map +1 -1
  151. package/dist/core/supervisor/services.js +22 -1
  152. package/dist/core/supervisor/status.d.ts +17 -2
  153. package/dist/core/supervisor/status.d.ts.map +1 -1
  154. package/dist/core/supervisor/status.js +70 -12
  155. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  156. package/dist/core/supervisor/timeline.js +2 -1
  157. package/dist/core/turn-rollback/content.d.ts +53 -0
  158. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  159. package/dist/core/turn-rollback/content.js +396 -0
  160. package/dist/core/turn-rollback/index.d.ts +72 -0
  161. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  162. package/dist/core/turn-rollback/index.js +443 -0
  163. package/dist/core/work/state.js +7 -7
  164. package/dist/core/workflow/engine.d.ts.map +1 -1
  165. package/dist/core/workflow/engine.js +105 -32
  166. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  167. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  168. package/dist/core/workflow/failure-evidence.js +114 -0
  169. package/dist/core/workflow/index.d.ts +5 -3
  170. package/dist/core/workflow/index.d.ts.map +1 -1
  171. package/dist/core/workflow/index.js +3 -2
  172. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  173. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  174. package/dist/core/workflow/live-session-v3.js +126 -5
  175. package/dist/core/workflow/proof.d.ts +5 -5
  176. package/dist/core/workflow/proof.d.ts.map +1 -1
  177. package/dist/core/workflow/proof.js +115 -20
  178. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  179. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  180. package/dist/core/workflow/recovery-audit.js +116 -0
  181. package/dist/core/workflow/run-state.d.ts +20 -1
  182. package/dist/core/workflow/run-state.d.ts.map +1 -1
  183. package/dist/core/workflow/run-state.js +141 -32
  184. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  185. package/dist/core/workflow/spawn-claude.js +19 -0
  186. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  187. package/dist/core/workflow/spawn-codex.js +13 -0
  188. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  189. package/dist/core/workflow/spawn-cursor.js +19 -0
  190. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-opencode.js +14 -0
  192. package/dist/core/workflow/types.d.ts +64 -17
  193. package/dist/core/workflow/types.d.ts.map +1 -1
  194. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  195. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  196. package/dist/core/workflow/workspaces/index.js +0 -1
  197. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  198. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  199. package/dist/core/workflow/workspaces/leases.js +30 -16
  200. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  201. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/local-git.js +328 -8
  203. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  204. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  205. package/dist/core/workflow/workspaces/ownership.js +194 -0
  206. package/dist/core/workflow/workspaces/reclaim.d.ts +40 -0
  207. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  209. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  210. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  211. package/dist/core/workflow/workspaces/recovery.js +42 -10
  212. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  213. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/state.js +21 -1
  215. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  216. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  217. package/dist/lib/browser/windows-chrome.js +262 -73
  218. package/dist/lib/docs-links.d.ts.map +1 -1
  219. package/dist/lib/docs-links.js +45 -2
  220. package/dist/lib/guarded-remove.d.ts +7 -0
  221. package/dist/lib/guarded-remove.d.ts.map +1 -1
  222. package/dist/lib/guarded-remove.js +106 -12
  223. package/dist/lib/identities/assume.d.ts.map +1 -1
  224. package/dist/lib/identities/assume.js +4 -0
  225. package/dist/lib/tunnel/state.d.ts +13 -6
  226. package/dist/lib/tunnel/state.d.ts.map +1 -1
  227. package/dist/lib/tunnel/state.js +58 -12
  228. package/dist/lib/zip/index.d.ts +41 -0
  229. package/dist/lib/zip/index.d.ts.map +1 -0
  230. package/dist/lib/zip/index.js +398 -0
  231. package/package.json +12 -1
  232. package/src/commander.ts +10 -0
  233. package/src/commands/agents.ts +54 -63
  234. package/src/commands/browse.ts +50 -14
  235. package/src/commands/diagnostics.ts +10 -0
  236. package/src/commands/doctor.ts +26 -7
  237. package/src/commands/files.ts +13 -5
  238. package/src/commands/governor.ts +23 -10
  239. package/src/commands/inbox.ts +52 -2
  240. package/src/commands/rm.ts +43 -4
  241. package/src/commands/servers.ts +27 -26
  242. package/src/commands/supervisor.ts +7 -2
  243. package/src/commands/toc.ts +14 -6
  244. package/src/commands/tunnel.ts +533 -166
  245. package/src/commands/turn-rollback.ts +111 -0
  246. package/src/commands/workflow.ts +89 -30
  247. package/src/core/agents/coord-client.ts +20 -0
  248. package/src/core/agents/live-authority-v3.ts +17 -0
  249. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  250. package/src/core/agents/mailbox.ts +26 -17
  251. package/src/core/agents/render/prompt-context.ts +10 -12
  252. package/src/core/agents/render/session-context.ts +9 -32
  253. package/src/core/agents/rules/stop-hook.ts +3 -3
  254. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  255. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  256. package/src/core/agents/state/pidmap.ts +13 -0
  257. package/src/core/agents/state/proc-start.ts +62 -2
  258. package/src/core/agents/state/process-handle.ts +165 -0
  259. package/src/core/agents/state/stale-sweep.ts +247 -82
  260. package/src/core/agents/state/unchanged-file.ts +456 -0
  261. package/src/core/artifacts/delivery-card.ts +7 -1
  262. package/src/core/context/index.ts +446 -136
  263. package/src/core/context/observation.ts +29 -0
  264. package/src/core/diagnostics/bundle.ts +23 -18
  265. package/src/core/diagnostics/path-identity.ts +15 -0
  266. package/src/core/diagnostics/sanitize.ts +4 -3
  267. package/src/core/events/v3/authority-outbox.ts +56 -0
  268. package/src/core/events/v3/generation-custody.ts +59 -0
  269. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  270. package/src/core/events/v3/producers/recorder.ts +94 -68
  271. package/src/core/governor/planning.ts +20 -8
  272. package/src/core/governor/service-read.ts +128 -20
  273. package/src/core/governor/service.ts +119 -42
  274. package/src/core/hooks/cli.ts +33 -5
  275. package/src/core/inbox/contract.ts +2 -1
  276. package/src/core/inbox/index.ts +2 -0
  277. package/src/core/inbox/peer-messages.ts +94 -0
  278. package/src/core/inbox/service.ts +88 -22
  279. package/src/core/inbox/targets.ts +148 -0
  280. package/src/core/inbox/watcher.ts +8 -9
  281. package/src/core/journal/index.ts +19 -2
  282. package/src/core/resources/contract.ts +4 -0
  283. package/src/core/resources/service-status.ts +35 -12
  284. package/src/core/resources/service.ts +57 -95
  285. package/src/core/resources/status.ts +57 -8
  286. package/src/core/semantic/service.ts +2 -8
  287. package/src/core/servers/index.ts +376 -141
  288. package/src/core/servers/tunnels.ts +57 -13
  289. package/src/core/supervisor/contract.ts +13 -0
  290. package/src/core/supervisor/explanations.ts +4 -1
  291. package/src/core/supervisor/findings.ts +68 -10
  292. package/src/core/supervisor/service.ts +87 -26
  293. package/src/core/supervisor/services.ts +26 -8
  294. package/src/core/supervisor/status.ts +89 -12
  295. package/src/core/supervisor/timeline.ts +2 -1
  296. package/src/core/turn-rollback/content.ts +499 -0
  297. package/src/core/turn-rollback/index.ts +580 -0
  298. package/src/core/work/state.ts +7 -7
  299. package/src/core/workflow/engine.ts +131 -49
  300. package/src/core/workflow/failure-evidence.ts +141 -0
  301. package/src/core/workflow/index.ts +12 -2
  302. package/src/core/workflow/live-session-v3.ts +146 -5
  303. package/src/core/workflow/proof.ts +134 -26
  304. package/src/core/workflow/recovery-audit.ts +181 -0
  305. package/src/core/workflow/run-state.ts +165 -34
  306. package/src/core/workflow/spawn-claude.ts +18 -0
  307. package/src/core/workflow/spawn-codex.ts +13 -0
  308. package/src/core/workflow/spawn-cursor.ts +18 -0
  309. package/src/core/workflow/spawn-opencode.ts +14 -0
  310. package/src/core/workflow/types.ts +51 -17
  311. package/src/core/workflow/workspaces/index.ts +0 -1
  312. package/src/core/workflow/workspaces/leases.ts +48 -17
  313. package/src/core/workflow/workspaces/local-git.ts +388 -7
  314. package/src/core/workflow/workspaces/ownership.ts +232 -0
  315. package/src/core/workflow/workspaces/reclaim.ts +62 -0
  316. package/src/core/workflow/workspaces/recovery.ts +45 -10
  317. package/src/core/workflow/workspaces/state.ts +20 -0
  318. package/src/lib/browser/windows-chrome.ts +346 -86
  319. package/src/lib/docs-links.ts +49 -2
  320. package/src/lib/guarded-remove.ts +132 -14
  321. package/src/lib/identities/assume.ts +4 -0
  322. package/src/lib/tunnel/state.ts +70 -15
  323. package/src/lib/zip/index.ts +483 -0
@@ -0,0 +1,232 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { readJsonRecord, stableDigest } from "../durable-record.ts";
4
+ import { acquireNoClobberLease, type NoClobberLease } from "./leases.ts";
5
+ import { containsPath } from "./paths.ts";
6
+ import type { WorkspaceClaim } from "./state.ts";
7
+ import { isWorkspaceAllocationRequest } from "./validate.ts";
8
+
9
+ const STALE_MS = 5 * 60 * 1_000;
10
+ const RESERVATION_SCOPE = "workspace-reclaim";
11
+
12
+ function leaseRoot(coordRoot: string, providerId: string): string {
13
+ return join(resolve(coordRoot), ".harnery", "workspaces", providerId, ".leases");
14
+ }
15
+
16
+ /** Serialize only publication and reservation registration, never Git work. */
17
+ function withPublicationGate<T>(coordRoot: string, providerId: string, operation: () => T): T {
18
+ const authority = stableDigest({ provider_id: providerId, scope: "workspace-publication" });
19
+ const gate = acquireNoClobberLease({
20
+ path: join(leaseRoot(coordRoot, providerId), "publication.lock"),
21
+ scope: "workspace-publication",
22
+ authoritySha256: authority,
23
+ staleAfterMs: STALE_MS,
24
+ validateStaleOwner: (owner) => owner.authority_sha256 === authority,
25
+ });
26
+ try {
27
+ return operation();
28
+ } finally {
29
+ gate.release();
30
+ }
31
+ }
32
+
33
+ function reservationPath(coordRoot: string, claim: WorkspaceClaim): string {
34
+ return join(leaseRoot(coordRoot, claim.provider_id), `reclaim-${claim.binding_id}.lock`);
35
+ }
36
+
37
+ function acquireReservation(coordRoot: string, claim: WorkspaceClaim): NoClobberLease {
38
+ const authority = stableDigest(claim);
39
+ return acquireNoClobberLease({
40
+ path: reservationPath(coordRoot, claim),
41
+ scope: RESERVATION_SCOPE,
42
+ authoritySha256: authority,
43
+ staleAfterMs: STALE_MS,
44
+ metadata: { binding_id: claim.binding_id, request_sha256: claim.request_sha256 },
45
+ validateStaleOwner: (owner) =>
46
+ owner.authority_sha256 === authority &&
47
+ owner.metadata?.binding_id === claim.binding_id &&
48
+ owner.metadata?.request_sha256 === claim.request_sha256,
49
+ });
50
+ }
51
+
52
+ export interface WorkspaceReclaimReservation {
53
+ assertCurrent(): void;
54
+ release(): void;
55
+ }
56
+
57
+ /** The immutable claim is the authority; the lease reserves its destructive path. */
58
+ export function reserveWorkspaceReclaim(
59
+ coordRoot: string,
60
+ claim: WorkspaceClaim,
61
+ ): WorkspaceReclaimReservation {
62
+ assertClaimAuthority(claim, claim.provider_id, claim.binding_id);
63
+ const lease = withPublicationGate(coordRoot, claim.provider_id, () =>
64
+ acquireReservation(coordRoot, claim),
65
+ );
66
+ return {
67
+ assertCurrent() {
68
+ const path = reservationPath(coordRoot, claim);
69
+ const current = join(path, "current");
70
+ const pin = join(path, lease.owner.owner_file);
71
+ const a = lstatSync(current, { bigint: true });
72
+ const b = lstatSync(pin, { bigint: true });
73
+ if (
74
+ !a.isFile() ||
75
+ !b.isFile() ||
76
+ a.dev !== b.dev ||
77
+ a.ino !== b.ino ||
78
+ readFileSync(current, "utf8") !== readFileSync(pin, "utf8") ||
79
+ stableDigest(readJsonRecord(current, "reclaim reservation")) !== stableDigest(lease.owner)
80
+ ) {
81
+ throw new Error("workspace reclaim reservation owner changed");
82
+ }
83
+ const persisted = readJsonRecord<WorkspaceClaim>(
84
+ join(dirname(dirname(path)), claim.binding_id, "claim.json"),
85
+ "reserved workspace claim",
86
+ );
87
+ if (stableDigest(persisted) !== stableDigest(claim)) {
88
+ throw new Error("workspace reclaim reservation claim changed");
89
+ }
90
+ },
91
+ release: () => lease.release(),
92
+ };
93
+ }
94
+
95
+ export function publishWorkspaceClaim<T>(
96
+ coordRoot: string,
97
+ claim: WorkspaceClaim,
98
+ publish: () => T,
99
+ ): T {
100
+ assertClaimAuthority(claim, claim.provider_id, claim.binding_id);
101
+ return withPublicationGate(coordRoot, claim.provider_id, () => {
102
+ for (const entry of readdirSync(leaseRoot(coordRoot, claim.provider_id), {
103
+ withFileTypes: true,
104
+ })) {
105
+ if (!entry.name.startsWith("reclaim-") || !entry.name.endsWith(".lock")) continue;
106
+ if (!entry.isDirectory()) throw new Error("workspace reclaim reservation is not a directory");
107
+ const path = join(leaseRoot(coordRoot, claim.provider_id), entry.name);
108
+ const current = join(path, "current");
109
+ try {
110
+ lstatSync(current);
111
+ } catch (error) {
112
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
113
+ throw error;
114
+ }
115
+ const id = entry.name.slice("reclaim-".length, -".lock".length);
116
+ const reserved = readJsonRecord<WorkspaceClaim>(
117
+ join(dirname(leaseRoot(coordRoot, claim.provider_id)), id, "claim.json"),
118
+ "reserved workspace claim",
119
+ );
120
+ assertClaimAuthority(reserved, claim.provider_id, id);
121
+ // Unknown reservation authority is never interpreted as an unrelated owner.
122
+ const owner = readJsonRecord<NoClobberLease["owner"]>(current, "reclaim reservation");
123
+ if (
124
+ owner.schema_version !== 1 ||
125
+ typeof owner.owner_id !== "string" ||
126
+ !/^[a-f0-9-]{36}$/.test(owner.owner_id) ||
127
+ owner.owner_file !== `owner-${owner.owner_id}.json` ||
128
+ !Number.isSafeInteger(owner.pid) ||
129
+ owner.pid <= 0 ||
130
+ typeof owner.host !== "string" ||
131
+ owner.host.length === 0 ||
132
+ !Number.isFinite(Date.parse(owner.created_at)) ||
133
+ !Number.isFinite(Date.parse(owner.expires_at)) ||
134
+ Date.parse(owner.expires_at) < Date.parse(owner.created_at) ||
135
+ owner.scope !== RESERVATION_SCOPE ||
136
+ owner.authority_sha256 !== stableDigest(reserved) ||
137
+ owner.metadata?.binding_id !== id ||
138
+ owner.metadata?.request_sha256 !== reserved.request_sha256
139
+ ) {
140
+ throw new Error("workspace reclaim reservation authority is unproven");
141
+ }
142
+ const a = lstatSync(current, { bigint: true });
143
+ const pin = join(path, owner.owner_file);
144
+ const b = lstatSync(pin, { bigint: true });
145
+ if (
146
+ !a.isFile() ||
147
+ !b.isFile() ||
148
+ a.dev !== b.dev ||
149
+ a.ino !== b.ino ||
150
+ readFileSync(current, "utf8") !== readFileSync(pin, "utf8")
151
+ )
152
+ throw new Error("workspace reclaim reservation owner is unproven");
153
+ if (!claimOverlapsWorkspace(claim, reserved.workspace_root)) continue;
154
+ // This succeeds only for an expired, provably dead, exact prior owner.
155
+ // It retires the reservation, never the immutable claim or resource.
156
+ const recovered = acquireReservation(coordRoot, reserved);
157
+ recovered.release();
158
+ }
159
+ return publish();
160
+ });
161
+ }
162
+
163
+ export function assertClaimAuthority(claim: WorkspaceClaim, providerId: string, id: string): void {
164
+ if (
165
+ claim?.schema_version !== 1 ||
166
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(providerId) ||
167
+ claim.provider_id !== providerId ||
168
+ claim.provider_version !== "1" ||
169
+ claim.binding_id !== id ||
170
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(id) ||
171
+ !isWorkspaceAllocationRequest(claim.request) ||
172
+ claim.request.provider_id !== providerId ||
173
+ stableDigest(claim.request) !== claim.request_sha256 ||
174
+ !/^[a-f0-9]{64}$/.test(claim.recovery_token) ||
175
+ stableDigest(claim.writable_root) !== stableDigest(claim.request.selected_writable_root)
176
+ ) {
177
+ throw new Error("retained workspace claim authority is unproven");
178
+ }
179
+ for (const path of claimPaths(claim)) {
180
+ if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path) {
181
+ throw new Error("retained workspace claim path is unproven");
182
+ }
183
+ }
184
+ if (!containsPath(claim.workspace_root, claim.active_root)) {
185
+ throw new Error("retained workspace active root escapes its claim");
186
+ }
187
+ }
188
+
189
+ function claimPaths(claim: WorkspaceClaim): string[] {
190
+ return [
191
+ claim.workspace_root,
192
+ claim.active_root,
193
+ claim.repository?.source_root?.realpath,
194
+ claim.repository?.common_dir?.realpath,
195
+ ];
196
+ }
197
+
198
+ /** Include absent descendants without losing aliases in an existing ancestor. */
199
+ function physicalPath(path: string): string {
200
+ const tail: string[] = [];
201
+ let cursor = path;
202
+ for (;;) {
203
+ try {
204
+ lstatSync(cursor);
205
+ return resolve(realpathSync(cursor), ...tail);
206
+ } catch (error) {
207
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(cursor) === cursor) {
208
+ throw error;
209
+ }
210
+ // A dangling symlink must fail, rather than be treated as an absent directory.
211
+ try {
212
+ if (lstatSync(cursor).isSymbolicLink()) throw new Error("unresolved workspace alias");
213
+ } catch (missing) {
214
+ if ((missing as NodeJS.ErrnoException).code !== "ENOENT") throw missing;
215
+ }
216
+ tail.unshift(basename(cursor));
217
+ cursor = dirname(cursor);
218
+ }
219
+ }
220
+ }
221
+
222
+ export function claimOverlapsWorkspace(claim: WorkspaceClaim, workspaceRoot: string): boolean {
223
+ const targets = [workspaceRoot, physicalPath(workspaceRoot)];
224
+ return claimPaths(claim).some((path, index) =>
225
+ [path, physicalPath(path)].some((candidate) =>
226
+ targets.some(
227
+ (target) =>
228
+ containsPath(target, candidate) || (index < 2 && containsPath(candidate, target)),
229
+ ),
230
+ ),
231
+ );
232
+ }
@@ -16,10 +16,64 @@
16
16
  */
17
17
 
18
18
  import { existsSync } from "node:fs";
19
+ import type { WorkflowRecoveryAuthorization } from "../recovery-audit.ts";
19
20
  import { git, gitMaybe } from "./git.ts";
21
+ import type { WorkflowLiveness } from "./recovery.ts";
22
+ import type { WorkspaceCleanupAttempt, WorkspaceCleanupReceipt } from "./types.ts";
20
23
 
21
24
  export type ReclaimMode = "salvage" | "discard";
22
25
 
26
+ export type WorkflowReclaimRefusalCode =
27
+ | "workflow_reclaim_run_live"
28
+ | "workflow_reclaim_liveness_unknown"
29
+ | "workflow_reclaim_recovery_mismatch"
30
+ | "workflow_reclaim_ownership_unproven"
31
+ | "workflow_reclaim_lease_held";
32
+
33
+ export class WorkflowReclaimError extends Error {
34
+ constructor(
35
+ readonly code: WorkflowReclaimRefusalCode,
36
+ message: string,
37
+ ) {
38
+ super(message);
39
+ this.name = "WorkflowReclaimError";
40
+ }
41
+ }
42
+
43
+ export interface WorkspaceReclaimOptions {
44
+ mode: ReclaimMode;
45
+ yes?: boolean;
46
+ unknownLiveness?: WorkflowRecoveryAuthorization & { evidenceSha256: string };
47
+ }
48
+
49
+ export interface ReclaimLeaseHolder {
50
+ scope: "binding" | "repository" | "resume";
51
+ owner_id?: string;
52
+ pid?: number;
53
+ host?: string;
54
+ detail?: string;
55
+ }
56
+
57
+ export interface WorkspaceReclaimPreview {
58
+ action: "preview";
59
+ run_id: string;
60
+ mode: ReclaimMode;
61
+ liveness: WorkflowLiveness;
62
+ evidence_sha256: string;
63
+ ownership: "ok" | "unproven";
64
+ ownership_detail?: string;
65
+ dirty_paths: string[] | null;
66
+ lease_holder: ReclaimLeaseHolder[];
67
+ }
68
+
69
+ export type WorkspaceReclaimResult =
70
+ | WorkspaceReclaimPreview
71
+ | (Omit<WorkspaceReclaimPreview, "action"> & {
72
+ action: "reclaimed";
73
+ preparation: ReclaimPreparation;
74
+ cleanup: WorkspaceCleanupAttempt | WorkspaceCleanupReceipt;
75
+ });
76
+
23
77
  export type ReclaimPreparation =
24
78
  | { action: "salvaged"; branch: string; commit: string; detail: string }
25
79
  | { action: "discarded"; detail: string }
@@ -30,6 +84,8 @@ export interface PrepareReclaimInput {
30
84
  worktreePath: string;
31
85
  mode: ReclaimMode;
32
86
  runId: string;
87
+ /** Revalidate the held reservation and peer authority immediately before effects. */
88
+ beforeEffect?: () => void;
33
89
  }
34
90
 
35
91
  /**
@@ -59,7 +115,9 @@ export function prepareReclaim(input: PrepareReclaimInput): ReclaimPreparation {
59
115
  if (input.mode === "discard") {
60
116
  // Ordered: reset drops tracked modifications, clean removes what reset
61
117
  // cannot see. Running clean first would leave staged deletions behind.
118
+ input.beforeEffect?.();
62
119
  git(input.worktreePath, ["reset", "--hard"]);
120
+ input.beforeEffect?.();
63
121
  git(input.worktreePath, ["clean", "-fd"]);
64
122
  return { action: "discarded", detail: "uncommitted changes were discarded on request" };
65
123
  }
@@ -78,11 +136,15 @@ export function prepareReclaim(input: PrepareReclaimInput): ReclaimPreparation {
78
136
  // ref ends untouched, and the work lives on a ref cleanup has no reason to
79
137
  // touch.
80
138
  const before = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
139
+ input.beforeEffect?.();
81
140
  git(input.worktreePath, ["add", "-A"]);
141
+ input.beforeEffect?.();
82
142
  git(input.worktreePath, ["commit", "--no-verify", "-m", salvageMessage(input.runId)]);
83
143
  const commit = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
84
144
  const branch = salvageBranch(input.runId);
145
+ input.beforeEffect?.();
85
146
  git(input.worktreePath, ["branch", "--force", branch, commit]);
147
+ input.beforeEffect?.();
86
148
  git(input.worktreePath, ["reset", "--hard", before]);
87
149
  return {
88
150
  action: "salvaged",
@@ -4,18 +4,31 @@ import { readWorkflowProof } from "../proof.ts";
4
4
 
5
5
  export type WorkflowLiveness = "active" | "inactive" | "unknown";
6
6
 
7
- export function readWorkflowLiveness(coordRoot: string, runId: string): WorkflowLiveness {
8
- const root = resolve(coordRoot);
9
- const proofPath = join(root, ".harnery", "workflows", runId, "proof.json");
10
- if (existsSync(proofPath)) {
7
+ /** Corruption must not hide a readable active marker from an audited override. */
8
+ export function hasActiveWorkflowEvidence(coordRoot: string, runId: string): boolean {
9
+ const path = join(resolve(coordRoot), ".harnery", "workflows", runId, "transcript.jsonl");
10
+ if (!existsSync(path)) return false;
11
+ let active = false;
12
+ for (const line of readFileSync(path, "utf8").split("\n")) {
11
13
  try {
12
- const proof = readWorkflowProof(root, runId);
13
- return ["succeeded", "failed"].includes(proof.run.status) ? "inactive" : "unknown";
14
+ const event = JSON.parse(line);
15
+ if (event?.run_id !== runId) continue;
16
+ if (["run.start", "run.resume", "agent.start"].includes(event.event)) active = true;
17
+ if (
18
+ event.event === "run.parked" ||
19
+ (event.event === "run.end" && typeof event.ok === "boolean") ||
20
+ (event.event === "workspace.reattach.failed" && ["blocked", "lost"].includes(event.status))
21
+ )
22
+ active = false;
14
23
  } catch {
15
- return "unknown";
24
+ /* Unreadable evidence cannot establish inactivity. */
16
25
  }
17
26
  }
27
+ return active;
28
+ }
18
29
 
30
+ export function readWorkflowLiveness(coordRoot: string, runId: string): WorkflowLiveness {
31
+ const root = resolve(coordRoot);
19
32
  const transcriptPath = join(root, ".harnery", "workflows", runId, "transcript.jsonl");
20
33
  if (!existsSync(transcriptPath)) return "unknown";
21
34
  let events: Array<{ event?: string; run_id?: string; ok?: unknown; status?: unknown }>;
@@ -35,13 +48,23 @@ export function readWorkflowLiveness(coordRoot: string, runId: string): Workflow
35
48
  } catch {
36
49
  return "unknown";
37
50
  }
38
- if (events.some((item) => item.run_id !== runId || typeof item.event !== "string")) {
51
+ if (
52
+ events.some(
53
+ (item) =>
54
+ !item ||
55
+ typeof item !== "object" ||
56
+ Array.isArray(item) ||
57
+ item.run_id !== runId ||
58
+ typeof item.event !== "string",
59
+ )
60
+ ) {
39
61
  return "unknown";
40
62
  }
41
63
 
42
64
  let liveness: WorkflowLiveness | undefined;
43
65
  for (const item of events) {
44
- if (item.event === "run.start" || item.event === "run.resume") liveness = "active";
66
+ if (item.event === "run.start" || item.event === "run.resume" || item.event === "agent.start")
67
+ liveness = "active";
45
68
  if (item.event === "run.parked") liveness = "inactive";
46
69
  if (item.event === "run.end") {
47
70
  if (typeof item.ok !== "boolean") return "unknown";
@@ -52,5 +75,17 @@ export function readWorkflowLiveness(coordRoot: string, runId: string): Workflow
52
75
  liveness = "inactive";
53
76
  }
54
77
  }
55
- return liveness ?? "unknown";
78
+ // A terminal proof can predate a resumed run. Only the current transcript
79
+ // can establish that no later activity invalidated that terminal observation.
80
+ if (liveness !== "inactive") return liveness ?? "unknown";
81
+ const proofPath = join(root, ".harnery", "workflows", runId, "proof.json");
82
+ if (existsSync(proofPath)) {
83
+ try {
84
+ const proof = readWorkflowProof(root, runId);
85
+ return ["succeeded", "failed"].includes(proof.run.status) ? "inactive" : "unknown";
86
+ } catch {
87
+ return "unknown";
88
+ }
89
+ }
90
+ return "inactive";
56
91
  }
@@ -5,6 +5,7 @@ import {
5
5
  fsyncSync,
6
6
  mkdirSync,
7
7
  openSync,
8
+ readdirSync,
8
9
  readFileSync,
9
10
  statSync,
10
11
  writeFileSync,
@@ -18,6 +19,7 @@ import {
18
19
  stableDigest,
19
20
  writeImmutableJson,
20
21
  } from "../durable-record.ts";
22
+ import { assertClaimAuthority, publishWorkspaceClaim } from "./ownership.ts";
21
23
 
22
24
  export { appendWorkflowTranscriptEvent } from "../transcript.ts";
23
25
 
@@ -141,6 +143,10 @@ export function readWorkspaceRequest(
141
143
  }
142
144
 
143
145
  export function writeWorkspaceClaim(coordRoot: string, claim: WorkspaceClaim): boolean {
146
+ return publishWorkspaceClaim(coordRoot, claim, () => writeClaim(coordRoot, claim));
147
+ }
148
+
149
+ function writeClaim(coordRoot: string, claim: WorkspaceClaim): boolean {
144
150
  const path = workspaceClaimPath(coordRoot, claim.provider_id, claim.binding_id);
145
151
  try {
146
152
  return writeImmutableJson(path, claim);
@@ -154,6 +160,20 @@ export function writeWorkspaceClaim(coordRoot: string, claim: WorkspaceClaim): b
154
160
  }
155
161
  }
156
162
 
163
+ export function readWorkspaceClaims(coordRoot: string, providerId: string): WorkspaceClaim[] {
164
+ const root = join(resolve(coordRoot), ".harnery", "workspaces", providerId);
165
+ if (!existsSync(root)) return [];
166
+ return readdirSync(root, { withFileTypes: true })
167
+ .filter((entry) => entry.name !== ".leases")
168
+ .map((entry) => {
169
+ if (!entry.isDirectory()) throw new Error("retained workspace inventory is unproven");
170
+ const claim = readWorkspaceClaim(coordRoot, providerId, entry.name);
171
+ if (!claim) throw new Error("retained workspace ownership claim is missing");
172
+ assertClaimAuthority(claim, providerId, entry.name);
173
+ return claim;
174
+ });
175
+ }
176
+
157
177
  export function readWorkspaceClaim(
158
178
  coordRoot: string,
159
179
  providerId: string,