@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached View State Coordinator (issue #91, spec D3) — the single logical
4
+ * writer of state.json/status.json for one board root.
5
+ *
6
+ * Usage: node state-coordinator.mjs <root>
7
+ *
8
+ * Design contract:
9
+ * - Exactly one instance per root: a token-fenced lease (acquireOwnedViewLock on
10
+ * the pseudo-view "_coordinator") is grabbed before anything else; a second
11
+ * instance exits 0 immediately ("busy" is an expected, idempotent outcome).
12
+ * - Every semantic mutation arrives as a `state_command` over the JSONL control
13
+ * socket and is decided by the pure layer (state-commands.mjs). This process
14
+ * owns ALL side effects: durable journal append (fsync BEFORE materialize, so
15
+ * boot replay can repair the crash window), materialization under a per-view
16
+ * file lock, checkpoint + journal GC, and socket lifecycle.
17
+ * - Transient kinds (TRANSIENT_KINDS, run_progress) bypass the durable
18
+ * machinery: validate → decide → materialize → reply, with no journal
19
+ * append, no dedupe, and no checkpoint trigger — a periodic snapshot whose
20
+ * next beat supersedes it. They still bump materializedRevision.
21
+ * - Idempotency: a processed commandId returns its original result forever —
22
+ * from the in-memory ring first (bounded, covers the post-GC window), then
23
+ * from the journal (findProcessedCommand). Duplicates never re-append.
24
+ * - Revisions: one global monotonic materializedRevision counter, initialized at
25
+ * boot to max(checkpoint, max journal revision, max revision across views'
26
+ * state.json), incremented once per applied command, and stamped identically
27
+ * on state.json and the command's run status.json (spec 根治条件 5 scope).
28
+ * - Boot replay re-materializes every applied journal record from its stored
29
+ * patches; per-file guards stamp only files strictly behind the recorded
30
+ * revision, so a half-materialized pair (state.json written, status.json not)
31
+ * repairs just the lagging half without moving the other one backwards. The
32
+ * decision-time status binding (statusRunId) is recorded in the journal, so
33
+ * replay is deterministic — no re-decision, no re-binding.
34
+ * - Legacy adoption: a view without materializedRevision is stamped as part of
35
+ * its first applied command's single materialization write (no extra write).
36
+ * - Root guard (issue #241): a board root that disappears — or is REPLACED by a
37
+ * write that recreates it in the delete window — makes the supervised
38
+ * heartbeat step down with exit 0 and one stderr line. Neither path writes a
39
+ * diagnostic or state: every append helper ensureDirs its parent and would
40
+ * recreate the deleted root skeleton.
41
+ * - AGENT_BOARD_COORDINATOR=off makes the process exit 0 immediately (tests and
42
+ * the client's degradation path).
43
+ */
44
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
45
+ import { randomBytes } from "node:crypto";
46
+ import { createServer } from "node:net";
47
+ import { readJson } from "../src/core/atomic.mjs";
48
+ import {
49
+ appendCommand,
50
+ findProcessedCommand,
51
+ gcJournal,
52
+ readCheckpoint,
53
+ readJournal,
54
+ repairJournalTail,
55
+ writeCheckpoint,
56
+ } from "../src/core/coordinator-journal.mjs";
57
+ import { classifyEndpointHealth, shouldUnlinkEndpoint, socketIdentityObservation } from "../src/core/host-coordination.mjs";
58
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
59
+ import { acquireOwnedViewLock, describeLeaseConflict, heartbeatFailureAction, withViewLockSync } from "../src/core/locks.mjs";
60
+ import { captureStartToken } from "../src/core/pid.mjs";
61
+ import { resolveTestMs } from "../src/core/test-knobs.mjs";
62
+ import * as P from "../src/core/paths.mjs";
63
+ import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
64
+ import { TRANSIENT_KINDS, decideStateTransition, validateCommand } from "../src/core/state-commands.mjs";
65
+ import { COORDINATOR_PROTOCOL_VERSION } from "../src/core/coordinator-protocol.mjs";
66
+
67
+ /** In-memory processed-command ring size (FIFO). Beyond the journal, this covers
68
+ * idempotency when the journal prefix has already been GC'd away. */
69
+ const PROCESSED_RING_MAX = 1000;
70
+ /** Journal growth (bytes) that triggers checkpoint + GC. Keeps boot replay bounded. */
71
+ const CHECKPOINT_THRESHOLD_BYTES = 262_144;
72
+ /** Lease heartbeat — matches pty-runner's host-start lease cadence. */
73
+ const HEARTBEAT_MS = 1000;
74
+ /** Lifecycle kinds whose legacy write sites stamped state.lastActivityAt on
75
+ * every persist (service markQueued/archive/adopt, job-runner plan-ready,
76
+ * reconcile terminal sites). The decision layer stays clock-free, so the
77
+ * shell adds the wall-clock stamp — into the patch BEFORE the journal
78
+ * append, so boot replay re-applies the exact same fields deterministically
79
+ * (there is no clock at replay time either). (Task 1 review F5.) */
80
+ const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "mark_holding", "clear_holding", "host_run_failed"]);
81
+ /** Kinds whose status patch may CREATE the run's status file: they carry the
82
+ * full status content for a run that has no materialized status yet. Every
83
+ * other kind keeps PR #1's "patch presence ≠ file requirement" semantics
84
+ * (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
85
+ * a status file must not fabricate one).
86
+ *
87
+ * `run_progress` joins them (issue #91 hard-down residual closure): its beat
88
+ * patch IS the runner's full in-memory status, and when the coordinator was
89
+ * down through the runner's boot window (mark_queued applied, run_started
90
+ * lost) the first post-recovery beat is the only surviving source that can
91
+ * recreate the file — without it beats and finalize reject stale_run forever
92
+ * and the row can never converge. The decision layer gates this on a
93
+ * qualified live-run beat (full-shaped patch, matching string runId). */
94
+ const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
95
+
96
+ const root = process.argv[2];
97
+ if (!root) {
98
+ process.stderr.write("state-coordinator: missing root argument\n");
99
+ process.exit(2);
100
+ }
101
+ if (process.env.AGENT_BOARD_COORDINATOR === "off") process.exit(0);
102
+
103
+ main().catch((err) => {
104
+ process.stderr.write(`state-coordinator: fatal ${err?.stack || err}\n`);
105
+ process.exit(1);
106
+ });
107
+
108
+ /**
109
+ * Append a coordinator-scoped diagnostic. `appendDiagnostic` throws when the
110
+ * disk is failing, so callers fence the write where a crash would be worse
111
+ * than a lost diagnostic (issue #224 D4). The AGENT_BOARD_TEST_FAIL_DIAGNOSTIC
112
+ * knob forces that throw so A14b can prove the step-down survives it.
113
+ * @param {string} code
114
+ * @param {Record<string, any>} details
115
+ * @param {"info"|"warn"|"error"} [level]
116
+ * @param {string} [source]
117
+ */
118
+ function appendCoordinatorDiagnostic(code, details, level = "info", source = "coordinator") {
119
+ if (process.env.AGENT_BOARD_TEST_FAIL_DIAGNOSTIC === "1") {
120
+ throw new Error(`forced coordinator diagnostic failure (${code})`);
121
+ }
122
+ appendDiagnostic(root, "_coordinator", { source, level, code, details });
123
+ }
124
+
125
+ /**
126
+ * Whether the board root is provably gone. Only `ENOENT` counts (issue #241):
127
+ * a transient access failure (EACCES/EBUSY, e.g. a scanner holding the
128
+ * directory) must never kill a healthy coordinator, so it reads as "present".
129
+ * @returns {boolean}
130
+ */
131
+ function rootVanished() {
132
+ try {
133
+ statSync(root);
134
+ return false;
135
+ } catch (err) {
136
+ return /** @type {NodeJS.ErrnoException} */ (err)?.code === "ENOENT";
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Snapshot the board root directory's filesystem identity (issue #241).
142
+ * `{dev, ino}` identifies one exact directory: a delete-and-recreate at the same
143
+ * path gets a new inode, which is the only way to tell "the root came back" from
144
+ * "the root never left" once a write's ensureDir has recreated it. Returns null
145
+ * when the root is missing or the filesystem reports no usable identity (ino 0,
146
+ * or no dev/ino at all), which degrades the check to existence-only.
147
+ * @returns {{dev: number, ino: number}|null}
148
+ */
149
+ function captureRootIdentity() {
150
+ try {
151
+ const st = statSync(root);
152
+ if (!Number.isFinite(st.dev) || !Number.isFinite(st.ino) || st.ino === 0) return null;
153
+ return { dev: st.dev, ino: st.ino };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Whether the board root is gone or was replaced since boot (issue #241).
161
+ * Existence alone is not enough: `appendCommand`/`writeState`/lease acquisition
162
+ * ensureDir their parent, so a write landing after the delete RECREATES the path
163
+ * and a plain stat then reads "present" (final-review I1). Comparing against the
164
+ * identity captured at boot detects the replacement on the next supervised tick,
165
+ * including the boot-window race where lease acquisition recreates the root.
166
+ * Only `ENOENT` counts as missing; any other stat error (EACCES/EBUSY) reads as
167
+ * present so a transient access failure never kills a healthy coordinator. With
168
+ * no captured identity the check degrades to existence-only (never a false
169
+ * step-down).
170
+ * @param {{dev: number, ino: number}|null} identity
171
+ * @returns {"missing"|"replaced"|null}
172
+ */
173
+ function rootVanishedOrReplaced(identity) {
174
+ try {
175
+ const st = statSync(root);
176
+ if (!identity) return null;
177
+ return st.dev !== identity.dev || st.ino !== identity.ino ? "replaced" : null;
178
+ } catch (err) {
179
+ return /** @type {NodeJS.ErrnoException} */ (err)?.code === "ENOENT" ? "missing" : null;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The one stderr line every "root is gone" exit emits (issue #241): the boot
185
+ * guard and the supervised step-down. Neither writes a diagnostic: every
186
+ * append helper ensures its parent directory (src/core/atomic.mjs), so a write
187
+ * under <root>/views/_coordinator would recreate the deleted directory chain —
188
+ * the throwaway-root residue this issue exists to remove. Detached callers
189
+ * ignore stdio; a test pipes stderr and attributes the exit to this line.
190
+ * @param {"missing"|"replaced"} state
191
+ * @param {string} outcome
192
+ */
193
+ function writeRootGone(state, outcome) {
194
+ process.stderr.write(`state-coordinator: board root ${state} (${root}); ${outcome}\n`);
195
+ }
196
+
197
+ async function main() {
198
+ const instanceId = randomBytes(8).toString("hex");
199
+ const startedAt = Date.now();
200
+ const heartbeatMs = resolveTestMs(process.env, "AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS", HEARTBEAT_MS);
201
+
202
+ // Capture the root's identity BEFORE the boot guard (issue #241, final-review
203
+ // I1): a delete between the guard and lease acquisition would let that
204
+ // ensureDir recreate the root WITH a valid lease, and the process would then
205
+ // serve it forever. The tick compares against this snapshot, so the recreated
206
+ // directory is detected on the first heartbeat.
207
+ const rootIdentity = captureRootIdentity();
208
+
209
+ // 0. A root that is already gone must fail closed (issue #241 A3b). Lease
210
+ // acquisition runs ensureDir, which would happily REBUILD the deleted board
211
+ // and serve that empty root forever — the exact orphan shape this issue is
212
+ // about. "Nothing to serve" is an expected outcome like a lost lease, so it
213
+ // exits 0, writes no diagnostic, and logs the shared stderr line.
214
+ if (rootVanished()) {
215
+ writeRootGone("missing", "nothing to serve; exiting");
216
+ process.exit(0);
217
+ }
218
+
219
+ // 1. Lease first: a second coordinator is an expected, idempotent no-op.
220
+ // Identity carries a POSIX startToken (pty-runner pattern) so a SIGKILLed
221
+ // coordinator's lease can be reclaimed by the next instance after pid reuse,
222
+ // plus the in-process instanceId the pong reports (issue #239 A4) so a client
223
+ // can tell whether the live endpoint is still the lease's owner.
224
+ // heartbeatMs declares the staleness contract (issue #224): a holder that
225
+ // stops heartbeating becomes reclaimable on its own staleness.
226
+ /** @type {import("../src/core/locks.mjs").Lease} */
227
+ let lease;
228
+ try {
229
+ lease = acquireOwnedViewLock(root, "_coordinator", "state-coordinator", {
230
+ waitMs: 500,
231
+ identity: { pid: process.pid, startToken: captureStartToken(process.pid), instanceId },
232
+ heartbeatMs,
233
+ });
234
+ } catch (err) {
235
+ // Record the conflict instead of exiting silently (issue #224 D6). The
236
+ // diagnostic write is best-effort: it must never turn the expected
237
+ // idempotent exit into a crash.
238
+ const summary = describeLeaseConflict(root, "_coordinator", "state-coordinator");
239
+ if (summary) {
240
+ try { appendCoordinatorDiagnostic("coordinator_lease_unavailable", summary, "error"); } catch { /* best effort */ }
241
+ }
242
+ process.stderr.write(`state-coordinator: lease unavailable (${err?.code ?? err?.message ?? "unknown"}); another instance may own it; exiting\n`);
243
+ process.exit(0);
244
+ }
245
+ // Leave a trace when this acquire reclaimed a stale lease (issue #224 D6).
246
+ if (lease.reclaimed) {
247
+ try { appendCoordinatorDiagnostic("lease_reclaimed", { ...lease.reclaimed }, "info", "lock"); } catch { /* best effort */ }
248
+ }
249
+ // Boot-edge touch (issue #224 D5): reflect acquisition progress before the
250
+ // supervised interval starts. Never counted toward the failure counter.
251
+ try { lease.touch(); } catch { /* best effort */ }
252
+
253
+ // 2. Boot state: journal summary, processed-command ring, replay, counter init.
254
+ // Repair a crash-torn tail FIRST: without it the first post-restart append
255
+ // concatenates onto the torn line and that fsynced record becomes invisible
256
+ // to every readJournal / replay / revision scan below.
257
+ repairJournalTail(root);
258
+ const checkpoint = readCheckpoint(root) ?? { materializedRevision: 0, journalBytes: 0 };
259
+ const journal = readJournal(root);
260
+ /** @type {Map<string, { result: { status: string, reason: string|null }, materializedRevision: number }>} */
261
+ const processedCommands = new Map();
262
+ for (const record of journal) {
263
+ const commandId = record?.command?.commandId;
264
+ if (commandId && !processedCommands.has(commandId)) {
265
+ processedCommands.set(commandId, {
266
+ result: record.result ?? { status: "rejected", reason: "unknown" },
267
+ materializedRevision: record.materializedRevision ?? 0,
268
+ });
269
+ }
270
+ }
271
+
272
+ // Replay in journal order: re-materialize every applied record from its stored
273
+ // patches; materialize's per-file guards write only files strictly behind the
274
+ // recorded revision, so this repairs just the half that missed its write in a
275
+ // crash window and never moves the other half backwards.
276
+ for (const record of journal) {
277
+ if (record?.result?.status !== "applied") continue;
278
+ const viewId = record?.command?.viewId;
279
+ if (!viewId) continue;
280
+ materialize(viewId, record.command ?? {}, record.mutate ?? {}, record.materializedRevision ?? 0, record.statusRunId ?? null);
281
+ }
282
+
283
+ // Global revision counter: never reissue a revision that exists anywhere.
284
+ let revisionCounter = Math.max(checkpoint.materializedRevision ?? 0, 0);
285
+ for (const record of journal) revisionCounter = Math.max(revisionCounter, record.materializedRevision ?? 0);
286
+ if (existsSync(P.viewsDir(root))) {
287
+ for (const entry of readdirSync(P.viewsDir(root))) {
288
+ const state = readJson(P.statePath(root, entry), null);
289
+ revisionCounter = Math.max(revisionCounter, state?.materializedRevision ?? 0);
290
+ }
291
+ }
292
+ let lastCheckpointBytes = 0;
293
+
294
+ // Boot-edge touch (issue #224 D5): replay is done; the heartbeat now
295
+ // reflects live progress before the endpoint binds. Never counted.
296
+ try { lease.touch(); } catch { /* best effort */ }
297
+
298
+ // 3. Control socket (JSONL, pty-runner's line-buffered server shape).
299
+ const socketPath = P.coordinatorEndpointPathFor(process.platform, root);
300
+ if (process.platform !== "win32" && existsSync(socketPath)) {
301
+ // Lease is ours: any leftover socket file belongs to a dead coordinator.
302
+ try { unlinkSync(socketPath); } catch { /* best effort */ }
303
+ }
304
+ /** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
305
+ let boundSocketIdentity = null;
306
+ /** Set by the supervised tick when the bound endpoint is gone or replaced:
307
+ * shutdown must then leave the path alone even while the lease still looks
308
+ * ours (issue #255). */
309
+ let endpointCompromised = false;
310
+
311
+ const server = createServer((socket) => {
312
+ let buffer = "";
313
+ socket.on("data", (chunk) => {
314
+ buffer += chunk.toString("utf8");
315
+ const lines = buffer.split("\n");
316
+ buffer = lines.pop() ?? "";
317
+ for (const line of lines) handleClientLine(line, socket);
318
+ });
319
+ socket.on("error", () => {}); // client vanished mid-request; nothing to answer
320
+ });
321
+
322
+ server.on("error", (err) => {
323
+ process.stderr.write(`state-coordinator: server error ${err instanceof Error ? err.message : String(err)}\n`);
324
+ shutdown(1);
325
+ });
326
+
327
+ // Boot-edge touch (issue #224 D5): the endpoint bind is the last boot
328
+ // boundary; the supervised interval takes over once listen succeeds.
329
+ // Never counted toward the failure counter.
330
+ try { lease.touch(); } catch { /* best effort */ }
331
+
332
+ // Consecutive supervised heartbeat failures. Only interval touches count;
333
+ // the boot-edge touches above are deliberately ignored (issue #224 D4/D5).
334
+ let consecutiveTouchFailures = 0;
335
+ let heartbeatTimer = null;
336
+
337
+ server.listen(socketPath, () => {
338
+ if (process.platform !== "win32") {
339
+ try {
340
+ const st = statSync(socketPath);
341
+ boundSocketIdentity = { dev: st.dev, ino: st.ino };
342
+ } catch (err) {
343
+ // Without the bind identity both endpoint cleanup and self-supervision
344
+ // degrade to a no-op; say so instead of failing silently (issue #255).
345
+ process.stderr.write(`state-coordinator: endpoint identity unavailable (${err instanceof Error ? err.message : String(err)}); self-supervision disabled\n`);
346
+ }
347
+ }
348
+ // Supervision starts only after the endpoint is bound (issue #224 D4):
349
+ // a live-but-stuck holder must step down before its lease can be
350
+ // preempted as stale, and stepping down before the socket exists would
351
+ // make the A14 endpoint cleanup a no-op.
352
+ heartbeatTimer = setInterval(() => {
353
+ // Step down when the board root is gone or was REPLACED (issue #241):
354
+ // a detached coordinator that outlives its root serves nobody, and its
355
+ // lease can never be reclaimed while the pid is alive. Detection reuses
356
+ // this tick — no second timer — so the worst case is one heartbeat.
357
+ //
358
+ // Existence alone is not enough (final-review I1): a write landing after
359
+ // the delete (appendCommand / writeState → ensureDir) recreates the
360
+ // directory, so a plain stat would read "present" and the coordinator
361
+ // would serve a dead board. The boot-time dev/ino identity distinguishes
362
+ // the recreated directory from the original one, and the same guard
363
+ // catches a root deleted between the boot guard and lease acquisition
364
+ // (that ensureDir would recreate it WITH a lease).
365
+ //
366
+ // No diagnostic is written on either path: every append helper
367
+ // ensureDirs its parent (src/core/atomic.mjs), so appending to
368
+ // <root>/views/_coordinator/diagnostics.jsonl would RECREATE the
369
+ // deleted directory chain — reintroducing exactly the throwaway-root
370
+ // residue this issue measures (U1). The shared stderr line is the
371
+ // attribution instead; detached callers ignore stdio, while a test
372
+ // spawns with a stderr pipe. Residual accepted (spec §7): a write in
373
+ // the delete window may leave a skeleton directory (data, not a
374
+ // process); this tick bounds its lifetime.
375
+ //
376
+ // A missing root also makes the touch below fail, but that path needs
377
+ // three consecutive failures; checking first attributes the step-down
378
+ // to the real cause and vacates the root immediately.
379
+ const rootState = rootVanishedOrReplaced(rootIdentity);
380
+ if (rootState) {
381
+ writeRootGone(rootState, "stepping down");
382
+ shutdown(0);
383
+ return;
384
+ }
385
+ // Self-supervise the endpoint (issue #255): a lease holder whose socket
386
+ // vanished or was replaced serves nobody — every client write fails with
387
+ // ENOENT while its fresh heartbeat keeps replacement coordinators losing
388
+ // the lease race. Step down so the next ensureCoordinator() binds a fresh
389
+ // endpoint. MUST stay after the root guard: a diagnostic append for a
390
+ // deleted root would recreate the directory skeleton (#241). `unknown`
391
+ // (transient stat error, or no bound identity) never steps a holder down.
392
+ // Short-circuit on the bound identity: without it (win32 named pipe, or a
393
+ // failed bind-time stat) there is nothing to compare against, and skipping
394
+ // the stat also avoids win32's non-trivial pipe stat side effects.
395
+ const endpointState = boundSocketIdentity
396
+ ? classifyEndpointHealth(boundSocketIdentity, statSocketObservation(socketPath))
397
+ : "unknown";
398
+ if (endpointState === "missing" || endpointState === "replaced") {
399
+ endpointCompromised = true; // veto endpoint cleanup in shutdown (R1)
400
+ try {
401
+ appendCoordinatorDiagnostic("coordinator_endpoint_lost", { state: endpointState }, "warn");
402
+ } catch { /* a failing diagnostic must not block the step-down (#224 D4) */ }
403
+ process.stderr.write(`state-coordinator: endpoint ${endpointState} (${socketPath}); stepping down\n`);
404
+ shutdown(0);
405
+ return;
406
+ }
407
+ let ok;
408
+ try {
409
+ ok = process.env.AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE === "1" ? false : lease.touch();
410
+ } catch {
411
+ ok = false;
412
+ }
413
+ if (!ok) {
414
+ consecutiveTouchFailures += 1;
415
+ if (heartbeatFailureAction(consecutiveTouchFailures) === "abandon") {
416
+ // Count → diagnostic → shutdown, in that order. The
417
+ // diagnostic write throws when the disk is failing (the
418
+ // exact step-down scenario), so it is fenced and must not
419
+ // block the exit.
420
+ try {
421
+ appendCoordinatorDiagnostic("coordinator_heartbeat_lost", { consecutiveFailures: consecutiveTouchFailures }, "error");
422
+ } catch { /* a failing diagnostic must not block the step-down */ }
423
+ shutdown(0);
424
+ }
425
+ } else {
426
+ consecutiveTouchFailures = 0;
427
+ }
428
+ }, heartbeatMs);
429
+ heartbeatTimer.unref?.();
430
+ });
431
+
432
+ let shuttingDown = false;
433
+ process.on("SIGTERM", () => shutdown(0));
434
+ process.on("SIGINT", () => shutdown(0));
435
+
436
+ /** One stat attempt, classified into the supervised observation discriminator.
437
+ * A transient access error is `unknown`, never `missing`/`replaced` (issue #255). */
438
+ function statSocketObservation(path) {
439
+ let stat = null;
440
+ let errorCode = null;
441
+ try {
442
+ stat = statSync(path);
443
+ } catch (err) {
444
+ errorCode = /** @type {NodeJS.ErrnoException} */ (err)?.code ?? "UNKNOWN";
445
+ }
446
+ return socketIdentityObservation(stat, errorCode);
447
+ }
448
+
449
+ /** Lease ownership at cleanup time; a read failure fails closed (no unlink). */
450
+ function safeIsOwner() {
451
+ try { return lease.isOwner(); } catch { return false; }
452
+ }
453
+
454
+ function shutdown(code) {
455
+ if (shuttingDown) return;
456
+ shuttingDown = true;
457
+ try { clearInterval(heartbeatTimer); } catch { /* best effort */ }
458
+ // Do NOT call server.close() (issue #255, nodejs/node#19729): libuv unlinks the
459
+ // unix-socket bind path when the handle closes, so a deposed coordinator closing
460
+ // after a successor bound would delete the successor's endpoint. The process is
461
+ // exiting anyway, and process.exit() skips the uv handle cleanup, so the implicit
462
+ // unlink never runs. Cleanup is explicit and ownership-gated below — the #70
463
+ // contract: unlink only while this instance still holds the lease AND the path
464
+ // still carries the inode it bound.
465
+ // `endpointCompromised` (set by the supervised tick) vetoes that explicit unlink
466
+ // as well; it is defence in depth on top of the dev+ino comparison.
467
+ const stillOwner = safeIsOwner();
468
+ if (process.platform !== "win32" && stillOwner && !endpointCompromised && boundSocketIdentity) {
469
+ try {
470
+ const st = statSync(socketPath);
471
+ if (shouldUnlinkEndpoint({ stillOwner, compromised: endpointCompromised, bound: boundSocketIdentity, current: { dev: st.dev, ino: st.ino } })) {
472
+ try { unlinkSync(socketPath); } catch { /* best effort */ }
473
+ }
474
+ } catch { /* path gone — nothing to clean */ }
475
+ }
476
+ try { lease.release(); } catch { /* best effort */ }
477
+ process.exit(code);
478
+ }
479
+
480
+ function send(socket, msg) {
481
+ socket.write(JSON.stringify(msg) + "\n");
482
+ }
483
+
484
+ function handleClientLine(line, socket) {
485
+ if (!line.trim()) return;
486
+ let msg;
487
+ try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
488
+ switch (msg?.type) {
489
+ case "ping":
490
+ send(socket, { type: "pong", instanceId, startedAt, protocolVersion: COORDINATOR_PROTOCOL_VERSION });
491
+ break;
492
+ case "state_command":
493
+ send(socket, { type: "state_command_result", ...processStateCommand(msg) });
494
+ break;
495
+ default:
496
+ send(socket, { type: "error", message: "unknown type" });
497
+ }
498
+ }
499
+
500
+ /**
501
+ * The command loop: validate → dedupe → decide → journal (fsync) → materialize
502
+ * → remember → reply. Rejections are journaled too, so their exact reason is
503
+ * replayable; duplicates short-circuit before any append.
504
+ * @param {object} msg
505
+ * @returns {{ commandId: string|null, status: string, reason: string|null, materializedRevision: number }}
506
+ */
507
+ function processStateCommand(msg) {
508
+ const checked = validateCommand(msg);
509
+ if (!checked.ok) {
510
+ return {
511
+ commandId: typeof msg?.commandId === "string" ? msg.commandId : null,
512
+ status: "rejected",
513
+ reason: checked.error,
514
+ materializedRevision: 0,
515
+ };
516
+ }
517
+ const command = checked.command;
518
+ const transient = TRANSIENT_KINDS.includes(command.kind);
519
+
520
+ if (!transient) {
521
+ const known = lookupProcessed(command.commandId);
522
+ if (known) {
523
+ return { commandId: command.commandId, status: known.result.status, reason: known.result.reason, materializedRevision: known.materializedRevision };
524
+ }
525
+ }
526
+
527
+ const state = readState(root, command.viewId);
528
+ // Status consistency only binds the view's current run (spec 根治条件 5):
529
+ // no currentRunId → status.json plays no role in this command.
530
+ // Exception (Task 1 review F1): followup_started re-points the row at a
531
+ // NEW run carried in payload.newRunId (command.runId is deliberately null
532
+ // so the stale-run guard cannot fire against the finished parent run).
533
+ // Resolving the status from the parent here would overwrite the parent's
534
+ // status.json with the new run's bootstrap patch; resolving from newRunId
535
+ // finds no file and cleanly bootstraps the new run's status instead.
536
+ const statusRunId = command.kind === "followup_started"
537
+ ? (typeof command.payload?.newRunId === "string" ? command.payload.newRunId : null)
538
+ : (command.runId ?? state?.currentRunId ?? null);
539
+ const status = statusRunId ? readStatus(root, command.viewId, statusRunId) : null;
540
+ const now = Date.now();
541
+ const decision = stampLegacyTimestamps(command, decideStateTransition(command, state, status, now), now);
542
+ const currentRevision = state?.materializedRevision ?? 0;
543
+
544
+ if (decision.action === "reject") {
545
+ if (transient) {
546
+ // Transient rejections are not replayable either — nothing was
547
+ // mutated, and journaling them would grow the journal for noise.
548
+ return { commandId: command.commandId ?? null, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
549
+ }
550
+ const result = { status: "rejected", reason: decision.reason };
551
+ const record = { command, result, materializedRevision: currentRevision, at: now };
552
+ const journalBytes = appendCommand(root, record);
553
+ rememberProcessed(command.commandId, result, currentRevision);
554
+ maybeCheckpoint(journalBytes);
555
+ return { commandId: command.commandId, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
556
+ }
557
+
558
+ const newRevision = revisionCounter + 1;
559
+ const result = { status: "applied", reason: decision.reason };
560
+ if (transient) {
561
+ // No journal, no processed ring, no checkpoint: a periodic snapshot —
562
+ // the next beat supersedes it.
563
+ materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
564
+ revisionCounter = newRevision;
565
+ return { commandId: command.commandId ?? null, status: "applied", reason: decision.reason, materializedRevision: newRevision };
566
+ }
567
+ // Journal first (fsync inside), materialize second: boot replay repairs
568
+ // the window between the two using the stored patches.
569
+ const record = { command, result, materializedRevision: newRevision, at: now, mutate: decision.mutate, statusRunId };
570
+ const journalBytes = appendCommand(root, record);
571
+ materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
572
+ revisionCounter = newRevision;
573
+ rememberProcessed(command.commandId, result, newRevision);
574
+ maybeCheckpoint(journalBytes);
575
+ return { commandId: command.commandId, status: "applied", reason: decision.reason, materializedRevision: newRevision };
576
+ }
577
+
578
+ /**
579
+ * Merge patches onto state.json (and the run's status.json when it exists)
580
+ * under the view's materialize lock, stamping the shared revision. Per-file
581
+ * guards stamp only files strictly behind the record revision: the live path
582
+ * always qualifies (the global counter is monotonic), while replay repairs
583
+ * just the half that missed its write in a crash window and never moves the
584
+ * other half backwards.
585
+ *
586
+ * Pairing invariant (issue #91 final review): EVERY applied revision pairs
587
+ * BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
588
+ * the existing status file's revision with no content change, and a
589
+ * status-only patch restamps the state file the same way — a healthy
590
+ * coordinator can never produce the asymmetric pair the reader-side
591
+ * revision check (reconcile) treats as a crash signature. Journaled
592
+ * state-only records heal on replay through this shared path. Residual:
593
+ * a torn TRANSIENT beat (crash between the two writes, never journaled)
594
+ * stays half-paired — ms-window × adjacent-crash, documented in the spec.
595
+ *
596
+ * A status patch without a status file is skipped — patch presence ≠ file
597
+ * requirement (mark_completed always emits `status: {autoState: null}`).
598
+ * The status run binding is the decision-time one (`statusRunId`, recorded
599
+ * in the journal) so replay cannot re-bind a runId-less patch to whatever
600
+ * run is current on disk at replay time; `state.currentRunId` remains only
601
+ * as a legacy fallback for records written before statusRunId existed.
602
+ */
603
+ function materialize(viewId, command, mutate, revision, statusRunId) {
604
+ withViewLockSync(root, viewId, "state-materialize", () => {
605
+ const state = readState(root, viewId);
606
+ if (state && (state.materializedRevision ?? 0) < revision) {
607
+ writeState(root, mutate?.state
608
+ ? { ...state, ...mutate.state, materializedRevision: revision }
609
+ : { ...state, materializedRevision: revision });
610
+ }
611
+ const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
612
+ if (!runId) return;
613
+ const status = readStatus(root, viewId, runId);
614
+ if (mutate?.status) {
615
+ if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
616
+ // run_started / followup_started / (bootstrapping) run_progress
617
+ // carry the full status content for a run that has no status
618
+ // file yet — create it. Identity fields come from the command
619
+ // context; the patch carries everything meaningful.
620
+ writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
621
+ } else if (status && (status.materializedRevision ?? 0) < revision) {
622
+ writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
623
+ }
624
+ } else if (status && (status.materializedRevision ?? 0) < revision) {
625
+ // State-only patch: restamp the existing status file's revision with
626
+ // no content change (pairing invariant above).
627
+ writeStatus(root, { ...status, materializedRevision: revision });
628
+ }
629
+ });
630
+ }
631
+
632
+ /** Add the legacy wall-clock stamp to a decided patch (F5): see
633
+ * LAST_ACTIVITY_STAMP_KINDS. Applied before the journal append so the
634
+ * stored patches are exactly what replay re-materializes. */
635
+ function stampLegacyTimestamps(command, decision, now) {
636
+ if (decision.action !== "apply" || !LAST_ACTIVITY_STAMP_KINDS.has(command.kind)) return decision;
637
+ return { ...decision, mutate: { ...decision.mutate, state: { ...(decision.mutate.state ?? {}), lastActivityAt: now } } };
638
+ }
639
+
640
+ /** Memory ring first (covers the post-GC window), journal second. */
641
+ function lookupProcessed(commandId) {
642
+ const inMemory = processedCommands.get(commandId);
643
+ if (inMemory) return inMemory;
644
+ const result = findProcessedCommand(root, commandId);
645
+ if (!result) return null;
646
+ const entry = { result, materializedRevision: revisionOf(commandId) };
647
+ rememberProcessed(commandId, result, entry.materializedRevision);
648
+ return entry;
649
+ }
650
+
651
+ /** Revision recorded for a known commandId (journal scan; 0 if unrecorded). */
652
+ function revisionOf(commandId) {
653
+ for (const record of readJournal(root)) {
654
+ if (record?.command?.commandId === commandId) return record.materializedRevision ?? 0;
655
+ }
656
+ return 0;
657
+ }
658
+
659
+ function rememberProcessed(commandId, result, materializedRevision) {
660
+ processedCommands.set(commandId, { result, materializedRevision });
661
+ while (processedCommands.size > PROCESSED_RING_MAX) {
662
+ processedCommands.delete(processedCommands.keys().next().value);
663
+ }
664
+ }
665
+
666
+ /** Checkpoint + GC once the journal outgrows the threshold. GC only runs when
667
+ * the checkpoint write provably succeeded (journal module enforces this). */
668
+ function maybeCheckpoint(journalBytes) {
669
+ if (journalBytes - lastCheckpointBytes < CHECKPOINT_THRESHOLD_BYTES) return;
670
+ if (!writeCheckpoint(root, { materializedRevision: revisionCounter, journalBytes })) return;
671
+ lastCheckpointBytes = gcJournal(root);
672
+ }
673
+ }