forge-workflow 0.1.0-beta.5 → 0.1.0-beta.7

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -1,570 +1,1177 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * The IMPURE half of the autonomous shepherd reconciler (W-S4b, design §3/§4).
5
- *
6
- * W-S4a shipped the pure `reconcile(desired, observed, now)` and the `tick()`
7
- * debounce guard. This module is the thin, side-effecting dispatcher over them:
8
- * it gathers the two state sets (GitHub via `gh`, kernel via the broker), runs the
9
- * actions `reconcile()` emits (spawn/stop/reap watchers, upsert/retire kernel_pr
10
- * rows), owns the SINGLETON DAEMON lease lifecycle, and provides the approved-seam
11
- * `fireAndForget()` trigger used by session-start, push, and ship.
12
- *
13
- * The NON-BLOCKING / ERROR-SWALLOWING contract is paramount: `fireAndForget()`
14
- * MUST never throw and never affect the command that triggered it. Every spawn is modeled on
15
- * `watch-lifecycle.startPrWatcherDetached` (detached, `stdio:'ignore'`,
16
- * `windowsHide:true`, `.unref()`, no-op `'error'` listener) so a failed launch
17
- * degrades to "not started" rather than crashing.
18
- *
19
- * SAFETY INVARIANTS (guarded by tests):
20
- * - Orphan reaping NEVER `process.kill`s on a PID match alone. It re-verifies at
21
- * kill time: the pid must be alive AND the journal start-time marker for that
22
- * PR must still exist AND equal the watcher entry's `startedAt`. A null/legacy
23
- * startedAt, or an absent/mismatched marker, means "do not kill" (PID reuse
24
- * fail-safe) — the stale entry is dropped silently.
25
- * - The singleton is arbitrated by the O_EXCL shepherd lease: a daemon that loses
26
- * `acquire` exits immediately and spawns nothing.
27
- * - Watcher launch classification branches on CAPABILITY presence
28
- * (`ctx.harness.hasBgShell`), NEVER on harness name; uncertain → detached.
4
+ * Side-effecting half of owner-row shepherd reconciliation. The filesystem
5
+ * lease elects one repository daemon; all per-PR lifecycle changes go through
6
+ * the narrow watch-owner APIs.
29
7
  *
30
8
  * @module pr-monitor/reconcile-executor
31
9
  */
32
10
 
33
11
  const fs = require('node:fs');
34
12
  const path = require('node:path');
13
+ const crypto = require('node:crypto');
35
14
  const { spawn } = require('node:child_process');
36
15
 
37
16
  const shepherdLease = require('./shepherd-lease');
38
- const journal = require('./journal');
17
+ const watchOwner = require('./watch-owner');
39
18
  const { reconcile: defaultReconcile } = require('./reconcile');
40
19
  const { tick: defaultTick } = require('./reconcile-tick');
41
- const { startPrWatcherDetached, forgeBin } = require('./watch-lifecycle');
20
+ const { startPrWatcherDetached, forgeArgs, githubWorkerEnvironment } = require('./watch-lifecycle');
21
+ const { privacySafeIdentity } = require('./flow-monitor');
22
+ const { processIdentityAlive, defaultPidStartedAt } = require('./process-identity');
42
23
  const brokerMod = require('../kernel/broker');
24
+ const { secureExecFileSync } = require('../shell-utils');
43
25
 
44
- const { STALE_MS } = shepherdLease;
45
26
  const CANONICAL_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
46
27
  const GH_COMMAND_TIMEOUT_MS = 30_000;
47
- const LINKAGE_FIELDS = ['issue_id', 'worktree_id', 'journal_ptr'];
28
+ const MAX_OPEN_PRS = 1000;
29
+ const MAX_ACTIONS_PER_PASS = 128;
30
+ const MAX_LEGACY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
31
+ const MAX_MIGRATION_ATTEMPTS = 3;
32
+ const MAX_MONITOR_ID_LENGTH = 128;
33
+ const OWNER_HEARTBEAT_STALE_MS = 4 * 60_000;
48
34
 
49
35
  function normalizeRepository(value) {
50
- return typeof value === 'string' && CANONICAL_REPOSITORY.test(value.trim())
51
- ? value.trim().toLowerCase()
52
- : null;
36
+ if (typeof value !== 'string') return null;
37
+ const normalized = value.trim().toLowerCase();
38
+ return CANONICAL_REPOSITORY.test(normalized) ? normalized : null;
39
+ }
40
+
41
+ function legacyMonitorId(repo, pr) {
42
+ const raw = privacySafeIdentity(`pr:${privacySafeIdentity(repo)}:${pr}`);
43
+ return raw.length <= MAX_MONITOR_ID_LENGTH
44
+ ? raw
45
+ : `pr:${crypto.createHash('sha256').update(raw).digest('hex')}`;
53
46
  }
54
47
 
55
- /** Resolve the GitHub identity used by merge binding, never a bare repo basename. */
56
48
  function resolveCanonicalRepository(runGh) {
57
49
  try {
58
- const raw = runGh(['repo', 'view', '--json', 'owner,name']);
59
- const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
60
- const owner = parsed && parsed.owner && parsed.owner.login;
61
- const name = parsed && parsed.name;
62
- if (typeof owner !== 'string' || owner.trim() === '' || typeof name !== 'string' || name.trim() === '') return null;
63
- return normalizeRepository(`${owner}/${name}`);
50
+ const raw = runGh(['repo', 'view', '--json', 'nameWithOwner,parent']);
51
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
52
+ return normalizeRepository(value?.parent?.nameWithOwner || value?.nameWithOwner);
64
53
  } catch {
65
54
  return null;
66
55
  }
67
56
  }
68
57
 
69
- /**
70
- * Persist the daemon's latest lifecycle outcome beside the lease. This status
71
- * file is deliberately separate from `shepherd.reconcile`: that sentinel's
72
- * mtime is the cold-trigger debounce clock and diagnostics must never move it.
73
- */
58
+ function githubRunner(opts = {}) {
59
+ return opts.runGh || ((args) => secureExecFileSync('gh', args, {
60
+ cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
61
+ }));
62
+ }
63
+
74
64
  function writeDaemonDiagnostic(gitCommonDir, entry, opts = {}) {
65
+ if (!gitCommonDir) return false;
75
66
  try {
76
- const file = path.join(gitCommonDir, 'forge', 'shepherd.status.json');
77
- fs.mkdirSync(path.dirname(file), { recursive: true });
78
- const now = opts.now || (() => Date.now());
79
- const payload = {
80
- ...entry,
81
- pid: opts.pid ?? process.pid,
82
- at: new Date(now()).toISOString(),
83
- };
84
- fs.writeFileSync(file, `${JSON.stringify(payload)}\n`);
67
+ const dir = path.join(gitCommonDir, 'forge');
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ fs.appendFileSync(path.join(dir, 'shepherd-daemon.ndjson'), `${JSON.stringify(entry)}\n`, {
70
+ encoding: 'utf8', mode: 0o600,
71
+ });
85
72
  return true;
86
- } catch {
73
+ } catch (error) {
74
+ try { opts.onDiagnosticError?.(error); } catch { /* diagnostics remain best effort */ }
87
75
  return false;
88
76
  }
89
77
  }
90
78
 
91
- /** Record through an injected test seam or the separate durable status file. */
92
79
  function recordDaemonDiagnostic(opts, gitCommonDir, kind, detail) {
93
80
  const entry = {
94
81
  kind,
95
- ...(detail ? { detail: (detail && detail.message) || String(detail) } : {}),
82
+ at: new Date((opts.now || (() => Date.now()))()).toISOString(),
83
+ ...(detail ? { detail: String(detail?.message || detail).slice(0, 500) } : {}),
96
84
  };
97
85
  try {
98
- if (typeof opts.recordDiagnostic === 'function') opts.recordDiagnostic(entry);
99
- else writeDaemonDiagnostic(gitCommonDir, entry, { now: opts.now });
100
- } catch {
101
- /* diagnostics must never affect a command or daemon lifecycle */
102
- }
103
- }
104
-
105
- /** Normalize a lease watcher entry to the W-S4b `{pr, repo, pid, startedAt}` shape. */
106
- function normalizeWatcher(entry) {
107
- if (typeof entry === 'number') return { pr: entry, repo: null, pid: null, startedAt: null };
108
- return {
109
- pr: entry.pr,
110
- repo: entry.repo ?? null,
111
- pid: entry.pid ?? null,
112
- startedAt: entry.startedAt ?? null,
113
- };
114
- }
115
-
116
- /** File that records a watcher's spawn-time ISO stamp for kill-time re-verification. */
117
- function claimPath(dir) {
118
- return path.join(dir, 'watch.startedat');
119
- }
120
-
121
- /**
122
- * Write the start-time marker for `(repo, pr)` into its journal dir. This is the
123
- * kill-time re-verification token — orphan reaping refuses to kill a pid unless
124
- * this marker still equals the watcher entry's `startedAt`.
125
- */
126
- function writeClaimMarker(projectRoot, repo, pr, startedAt, gitCommonDir) {
127
- if (repo == null || pr == null || startedAt == null) return;
128
- try {
129
- const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
130
- fs.writeFileSync(claimPath(dir), String(startedAt));
131
- } catch {
132
- /* best-effort — a missing marker just means the pid is treated as unverifiable (never reaped) */
133
- }
134
- }
135
-
136
- /** Read the start-time marker for `(repo, pr)`, or null when absent/unreadable. */
137
- function readClaimMarker(projectRoot, repo, pr, gitCommonDir) {
138
- if (repo == null || pr == null) return null;
139
- try {
140
- const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
141
- return fs.readFileSync(claimPath(dir), 'utf8').trim();
142
- } catch {
143
- return null;
144
- }
145
- }
146
-
147
- /**
148
- * Remove the start-time marker for `(repo, pr)` when its watcher is stopped/reaped, so
149
- * a future PID reuse can't match a STALE marker and get treated as the live watcher.
150
- * Best-effort; a missing marker is fine (an unverifiable pid is never reaped anyway).
151
- */
152
- function removeClaimMarker(projectRoot, repo, pr, gitCommonDir) {
153
- if (repo == null || pr == null) return;
154
- try {
155
- const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
156
- fs.rmSync(claimPath(dir), { force: true });
157
- } catch {
158
- /* best-effort marker cleanup */
159
- }
86
+ (opts.writeDaemonDiagnostic || writeDaemonDiagnostic)(gitCommonDir, entry, opts);
87
+ } catch { /* diagnostics never affect daemon lifecycle */ }
160
88
  }
161
89
 
162
- /**
163
- * Gather the DESIRED open-PR set: GitHub's open PRs (`gh pr list`) enriched with
164
- * kernel linkage (issue/worktree/journal) where a `kernel_pr` row already exists.
165
- * A hand-opened PR with no kernel row is still included (issue_id/worktree_id
166
- * null) so the reconciler self-registers it — zero user invocation. External
167
- * fields (branch names) are stored raw and NEVER evaluated.
168
- */
169
90
  async function gatherDesired(gitCommonDir, opts = {}) {
170
- const runGh = opts.runGh || ((args) => require('node:child_process').execFileSync('gh', args, {
171
- cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
172
- }));
173
- // The broker is a live kernel handle from createLocalBroker (listOpenPrs/upsertPr/
174
- // retirePr are INSTANCE methods) — the daemon threads one in. Never fall back to the
175
- // broker MODULE namespace: those methods don't exist there and every call would
176
- // silently no-op behind the catch (this exact bug shipped once — keep it gone).
177
- const broker = opts.broker || null;
178
- // Explicit repo injection remains a test/programmatic seam. Production resolves the
179
- // owner/name pair from GitHub; a bare basename is never inferred from a failed lookup.
180
- const suppliedRepo = typeof opts.repo === 'string' && opts.repo.trim() ? opts.repo.trim() : null;
91
+ const runGh = githubRunner(opts);
92
+ const suppliedRepo = normalizeRepository(opts.repo);
181
93
  const repo = suppliedRepo || resolveCanonicalRepository(runGh);
94
+ if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
182
95
 
183
- let ghPrs = [];
184
- // `listingOk` distinguishes "GitHub says zero open PRs" from "the gh call failed"
185
- // (network/auth/rate-limit). A FAILED listing must be a no-op upstream, never a
186
- // teardown of every watcher+row — the caller skips the reconcile pass when false.
187
- let listingOk = true;
96
+ let ghPrs;
188
97
  try {
189
- // `--limit 1000` overrides gh's default 30-result cap so a repo with >30 open
190
- // PRs is fully enumerated (otherwise the tail would go unwatched or get retired).
191
- const raw = runGh(['pr', 'list', '--state', 'open', '--limit', '1000', '--json', 'number,headRefName,headRefOid']);
192
- const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
193
- if (Array.isArray(parsed)) ghPrs = parsed;
98
+ const raw = runGh(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', String(MAX_OPEN_PRS + 1), '--json', 'number,headRefName,headRefOid']);
99
+ ghPrs = typeof raw === 'string' ? JSON.parse(raw) : raw;
100
+ if (!Array.isArray(ghPrs)) throw new TypeError('PR listing is not an array');
101
+ if (ghPrs.length > MAX_OPEN_PRS) throw new RangeError('PR listing exceeds safe reconciliation bound');
194
102
  } catch {
195
- ghPrs = [];
196
- listingOk = false;
103
+ return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: true, repo };
197
104
  }
198
105
 
199
- if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
200
-
201
106
  let prRows = [];
202
107
  try {
203
- if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
108
+ if (opts.broker) prRows = await opts.broker.listOpenPrs(gitCommonDir);
204
109
  } catch {
205
- /* kernel unavailable → treat as no linkage; the GitHub-driven desired set still stands */
206
- }
207
- // Key by canonical (repo, number). A single bare-name row is accepted
208
- // only as a one-way compatibility bridge; it is rewritten under the canonical key
209
- // and retired by the same reconcile pass. The common-dir natural key plus an exact
210
- // basename/number match preserves links across force-pushes; cross-repository,
211
- // ambiguous, and other malformed rows never bind.
212
- const canonicalRepo = normalizeRepository(repo);
213
- const legacyRepo = canonicalRepo ? canonicalRepo.slice(canonicalRepo.lastIndexOf('/') + 1) : null;
214
- const validPrNumber = (value) => (Number.isSafeInteger(value) && value > 0)
215
- || (typeof value === 'string' && /^[1-9][0-9]*$/.test(value));
216
- const rows = Array.isArray(prRows) ? prRows : [];
217
- if (canonicalRepo) {
218
- const counts = new Map();
219
- const conflicting = rows.some((row) => {
220
- if (!row || !validPrNumber(row.number)) return true;
221
- const rowRepo = normalizeRepository(row.repo);
222
- const canonical = rowRepo === canonicalRepo;
223
- const legacy = typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo;
224
- if (!canonical && !legacy) return true;
225
- const number = String(row.number);
226
- const count = counts.get(number) || { canonical: 0, legacy: 0 };
227
- if (canonical) count.canonical += 1;
228
- else count.legacy += 1;
229
- counts.set(number, count);
230
- return count.canonical > 1 || count.legacy > 1;
231
- });
232
- if (conflicting) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
110
+ return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: true, repo };
233
111
  }
234
112
  const exactRows = new Map();
235
- const legacyRows = new Map();
236
- const add = (map, number, row) => {
237
- if (!validPrNumber(number)) return;
238
- const list = map.get(String(number)) || [];
239
- list.push(row);
240
- map.set(String(number), list);
241
- };
242
- for (const row of rows) {
243
- if (canonicalRepo && normalizeRepository(row && row.repo) === canonicalRepo) add(exactRows, row.number, row);
244
- else if (legacyRepo && row && typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo) add(legacyRows, row.number, row);
245
- else if (!canonicalRepo && row && row.repo === repo) add(exactRows, row.number, row);
246
- }
247
- const coalesceLinkage = (canonical, legacy) => {
248
- if (!canonical || !legacy) return canonical || legacy || null;
249
- const merged = { ...canonical };
250
- for (const field of LINKAGE_FIELDS) {
251
- const canonicalValue = canonical[field] ?? null;
252
- const legacyValue = legacy[field] ?? null;
253
- if (canonicalValue != null && legacyValue != null && canonicalValue !== legacyValue) return null;
254
- if (canonicalValue == null && legacyValue != null) merged[field] = legacyValue;
255
- }
256
- return merged;
257
- };
258
- const mergedRows = new Map();
259
- for (const number of new Set([...exactRows.keys(), ...legacyRows.keys()])) {
260
- const exact = exactRows.get(number) || [];
261
- const legacy = legacyRows.get(number) || [];
262
- const merged = coalesceLinkage(exact[0], legacy[0]);
263
- if (exact.length > 0 && legacy.length > 0 && !merged) {
264
- return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
265
- }
266
- if (merged) mergedRows.set(number, merged);
113
+ for (const row of Array.isArray(prRows) ? prRows : []) {
114
+ const rowRepo = normalizeRepository(row?.repo);
115
+ const number = Number(row?.number);
116
+ if (rowRepo !== repo || !Number.isSafeInteger(number) || number <= 0) continue;
117
+ const current = exactRows.get(number);
118
+ if (current) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false, repo };
119
+ exactRows.set(number, row);
267
120
  }
268
- const rowForPr = (p) => {
269
- if (canonicalRepo) return mergedRows.get(String(p.number)) || null;
270
- const exact = exactRows.get(String(p.number)) || [];
271
- return exact.length === 1 ? exact[0] : null;
272
- };
273
-
274
- const openPrs = ghPrs.map((p) => {
275
- const row = rowForPr(p);
121
+ const openPrs = ghPrs.map((item) => {
122
+ const number = Number(item?.number);
123
+ const row = exactRows.get(number);
276
124
  return {
277
- repo: canonicalRepo || repo,
278
- number: p.number,
279
- branch: p.headRefName ?? null,
280
- headSha: p.headRefOid ?? null,
125
+ repo,
126
+ number,
127
+ branch: item?.headRefName ?? null,
128
+ headSha: item?.headRefOid ?? null,
281
129
  issueId: row?.issue_id ?? null,
282
130
  worktreeId: row?.worktree_id ?? null,
283
131
  journalPtr: row?.journal_ptr ?? null,
284
132
  };
285
133
  });
286
- return { openPrs, gitCommonDir, listingOk };
134
+ if (openPrs.some(item => !Number.isSafeInteger(item.number) || item.number <= 0)) {
135
+ return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false, repo };
136
+ }
137
+ return { openPrs, gitCommonDir, listingOk: true, repositoryOk: true, repo };
287
138
  }
288
139
 
289
- /**
290
- * Gather the OBSERVED state: kernel `kernel_pr` rows, the lease watcher set, and
291
- * which of those watcher pids are actually alive (probed via `pidAlive`).
292
- */
293
- async function gatherObserved(gitCommonDir, lock, opts = {}) {
294
- const broker = opts.broker || null; // live kernel handle threaded by the daemon; never the module namespace
295
- const isAlive = opts.isAlive || shepherdLease.pidAlive;
296
- const readClaim = opts.readClaim
297
- || ((repo, pr) => readClaimMarker(opts.projectRoot, repo, pr, gitCommonDir));
298
- const now = (opts.now || (() => Date.now()))();
140
+ function ownerOptions(ctx) {
141
+ const isPidAlive = ctx.ownerOptions?.isPidAlive || ctx.isAlive || shepherdLease.pidAlive;
142
+ // Both halves of an identity proof must describe the same process. A caller that
143
+ // supplies its own liveness answer (tests, cached legacy evidence) is answering for
144
+ // PIDs the built-in /proc probe knows nothing about, so pairing that answer with the
145
+ // built-in probe would compare a fabricated PID's liveness against a real process's
146
+ // start time and "prove" reuse. The built-in probe travels with the built-in
147
+ // liveness check only; otherwise identity stays unprovable.
148
+ const pidStartedAt = ctx.ownerOptions?.pidStartedAt || ctx.pidStartedAt
149
+ || (isPidAlive === shepherdLease.pidAlive ? defaultPidStartedAt : null);
150
+ return {
151
+ ...(ctx.ownerOptions || {}),
152
+ ...(ctx.driver ? { driver: ctx.driver } : {}),
153
+ ...(ctx.databaseConfig ? { databaseConfig: ctx.databaseConfig } : {}),
154
+ isPidAlive,
155
+ pidStartedAt,
156
+ verifyProviderEvidence: ctx.ownerOptions?.verifyProviderEvidence
157
+ || (async (evidence, expected) => expected.states.includes(String(evidence?.state || '').toLowerCase())),
158
+ verifyTerminalReceipt: ctx.ownerOptions?.verifyTerminalReceipt
159
+ || ctx.verifyTerminalReceipt
160
+ || (async () => false),
161
+ };
162
+ }
299
163
 
164
+ async function gatherObserved(gitCommonDir, _lock, opts = {}) {
300
165
  let prRows = [];
301
166
  try {
302
- if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
167
+ if (opts.broker) prRows = await opts.broker.listOpenPrs(gitCommonDir);
303
168
  } catch {
304
- /* kernel unavailable → observe an empty kernel set; reconcile still converges watchers */
169
+ return { prRows: [], ownerRows: [], ownerRowsOk: false, migrationGate: null };
305
170
  }
171
+ const authority = opts.authority || watchOwner;
172
+ const options = ownerOptions(opts);
173
+ const listed = await authority.enumerateOwners({}, options);
174
+ const gate = typeof authority.readMigrationGate === 'function'
175
+ ? await authority.readMigrationGate({}, options)
176
+ : { ok: false, reason: 'authority_unavailable' };
177
+ if (!listed?.ok || !Array.isArray(listed.records) || !gate?.ok || !gate.gate) {
178
+ return {
179
+ prRows: Array.isArray(prRows) ? prRows : [], ownerRows: [], ownerRowsOk: false,
180
+ migrationGate: gate?.gate || null,
181
+ };
182
+ }
183
+ const isAlive = options.isPidAlive;
184
+ const pidStartedAt = options.pidStartedAt;
185
+ const observedAt = Number((opts.now || (() => Date.now()))());
186
+ const heartbeatStaleMs = opts.ownerHeartbeatStaleMs ?? OWNER_HEARTBEAT_STALE_MS;
187
+ const ownerRows = [];
188
+ for (const record of listed.records) {
189
+ const next = { ...record };
190
+ if (record.controllerPid != null) {
191
+ // The controller itself wrote this row at `updatedAt`, so it was running then.
192
+ // A process holding the same number that booted afterwards is a different
193
+ // process: the controller is gone and the PID was reused. Recovery must not
194
+ // defer to it, and the proof travels with the row so the authority
195
+ // transaction can accept recovery instead of rejecting on a bare live PID.
196
+ const controllerState = await processIdentityAlive({
197
+ pid: record.controllerPid, startedAt: record.updatedAt, isPidAlive: isAlive, pidStartedAt,
198
+ });
199
+ next.controllerAlive = controllerState === 'alive';
200
+ if (controllerState === 'reused') next.controllerPidReuseProven = true;
201
+ }
202
+ if (record.watcherPid != null) {
203
+ // The watcher stamps `heartbeatAt` itself, making it the tightest honest
204
+ // marker for watcher identity. Blocked legacy rows never beat — the schema
205
+ // forbids it — so their marker is the legacy `startedAt` the import retained.
206
+ // Without it a legacy watcher that exited and had its PID inherited reads as
207
+ // alive forever, recheckLegacyBlocked never fires, and the PR stays unwatched.
208
+ const watcherMarker = record.phase === 'blocked' ? record.startedAt : record.heartbeatAt;
209
+ const watcherState = await processIdentityAlive({
210
+ pid: record.watcherPid, startedAt: watcherMarker, isPidAlive: isAlive, pidStartedAt,
211
+ });
212
+ if (watcherState === 'reused') next.watcherPidReuseProven = true;
213
+ const pidAlive = watcherState === 'alive';
214
+ const heartbeatRequired = record.phase === 'running'
215
+ || record.phase === 'stop_requested'
216
+ || record.phase === 'terminal_pending';
217
+ const heartbeatAt = Date.parse(record.heartbeatAt);
218
+ const heartbeatFresh = Number.isFinite(heartbeatAt)
219
+ && Number.isFinite(observedAt)
220
+ && observedAt >= heartbeatAt
221
+ && observedAt - heartbeatAt <= heartbeatStaleMs;
222
+ next.watcherAlive = pidAlive && (!heartbeatRequired || heartbeatFresh);
223
+ }
224
+ ownerRows.push(next);
225
+ }
226
+ return {
227
+ prRows: Array.isArray(prRows) ? prRows : [], ownerRows, ownerRowsOk: true,
228
+ migrationGate: gate.gate,
229
+ };
230
+ }
306
231
 
307
- const watchers = (lock && Array.isArray(lock.watchers) ? lock.watchers : []).map(normalizeWatcher);
308
- // A pid is "live" ONLY when it is alive AND its journal start-time marker still
309
- // equals the entry's startedAt — the SAME check verifiedKill makes at kill time.
310
- // A reused PID (alive, but a mismatched/absent marker) must NOT be reported live,
311
- // or reconcile would suppress the startWatcher and leave the PR unmonitored.
312
- const liveWatcherPids = watchers
313
- .filter((w) => {
314
- if (w.pid == null || !isAlive(w.pid)) return false;
315
- const marker = readClaim(w.repo, w.pr);
316
- return marker != null && String(marker) === String(w.startedAt);
317
- })
318
- .map((w) => ({ pid: w.pid, startedAt: w.startedAt ?? null }));
319
-
320
- const beat = lock ? Date.parse(lock.heartbeatAt) : NaN;
321
- const leaseFresh = Number.isFinite(beat) && (now - beat) < STALE_MS;
232
+ function identity(recordOrPr) {
233
+ return { repo: recordOrPr.repo, pr: Number(recordOrPr.pr ?? recordOrPr.number) };
234
+ }
322
235
 
323
- return { lease: lock || null, leaseFresh, prRows: Array.isArray(prRows) ? prRows : [], liveWatcherPids };
236
+ function operationInput(record) {
237
+ return { generation: record.generation, pid: record.watcherPid };
324
238
  }
325
239
 
326
- /**
327
- * Kill a watcher pid ONLY after re-verifying start-time (design risk #4). Returns
328
- * true iff the kill actually happened. Never kills on a PID match alone.
329
- */
330
- function verifiedKill(entry, ctx) {
331
- if (!entry || entry.pid == null || entry.startedAt == null) return false;
332
- const isAlive = ctx.isAlive || shepherdLease.pidAlive;
333
- if (!isAlive(entry.pid)) return false;
334
- const readClaim = ctx.readClaim
335
- || ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir));
336
- const claim = readClaim(entry);
337
- if (claim == null || String(claim) !== String(entry.startedAt)) return false;
240
+ async function bindSpawned(reservation, pr, s) {
241
+ if (!reservation?.ok || !reservation.record) return reservation || { ok: false, reason: 'reservation_failed' };
242
+ const record = reservation.record;
243
+ let spawned;
338
244
  try {
339
- (ctx.kill || process.kill)(entry.pid);
245
+ spawned = await s.spawnWatcher({
246
+ prNumber: pr.number,
247
+ repository: pr.repo,
248
+ reservation,
249
+ controllerPid: s.controllerPid,
250
+ cwd: s.projectRoot,
251
+ gitCommonDir: s.gitCommonDir,
252
+ owner: s.authority,
253
+ ownerOptions: s.options,
254
+ });
340
255
  } catch {
341
- return false;
256
+ spawned = null;
257
+ }
258
+ const pid = Number(spawned?.pid);
259
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
260
+ return s.authority.abortStarting(identity(pr), {
261
+ generation: record.generation, controllerPid: s.controllerPid,
262
+ }, s.options);
342
263
  }
343
- return true;
264
+ return s.authority.bindRunning(identity(pr), {
265
+ generation: record.generation, controllerPid: s.controllerPid, pid,
266
+ }, s.options);
267
+ }
268
+
269
+ // Re-reads the on-disk legacy evidence for one blocked legacy row and returns the
270
+ // terminal receipt the legacy watcher wrote after the migration snapshot was taken.
271
+ // Ambiguous or unreadable evidence returns null so the caller fails closed.
272
+ async function recoverLegacyTerminalReceipt(owner, s) {
273
+ let snapshot;
274
+ try { snapshot = await s.readLegacySnapshot(); } catch { return null; }
275
+ if (!snapshot || snapshot.corrupt === true) return null;
276
+ const repo = normalizeRepository(owner?.repo);
277
+ const pr = Number(owner?.pr);
278
+ if (!repo || !Number.isSafeInteger(pr) || pr <= 0) return null;
279
+ const receipts = new Set();
280
+ for (const entry of Array.isArray(snapshot.entries) ? snapshot.entries : []) {
281
+ if (normalizeRepository(entry?.repo) !== repo || Number(entry?.pr) !== pr) continue;
282
+ if (entry?.terminalReceiptId) receipts.add(String(entry.terminalReceiptId));
283
+ }
284
+ return receipts.size === 1 ? [...receipts][0] : null;
344
285
  }
345
286
 
346
- /**
347
- * Per-action-type handlers, keyed by `action.type`. Extracted from `execute` so
348
- * each is small and independently testable and the dispatcher stays a flat loop
349
- * (keeps `execute`'s cognitive complexity under the SonarCloud gate). Each handler
350
- * mutates the shared `s` state (`s.watchers` is reassigned by stop/reap) and the
351
- * behavior is identical to the former if/else-if chain.
352
- */
353
287
  const ACTION_HANDLERS = {
354
- startWatcher(action, s) {
355
- const startedAt = new Date(s.now()).toISOString();
356
- const repo = action.pr.repo ?? s.repo ?? null;
357
- const res = s.spawnWatcher({
358
- prNumber: action.pr.number, cwd: s.projectRoot, gitCommonDir: s.gitCommonDir,
359
- });
360
- const pid = res && res.pid != null ? res.pid : null;
361
- // A pid-less result means the watcher is already running (ship/push/adopt started it,
362
- // startPrWatcherDetached → {started:false, reason:'already-running'}) or the spawn
363
- // failed. Do NOT record a {pid:null} entry: gatherObserved never counts it live, so
364
- // each interval would re-emit startWatcher and append another null entry forever.
365
- if (pid == null) return;
366
- const entry = { pr: action.pr.number, repo, pid, startedAt };
367
- s.watchers.push(entry);
368
- s.writeClaim(entry);
288
+ async reserveWatcher(action, s) {
289
+ const result = await s.authority.reserveStarting(identity(action.pr), {
290
+ controllerPid: s.controllerPid,
291
+ }, s.options);
292
+ return bindSpawned(result, action.pr, s);
293
+ },
294
+ async recoverStarting(action, s) {
295
+ const result = await s.authority.recoverDeadStarting(identity(action.owner), {
296
+ generation: action.owner.generation,
297
+ controllerPid: action.owner.controllerPid,
298
+ recoveryControllerPid: s.controllerPid,
299
+ pidReuseProven: action.owner.controllerPidReuseProven === true,
300
+ }, s.options);
301
+ return bindSpawned(result, action.pr || { repo: action.owner.repo, number: action.owner.pr }, s);
369
302
  },
370
- stopWatcher(action, s) {
371
- for (const entry of s.watchers.filter((w) => w.pr === action.pr.number)) {
372
- verifiedKill(entry, s.ctx);
373
- s.removeClaim(entry); // clear the start-time marker so a reused PID can't match it later
303
+ async retryStarting(action, s) {
304
+ const released = await s.authority.abortStarting(identity(action.owner), {
305
+ generation: action.owner.generation, controllerPid: s.controllerPid,
306
+ }, s.options);
307
+ if (!released?.ok || released.changed !== true) {
308
+ return released || { ok: false, reason: 'starting_release_failed' };
374
309
  }
375
- s.watchers = s.watchers.filter((w) => w.pr !== action.pr.number);
310
+ const pr = action.pr || { repo: action.owner.repo, number: action.owner.pr };
311
+ const reservation = await s.authority.reserveStarting(identity(pr), {
312
+ controllerPid: s.controllerPid,
313
+ }, s.options);
314
+ return bindSpawned(reservation, pr, s);
376
315
  },
377
- reapOrphan(action, s) {
378
- const entry = s.watchers.find((w) => w.pid === action.pid && w.startedAt === action.startedAt);
379
- verifiedKill(entry, s.ctx);
380
- if (entry) s.removeClaim(entry);
381
- s.watchers = s.watchers.filter((w) => !(w.pid === action.pid && w.startedAt === action.startedAt));
316
+ async recoverWatcher(action, s) {
317
+ const result = await s.authority.recoverDeadWatcher(identity(action.owner), {
318
+ ...operationInput(action.owner),
319
+ recoveryControllerPid: s.controllerPid,
320
+ pidReuseProven: action.owner.watcherPidReuseProven === true,
321
+ providerEvidence: { state: action.providerState },
322
+ }, s.options);
323
+ return bindSpawned(result, action.pr || { repo: action.owner.repo, number: action.owner.pr }, s);
324
+ },
325
+ async reopenWatcher(action, s) {
326
+ const result = await s.authority.reserveReopened(identity(action.owner), {
327
+ generation: action.owner.generation,
328
+ expectedReceiptId: action.owner.terminalReceiptId,
329
+ controllerPid: s.controllerPid,
330
+ providerEvidence: { state: 'open' },
331
+ }, s.options);
332
+ return bindSpawned(result, action.pr, s);
333
+ },
334
+ async requestStop(action, s) {
335
+ return s.authority.requestStop(identity(action.owner), operationInput(action.owner), s.options);
336
+ },
337
+ async completeTerminal(action, s) {
338
+ return s.authority.completeTerminal(identity(action.owner), {
339
+ ...operationInput(action.owner),
340
+ pidReuseProven: action.owner.watcherPidReuseProven === true,
341
+ terminalReceiptId: action.owner.terminalReceiptId,
342
+ }, s.options);
343
+ },
344
+ async recheckLegacyBlocked(action, s) {
345
+ const terminal = action.providerState === 'terminal';
346
+ // A legacy watcher imported while its PR was still open carries no terminal
347
+ // receipt. If the PR later reaches a terminal state and the legacy process
348
+ // exits, releasing the row plainly drops the terminal receipt on the floor:
349
+ // the PR is already absent from the open-PR listing, so no replacement watcher
350
+ // will ever record it. Recover the receipt the exiting legacy watcher wrote and
351
+ // complete instead; fail closed (leave the row blocked for the next pass) when
352
+ // no single unambiguous receipt can be recovered.
353
+ let terminalReceiptId = terminal ? action.owner.terminalReceiptId || null : null;
354
+ if (terminal && !terminalReceiptId) {
355
+ terminalReceiptId = await recoverLegacyTerminalReceipt(action.owner, s);
356
+ if (!terminalReceiptId) return { ok: false, reason: 'terminal_receipt_unrecovered' };
357
+ }
358
+ const complete = terminal && Boolean(terminalReceiptId);
359
+ return s.authority.recheckLegacyBlocked(identity(action.owner), {
360
+ generation: action.owner.generation,
361
+ legacyEvidenceHash: action.owner.legacyEvidenceHash,
362
+ pid: action.owner.watcherPid,
363
+ pidReuseProven: action.owner.watcherPidReuseProven === true,
364
+ action: complete ? 'complete' : 'release',
365
+ ...(complete ? { terminalReceiptId } : {}),
366
+ }, s.options);
382
367
  },
383
368
  async upsertPrRow(action, s) {
369
+ if (!s.broker) return { ok: false, reason: 'kernel_unavailable' };
384
370
  try {
385
- if (!s.broker) return true;
386
371
  const result = await s.broker.upsertPr(action.row);
387
- return result !== false && !(result && result.ok === false);
372
+ return result === false || result?.ok === false
373
+ ? { ok: false, reason: 'kernel_write_failed' }
374
+ : { ok: true, changed: true };
388
375
  } catch {
389
- /* derived reconcile state — a failed upsert retries on the next converge */
390
- return false;
376
+ return { ok: false, reason: 'kernel_write_failed' };
391
377
  }
392
378
  },
393
379
  async retire(action, s) {
380
+ if (!s.broker) return { ok: false, reason: 'kernel_unavailable' };
394
381
  try {
395
- if (s.broker) await s.broker.retirePr(
382
+ await s.broker.retirePr(
396
383
  { git_common_dir: s.gitCommonDir, repo: action.pr.repo, number: action.pr.number },
397
384
  { state: 'closed', retired_at: new Date(s.now()).toISOString() },
398
385
  );
386
+ return { ok: true, changed: true };
399
387
  } catch {
400
- /* retried on the next converge */
388
+ return { ok: false, reason: 'kernel_write_failed' };
401
389
  }
402
390
  },
403
391
  };
404
392
 
405
- /**
406
- * Dispatch a reconcile action set in order. A failed kernel upsert stops the pass so
407
- * dependent retire actions retry on the next converge. Returns the updated
408
- * watcher entry list (`{pr,repo,pid,startedAt}[]`) for the caller to publish via
409
- * `updateWatchers`. `ctx.watchers` seeds the current set (from observed state).
410
- */
411
393
  async function execute(actions, ctx = {}) {
394
+ if (!Array.isArray(actions)) {
395
+ const error = new TypeError('Reconcile actions must be an array');
396
+ error.code = 'INVALID_ACTIONS';
397
+ throw error;
398
+ }
399
+ const authority = ctx.authority || watchOwner;
412
400
  const s = {
413
- broker: ctx.broker || null, // live kernel handle threaded by the daemon; never the module namespace
401
+ authority,
402
+ options: ownerOptions(ctx),
403
+ controllerPid: Number.isSafeInteger(ctx.controllerPid) && ctx.controllerPid > 0 ? ctx.controllerPid : process.pid,
414
404
  spawnWatcher: ctx.spawnWatcher || startPrWatcherDetached,
415
- writeClaim: ctx.writeClaim
416
- || ((e) => writeClaimMarker(ctx.projectRoot, e.repo, e.pr, e.startedAt, ctx.gitCommonDir)),
417
- removeClaim: ctx.removeClaim
418
- || ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir)),
419
- now: ctx.now || (() => Date.now()),
420
- repo: ctx.repo,
405
+ broker: ctx.broker || null,
421
406
  projectRoot: ctx.projectRoot,
422
407
  gitCommonDir: ctx.gitCommonDir,
423
- watchers: Array.isArray(ctx.watchers) ? ctx.watchers.map(normalizeWatcher) : [],
424
- ctx, // verifiedKill reads isAlive/readClaim/kill/projectRoot off the original ctx
408
+ readLegacySnapshot: ctx.readLegacySnapshot || (() => defaultReadLegacySnapshot(ctx.projectRoot, ctx)),
409
+ now: ctx.now || (() => Date.now()),
425
410
  };
426
-
427
- for (const action of (Array.isArray(actions) ? actions : [])) {
428
- const handler = ACTION_HANDLERS[action && action.type];
429
- if (handler && (await handler(action, s)) === false) break;
411
+ let changed = false;
412
+ const results = [];
413
+ for (const action of actions.slice(0, MAX_ACTIONS_PER_PASS)) {
414
+ const handler = ACTION_HANDLERS[action?.type];
415
+ if (!handler) continue;
416
+ const result = await handler(action, s);
417
+ results.push({ type: action.type, result });
418
+ changed ||= result?.changed === true;
419
+ if (result?.ok === false && (action.type === 'upsertPrRow' || result.reason === 'authority_unavailable')) break;
430
420
  }
431
- return s.watchers;
421
+ return { ok: results.every(item => item.result?.ok !== false), changed, results };
422
+ }
423
+
424
+ function activeOwnerCount(records) {
425
+ return records.filter(record => record.phase !== 'complete').length;
432
426
  }
433
427
 
434
- /**
435
- * One converge pass: gather → reconcile → execute → publish watchers. Used by the
436
- * daemon loop and directly unit-testable with injected gather/reconcile/execute.
437
- * Returns `{ actions, watchers, desiredCount }`.
438
- */
439
428
  async function convergeOnce(projectRoot, opts = {}) {
440
429
  const gitCommonDir = opts.gitCommonDir;
441
- const reconcile = opts.reconcile || defaultReconcile;
442
- const now = opts.now || (() => Date.now());
443
- const lock = opts.lock !== undefined ? opts.lock : null; // daemon threads the live lock in; default null
444
-
445
430
  const desired = opts.gatherDesired
446
431
  ? await opts.gatherDesired()
447
432
  : await gatherDesired(gitCommonDir, { ...opts, projectRoot });
448
-
449
- // A FAILED gh listing (listingOk === false) is a transient outage, not "zero open
450
- // PRs". Skipping the reconcile+execute pass entirely makes it a true no-op — no
451
- // observe, no retire/stopWatcher teardown of every row+watcher. desiredCount is
452
- // left non-zero (null) so the daemon does NOT read it as "no PRs → self-retire".
453
- if (desired && desired.listingOk === false) {
454
- const keep = (lock && Array.isArray(lock.watchers)) ? lock.watchers : (opts.watchers || []);
455
- return { actions: [], watchers: keep, desiredCount: null, listingOk: false };
433
+ if (!desired || desired.listingOk === false || desired.repositoryOk === false) {
434
+ return { actions: [], desiredCount: null, authorityOk: false, activeOwnerCount: null, listingOk: false };
456
435
  }
457
-
458
436
  const observed = opts.gatherObserved
459
437
  ? await opts.gatherObserved()
460
- : await gatherObserved(gitCommonDir, lock, { ...opts, now, projectRoot });
461
-
462
- const { actions } = reconcile(desired, observed, now());
463
- const seedWatchers = observed.lease && Array.isArray(observed.lease.watchers)
464
- ? observed.lease.watchers
465
- : (opts.watchers || []);
466
- const watchers = await execute(actions, {
467
- ...opts,
468
- projectRoot,
469
- gitCommonDir,
470
- watchers: seedWatchers,
471
- now,
438
+ : await gatherObserved(gitCommonDir, null, { ...opts, projectRoot });
439
+ const controllerPid = Number.isSafeInteger(opts.controllerPid) && opts.controllerPid > 0
440
+ ? opts.controllerPid
441
+ : process.pid;
442
+ const decision = (opts.reconcile || defaultReconcile)(
443
+ { ...desired, controllerPid }, observed, (opts.now || (() => Date.now()))(),
444
+ );
445
+ const actions = Array.isArray(decision?.actions) ? decision.actions : [];
446
+ const execution = await (opts.execute || execute)(actions, { ...opts, projectRoot, gitCommonDir });
447
+
448
+ const authority = opts.authority || watchOwner;
449
+ const listed = await authority.enumerateOwners({}, ownerOptions(opts));
450
+ const authorityOk = listed?.ok === true && Array.isArray(listed.records);
451
+ return {
452
+ actions,
453
+ desiredCount: desired.openPrs.length,
454
+ authorityOk,
455
+ activeOwnerCount: authorityOk ? activeOwnerCount(listed.records) : null,
456
+ executionOk: execution?.ok === true,
457
+ };
458
+ }
459
+
460
+ function daemonCanRetire(convergence) {
461
+ return convergence?.desiredCount === 0
462
+ && convergence.authorityOk === true
463
+ && convergence.activeOwnerCount === 0
464
+ && convergence.executionOk !== false;
465
+ }
466
+
467
+ function stableJson(value) {
468
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
469
+ if (value && typeof value === 'object') {
470
+ return `{${Object.keys(value).sort((left, right) => left.localeCompare(right)).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
471
+ }
472
+ return JSON.stringify(value);
473
+ }
474
+
475
+ const LEGACY_HASH_ENTRY_FIELDS = [
476
+ 'repo', 'pr', 'pid', 'startedAt', 'terminalReceiptId', 'providerState', 'legacyPhase',
477
+ 'generation', 'controllerPid', 'blockReason',
478
+ 'lifecycleConflict', 'legacyEvidence',
479
+ ];
480
+
481
+ function projectLegacyHashEntry(entry) {
482
+ const projected = {};
483
+ for (const field of LEGACY_HASH_ENTRY_FIELDS) {
484
+ if (Object.hasOwn(entry || {}, field)) projected[field] = entry[field];
485
+ }
486
+ return projected;
487
+ }
488
+
489
+ function hashLegacyEntry(entry) {
490
+ return crypto.createHash('sha256').update(stableJson(projectLegacyHashEntry(entry))).digest('hex');
491
+ }
492
+
493
+ // Legacy discovery labels each root by how the *invoking* checkout reached it, so the same
494
+ // shared marker is recorded as `.forge/pr-monitor/...` from the primary checkout and
495
+ // `git-common-root/.forge/pr-monitor/...` from a linked worktree. Hash a checkout-independent
496
+ // identity instead, or an interrupted cutover resumed from another checkout self-conflicts.
497
+ const LEGACY_ROOT_LABELS = [
498
+ 'git-common-root/.forge/pr-monitor',
499
+ 'git-common/forge/pr-monitor',
500
+ '.forge/pr-monitor',
501
+ ];
502
+
503
+ function canonicalMarkerPath(rawPath) {
504
+ let value = String(rawPath ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
505
+ for (const label of LEGACY_ROOT_LABELS) {
506
+ if (value === label || value.startsWith(`${label}/`)) {
507
+ value = value.slice(label.length).replace(/^\/+/, '');
508
+ break;
509
+ }
510
+ }
511
+ return process.platform === 'win32' ? value.toLowerCase() : value;
512
+ }
513
+
514
+ function canonicalMarkers(sources) {
515
+ const unique = new Map();
516
+ for (const source of sources) {
517
+ const marker = { path: canonicalMarkerPath(source?.path), content: source?.content };
518
+ unique.set(stableJson(marker), marker);
519
+ }
520
+ return [...unique.values()].sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
521
+ }
522
+
523
+ function hashLegacySnapshot(snapshot) {
524
+ const canonical = snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)
525
+ ? {
526
+ corrupt: snapshot.corrupt === true,
527
+ unmappable: snapshot.unmappable === true,
528
+ entries: (Array.isArray(snapshot.entries) ? snapshot.entries : [])
529
+ .map(projectLegacyHashEntry)
530
+ .sort((left, right) => stableJson(left).localeCompare(stableJson(right))),
531
+ markers: canonicalMarkers((Array.isArray(snapshot.sources) ? snapshot.sources : [])
532
+ .filter(source => /generation|cleanup/i.test(path.basename(String(source?.path || ''))))),
533
+ }
534
+ : snapshot;
535
+ const encoded = stableJson(canonical);
536
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_LEGACY_SNAPSHOT_BYTES) {
537
+ const error = new Error('Legacy watcher snapshot exceeds the migration bound');
538
+ error.code = 'LEGACY_SNAPSHOT_TOO_LARGE';
539
+ throw error;
540
+ }
541
+ return crypto.createHash('sha256').update(encoded).digest('hex');
542
+ }
543
+
544
+ const LEGACY_LIFECYCLE_FIELDS = [
545
+ 'pid', 'startedAt', 'terminalReceiptId', 'providerState', 'legacyPhase',
546
+ 'generation', 'controllerPid', 'blockReason',
547
+ ];
548
+
549
+ function consolidateLegacyEntries(rawEntries) {
550
+ const grouped = new Map();
551
+ let unmappable = false;
552
+ for (const raw of Array.isArray(rawEntries) ? rawEntries : []) {
553
+ const repo = normalizeRepository(raw?.repo);
554
+ const pr = Number(raw?.pr);
555
+ if (!repo || !Number.isSafeInteger(pr) || pr <= 0) {
556
+ unmappable = true;
557
+ continue;
558
+ }
559
+ const key = `${repo}#${pr}`;
560
+ let group = grouped.get(key);
561
+ if (!group) {
562
+ group = { repo, pr, values: {}, evidence: new Map(), lifecycleConflict: false };
563
+ grouped.set(key, group);
564
+ }
565
+ const normalized = { repo, pr };
566
+ for (const field of LEGACY_LIFECYCLE_FIELDS) {
567
+ const value = raw?.[field] == null ? null : raw[field];
568
+ normalized[field] = value;
569
+ if (value == null) continue;
570
+ if (group.values[field] != null && stableJson(group.values[field]) !== stableJson(value)) {
571
+ group.lifecycleConflict = true;
572
+ } else {
573
+ group.values[field] = value;
574
+ }
575
+ }
576
+ group.evidence.set(stableJson(normalized), normalized);
577
+ }
578
+ const entries = [...grouped.values()].map(group => ({
579
+ repo: group.repo,
580
+ pr: group.pr,
581
+ ...group.values,
582
+ lifecycleConflict: group.lifecycleConflict,
583
+ legacyEvidence: [...group.evidence.values()].sort((left, right) => stableJson(left).localeCompare(stableJson(right))),
584
+ })).sort((left, right) => left.repo.localeCompare(right.repo) || left.pr - right.pr);
585
+ return { entries, unmappable };
586
+ }
587
+
588
+ const OWNER_REREAD_FIELDS = [
589
+ 'version', 'repo', 'pr', 'generation', 'phase', 'controllerPid', 'watcherPid',
590
+ 'startedAt', 'updatedAt', 'heartbeatAt', 'terminalReceiptId', 'blockReason', 'legacyEvidenceHash',
591
+ ];
592
+
593
+ function ownerRereadProjection(record, fields = OWNER_REREAD_FIELDS) {
594
+ const projected = {};
595
+ for (const field of fields) {
596
+ if (Object.hasOwn(record || {}, field)) projected[field] = record[field];
597
+ }
598
+ return projected;
599
+ }
600
+
601
+ function ownerRowsMatch(expectedRows, actualRows) {
602
+ if (expectedRows.length !== actualRows.length) return false;
603
+ const actualByKey = new Map();
604
+ for (const row of actualRows) {
605
+ const key = `${row?.repo}#${row?.pr}`;
606
+ if (actualByKey.has(key)) return false;
607
+ actualByKey.set(key, row);
608
+ }
609
+ return expectedRows.every((expected) => {
610
+ const actual = actualByKey.get(`${expected.repo}#${expected.pr}`);
611
+ if (!actual) return false;
612
+ const fields = OWNER_REREAD_FIELDS.filter(field => Object.hasOwn(expected, field));
613
+ return stableJson(ownerRereadProjection(actual, fields)) === stableJson(ownerRereadProjection(expected, fields));
472
614
  });
615
+ }
616
+
617
+ function readOptionalFile(file) {
618
+ try { return fs.readFileSync(file, 'utf8'); } catch (error) {
619
+ if (error?.code === 'ENOENT') return null;
620
+ throw error;
621
+ }
622
+ }
473
623
 
474
- let leaseLost = false;
475
- const updateWatchers = opts.updateWatchers || shepherdLease.updateWatchers;
476
- if (opts.token) {
624
+ function defaultReadLegacySnapshot(projectRoot, opts = {}) {
625
+ const gitCommonDir = opts.gitCommonDir || brokerMod.resolveGitCommonDir(projectRoot);
626
+ const repo = normalizeRepository(opts.repo);
627
+ const readDirectory = opts.readDirectory || fs.readdirSync;
628
+ const sources = [];
629
+ const entries = [];
630
+ let unmappable = false;
631
+ const commonRoot = path.basename(gitCommonDir).toLowerCase() === '.git'
632
+ ? path.dirname(gitCommonDir) : projectRoot;
633
+ const leaseFile = path.join(gitCommonDir, 'forge', 'shepherd.lock');
634
+ const leaseRaw = readOptionalFile(leaseFile);
635
+ if (leaseRaw != null) {
636
+ let lease;
637
+ try { lease = JSON.parse(leaseRaw); } catch { return { entries, sources, corrupt: true, unmappable: false }; }
638
+ const legacyWatchers = Array.isArray(lease.watchers) ? lease.watchers : [];
639
+ sources.push({ path: 'shepherd.lock#watchers', content: stableJson(legacyWatchers) });
640
+ for (const watcher of legacyWatchers) {
641
+ const value = typeof watcher === 'number' ? { pr: watcher } : watcher;
642
+ const pr = Number(value?.pr);
643
+ const identityRepo = normalizeRepository(value?.repo) || repo;
644
+ if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
645
+ entries.push({ repo: identityRepo, pr, pid: Number(value?.pid) || null, startedAt: value?.startedAt || null });
646
+ }
647
+ }
648
+ const roots = [
649
+ { dir: path.join(projectRoot, '.forge', 'pr-monitor'), label: '.forge/pr-monitor' },
650
+ { dir: path.join(commonRoot, '.forge', 'pr-monitor'), label: 'git-common-root/.forge/pr-monitor' },
651
+ { dir: path.join(gitCommonDir, 'forge', 'pr-monitor'), label: 'git-common/forge/pr-monitor' },
652
+ ];
653
+ const seenRoots = new Set();
654
+ for (const candidate of roots) {
655
+ const resolved = path.resolve(candidate.dir).toLowerCase();
656
+ if (seenRoots.has(resolved)) continue;
657
+ seenRoots.add(resolved);
658
+ let directories = [];
477
659
  try {
478
- // updateWatchers returns false when the lock is gone or owned by a DIFFERENT
479
- // token — i.e. THIS daemon was superseded (its stale lease reclaimed by a newer
480
- // one). Signal the caller to stop: a superseded daemon must not keep spawning/
481
- // reaping watchers behind the live owner's back.
482
- const published = updateWatchers(projectRoot, watchers, { gitCommonDir, token: opts.token });
483
- if (published === false) leaseLost = true;
484
- } catch {
485
- /* publishing the watcher set is best-effort — the next pass re-derives it */
660
+ directories = readDirectory(candidate.dir, { withFileTypes: true })
661
+ .filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
662
+ }
663
+ catch (error) { if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable }; }
664
+ for (const directory of directories) {
665
+ if (directory.name === 'owners') continue;
666
+ const dir = path.join(candidate.dir, directory.name);
667
+ const snapshotRaw = readOptionalFile(path.join(dir, 'snapshot.json'));
668
+ const pidRaw = readOptionalFile(path.join(dir, 'watch.pid'));
669
+ const startedAtRaw = readOptionalFile(path.join(dir, 'watch.startedat'));
670
+ // Discover generation/cleanup markers BEFORE the emptiness check. An
671
+ // interrupted legacy cleanup can leave a directory holding nothing but a
672
+ // marker; skipping it would keep that marker out of the snapshot and its
673
+ // hash, letting the gate complete as if no legacy lifecycle authority
674
+ // existed and a new owner generation start beside an unresolved legacy one.
675
+ let markerFiles = [];
676
+ try {
677
+ markerFiles = readDirectory(dir, { withFileTypes: true })
678
+ .filter(item => item.isFile() && /(?:generation|cleanup)/i.test(item.name))
679
+ .sort((left, right) => left.name.localeCompare(right.name));
680
+ } catch (error) {
681
+ if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable };
682
+ }
683
+ if (snapshotRaw == null && pidRaw == null && startedAtRaw == null && markerFiles.length === 0) continue;
684
+ const sourcePrefix = `${candidate.label}/${directory.name}`;
685
+ sources.push({ path: `${sourcePrefix}/snapshot.json`, content: snapshotRaw });
686
+ sources.push({ path: `${sourcePrefix}/watch.pid`, content: pidRaw });
687
+ sources.push({ path: `${sourcePrefix}/watch.startedat`, content: startedAtRaw });
688
+ for (const marker of markerFiles) {
689
+ sources.push({ path: `${sourcePrefix}/${marker.name}`, content: readOptionalFile(path.join(dir, marker.name)) });
690
+ }
691
+ let record;
692
+ try { record = snapshotRaw == null ? null : JSON.parse(snapshotRaw); }
693
+ catch { return { entries, sources, corrupt: true, unmappable }; }
694
+ const snapshot = record?.snapshot || record;
695
+ const terminalReceiptId = record?.terminalReceiptId || snapshot?.terminalReceiptId || null;
696
+ const hasLifecycleAuthority = pidRaw != null || startedAtRaw != null || markerFiles.length > 0
697
+ || terminalReceiptId != null || record?.startedAt != null || snapshot?.startedAt != null;
698
+ const identityRepo = normalizeRepository(snapshot?.repo) || repo;
699
+ const pr = Number(snapshot?.pr);
700
+ if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
701
+ if (!hasLifecycleAuthority) continue;
702
+ entries.push({
703
+ repo: identityRepo,
704
+ pr,
705
+ pid: pidRaw == null ? null : Number(pidRaw.trim()),
706
+ startedAt: snapshot?.startedAt || startedAtRaw?.trim() || null,
707
+ terminalReceiptId,
708
+ providerState: snapshot?.state || null,
709
+ });
710
+ }
711
+ const ownersRoot = path.join(candidate.dir, 'owners');
712
+ let ownerDirectories = [];
713
+ try {
714
+ ownerDirectories = readDirectory(ownersRoot, { withFileTypes: true })
715
+ .filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
716
+ }
717
+ catch (error) { if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable }; }
718
+ for (const directory of ownerDirectories) {
719
+ const relative = `${candidate.label}/owners/${directory.name}/watch.owner.json`;
720
+ const raw = readOptionalFile(path.join(ownersRoot, directory.name, 'watch.owner.json'));
721
+ if (raw == null) continue;
722
+ sources.push({ path: relative, content: raw });
723
+ let record;
724
+ try { record = JSON.parse(raw); } catch { return { entries, sources, corrupt: true, unmappable }; }
725
+ const identityRepo = normalizeRepository(record?.repo);
726
+ const pr = Number(record?.pr);
727
+ if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
728
+ entries.push({
729
+ repo: identityRepo,
730
+ pr,
731
+ pid: record.pid == null ? null : Number(record.pid),
732
+ startedAt: record.startedAt || null,
733
+ terminalReceiptId: record.terminalReceiptId || null,
734
+ legacyPhase: record.phase || null,
735
+ generation: record.generation || null,
736
+ controllerPid: record.controllerPid == null ? null : Number(record.controllerPid),
737
+ blockReason: record.blockReason || null,
738
+ });
486
739
  }
487
740
  }
488
- return { actions, watchers, desiredCount: desired.openPrs.length, leaseLost };
741
+ entries.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
742
+ sources.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
743
+ return { entries, sources, corrupt: false, unmappable };
744
+ }
745
+
746
+ async function defaultReadProviderState(entry, opts = {}) {
747
+ const runGh = githubRunner(opts);
748
+ try {
749
+ const raw = runGh(['pr', 'view', String(entry.pr), '--repo', entry.repo, '--json', 'state']);
750
+ const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
751
+ return typeof value?.state === 'string' ? value.state.toLowerCase() : null;
752
+ } catch {
753
+ return null;
754
+ }
755
+ }
756
+
757
+ async function collectLegacyEntryEvidence(entry, options, opts, projectRoot) {
758
+ const ctx = identity(entry);
759
+ const pid = Number(entry.pid);
760
+ let hasPid = Number.isSafeInteger(pid) && pid > 0;
761
+ let pidState = false;
762
+ let pidReused = false;
763
+ // A bare `isPidAlive` hit is not proof the LEGACY watcher is alive: an unrelated
764
+ // long-lived process may have inherited the number. Importing that as
765
+ // blocked/legacy_live_pid is unrecoverable, because every later recheck sees the
766
+ // same live PID and the PR stays unwatched forever. Compare the process start time
767
+ // against the legacy start marker; a process that booted materially AFTER the
768
+ // marker cannot be the watcher that wrote it, so its PID evidence is discarded.
769
+ // An unknown start time changes nothing (fail closed on the live PID).
770
+ if (hasPid) {
771
+ let identityState;
772
+ try {
773
+ identityState = await processIdentityAlive({
774
+ pid,
775
+ startedAt: entry.startedAt,
776
+ isPidAlive: options.isPidAlive,
777
+ pidStartedAt: options.pidStartedAt,
778
+ });
779
+ } catch { identityState = 'unknown'; }
780
+ if (identityState === 'alive') pidState = true;
781
+ else if (identityState === 'unknown') pidState = null;
782
+ else pidState = false;
783
+ if (identityState === 'reused') {
784
+ hasPid = false;
785
+ pidReused = true;
786
+ }
787
+ }
788
+ let conflictingPidUnsafe = false;
789
+ if (entry.lifecycleConflict) {
790
+ const conflictPids = [...new Set((entry.legacyEvidence || [])
791
+ .map(value => Number(value?.pid)).filter(value => Number.isSafeInteger(value) && value > 0))];
792
+ for (const conflictPid of conflictPids) {
793
+ try {
794
+ if (await options.isPidAlive(conflictPid) !== false) conflictingPidUnsafe = true;
795
+ } catch { conflictingPidUnsafe = true; }
796
+ }
797
+ }
798
+ let providerState = '';
799
+ let providerReadable;
800
+ try {
801
+ providerState = String(
802
+ await (opts.readProviderState || defaultReadProviderState)(entry, { ...opts, projectRoot }) || '',
803
+ ).toLowerCase();
804
+ providerReadable = providerState.length > 0;
805
+ } catch { providerReadable = false; }
806
+ const providerTerminal = ['closed', 'merged', 'terminal'].includes(providerState);
807
+ let providerVerified = false;
808
+ if ((hasPid || pidReused) && providerState === 'open' && typeof options.verifyProviderEvidence === 'function') {
809
+ try {
810
+ providerVerified = await options.verifyProviderEvidence(
811
+ { state: providerState }, { ...ctx, states: ['open'] },
812
+ ) === true;
813
+ } catch { providerVerified = false; }
814
+ }
815
+ let receiptVerified = false;
816
+ if (entry.terminalReceiptId && providerTerminal && typeof options.verifyTerminalReceipt === 'function') {
817
+ try { receiptVerified = await options.verifyTerminalReceipt(entry.terminalReceiptId, ctx) === true; }
818
+ catch { receiptVerified = false; }
819
+ }
820
+ return {
821
+ entry,
822
+ entryHash: hashLegacyEntry(entry),
823
+ ctx,
824
+ pid,
825
+ hasPid,
826
+ pidState,
827
+ providerState,
828
+ providerReadable,
829
+ providerTerminal,
830
+ providerVerified,
831
+ receiptVerified,
832
+ conflictingPidUnsafe,
833
+ pidReused,
834
+ };
835
+ }
836
+
837
+ function cachedLegacyEvidenceOptions(options, evidence) {
838
+ const sameIdentity = value => value?.repo === evidence.ctx.repo && value?.pr === evidence.ctx.pr;
839
+ return {
840
+ ...options,
841
+ isPidAlive: async pid => (pid === evidence.pid ? evidence.pidState : null),
842
+ // Cached liveness has no start-time counterpart; identity was already settled
843
+ // while the evidence was collected.
844
+ pidStartedAt: null,
845
+ verifyProviderEvidence: async (providerEvidence, expected) => evidence.providerVerified
846
+ && sameIdentity(expected)
847
+ && String(providerEvidence?.state || '').toLowerCase() === evidence.providerState
848
+ && Array.isArray(expected?.states)
849
+ && expected.states.includes(evidence.providerState),
850
+ verifyTerminalReceipt: async (receipt, ownerIdentity) => evidence.receiptVerified
851
+ && sameIdentity(ownerIdentity)
852
+ && receipt === evidence.entry.terminalReceiptId,
853
+ };
854
+ }
855
+
856
+ async function migrateLegacyAuthority(projectRoot, opts = {}) {
857
+ const authority = opts.authority || watchOwner;
858
+ const options = ownerOptions(opts);
859
+ const now = new Date((opts.now || (() => Date.now()))()).toISOString();
860
+ const readSnapshot = opts.readLegacySnapshot || (() => defaultReadLegacySnapshot(projectRoot, opts));
861
+ // Legacy verification calls the SYNCHRONOUS provider runner once per entry, so the
862
+ // heartbeat timer started by the daemon cannot fire while a read is in flight. A
863
+ // handful of reads near the 30s command timeout would otherwise age the lease past
864
+ // its 90s TTL, let a concurrent trigger reclaim it, and leave two controllers
865
+ // mutating the same cutover gate. Stamp the lease around every entry, and re-verify
866
+ // ownership by token before any mutation that commits migration results.
867
+ const leaseGuarded = opts.token !== undefined && opts.token !== null;
868
+ const stampLease = opts.stampLease || (opts.acquire ? () => true : shepherdLease.stamp);
869
+ const ownsLease = opts.ownsLease || (opts.acquire ? () => true : shepherdLease.owns);
870
+ const leaseArgs = { gitCommonDir: opts.gitCommonDir, token: opts.token };
871
+ const heartbeatLease = () => {
872
+ if (!leaseGuarded) return;
873
+ try { stampLease(projectRoot, leaseArgs); } catch { /* best effort — ownership is rechecked below */ }
874
+ };
875
+ const holdsLease = () => {
876
+ if (!leaseGuarded) return true;
877
+ try { return ownsLease(projectRoot, leaseArgs) === true; } catch { return false; }
878
+ };
879
+ const initialGate = await authority.readMigrationGate({}, options);
880
+ if (initialGate?.ok && initialGate.gate?.state === 'complete') {
881
+ const completedSnapshot = await readSnapshot();
882
+ const completedHash = hashLegacySnapshot(completedSnapshot);
883
+ if (completedSnapshot?.corrupt || completedHash !== initialGate.gate.snapshot_hash) {
884
+ return {
885
+ ok: true, state: 'complete', snapshotHash: initialGate.gate.snapshot_hash,
886
+ cleanupPending: true, reason: 'legacy_source_changed',
887
+ };
888
+ }
889
+ try { await opts.cleanupLegacyEvidence?.(completedSnapshot); } catch {
890
+ return { ok: true, state: 'complete', snapshotHash: completedHash, cleanupPending: true };
891
+ }
892
+ return { ok: true, state: 'complete', snapshotHash: completedHash };
893
+ }
894
+ if (initialGate?.ok && initialGate.gate?.state === 'conflict') {
895
+ return { ok: false, state: 'conflict', reason: initialGate.gate.conflict_code || 'legacy_owner_conflict' };
896
+ }
897
+ if (!initialGate?.ok && initialGate?.reason !== 'absent') {
898
+ return { ok: false, state: 'quarantined', reason: initialGate?.reason || 'authority_unavailable' };
899
+ }
900
+ const quarantined = initialGate?.ok
901
+ ? initialGate
902
+ : await authority.publishMigrationQuarantine({ updatedAt: now }, options);
903
+ if (!quarantined?.ok || quarantined.gate?.state !== 'quarantined') {
904
+ return { ok: false, state: 'quarantined', reason: quarantined?.reason || 'authority_unavailable' };
905
+ }
906
+ const migrationStartedAt = quarantined.gate?.updated_at || now;
907
+ // This process is the migrating controller; it owns rows whose legacy controller
908
+ // PID proved reused and therefore cannot be trusted.
909
+ const migrationControllerPid = Number.isSafeInteger(Number(opts.controllerPid)) && Number(opts.controllerPid) > 0
910
+ ? Number(opts.controllerPid)
911
+ : process.pid;
912
+ let snapshot;
913
+ let snapshotHash;
914
+ let verifiedEntries;
915
+ for (let attempt = 0; attempt < MAX_MIGRATION_ATTEMPTS; attempt += 1) {
916
+ const first = await readSnapshot();
917
+ const firstHash = hashLegacySnapshot(first);
918
+ const consolidated = first && !first.corrupt
919
+ ? consolidateLegacyEntries(first.entries)
920
+ : null;
921
+ const evidence = [];
922
+ if (consolidated && !first.unmappable && !consolidated.unmappable) {
923
+ for (const entry of consolidated.entries) {
924
+ heartbeatLease();
925
+ evidence.push(await collectLegacyEntryEvidence(entry, options, opts, projectRoot));
926
+ heartbeatLease();
927
+ }
928
+ }
929
+ if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
930
+ const second = await readSnapshot();
931
+ const secondHash = hashLegacySnapshot(second);
932
+ if (firstHash === secondHash) {
933
+ snapshot = second;
934
+ snapshotHash = secondHash;
935
+ if (consolidated && (first.unmappable || consolidated.unmappable)) {
936
+ await authority.publishMigrationConflict({
937
+ snapshotHash, conflictCode: 'legacy_identity_unmappable', updatedAt: now,
938
+ }, options);
939
+ return { ok: false, state: 'conflict', reason: 'legacy_identity_unmappable' };
940
+ }
941
+ verifiedEntries = consolidated ? evidence : null;
942
+ break;
943
+ }
944
+ if (attempt === MAX_MIGRATION_ATTEMPTS - 1) {
945
+ await authority.publishMigrationConflict({
946
+ snapshotHash: secondHash, conflictCode: 'legacy_snapshot_changed', updatedAt: now,
947
+ }, options);
948
+ return { ok: false, state: 'conflict', reason: 'legacy_snapshot_changed' };
949
+ }
950
+ }
951
+ if (!snapshot || snapshot.corrupt) {
952
+ await authority.publishMigrationConflict({
953
+ snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
954
+ }, options);
955
+ return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
956
+ }
957
+ if (verifiedEntries?.some(entry => entry.conflictingPidUnsafe)) {
958
+ await authority.publishMigrationConflict({
959
+ snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
960
+ }, options);
961
+ return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
962
+ }
963
+ // Ownership is rechecked BEFORE every mutation, not only after: a lease reclaimed
964
+ // during the preceding await must not be able to bind a snapshot or write a single
965
+ // owner row on the way to reporting lease_lost.
966
+ if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
967
+ const bound = await authority.bindMigrationSnapshot({ snapshotHash, updatedAt: now }, options);
968
+ if (!bound?.ok) return { ok: false, state: 'conflict', reason: bound?.reason || 'snapshot_mismatch' };
969
+ if (verifiedEntries?.some(entry => !entry.providerReadable)) {
970
+ return { ok: false, state: 'quarantined', reason: 'legacy_provider_unreadable' };
971
+ }
972
+
973
+ const expectedRows = [];
974
+ for (const evidence of verifiedEntries || []) {
975
+ const {
976
+ entry, entryHash, ctx, pid, hasPid, pidState, providerState, providerReadable,
977
+ providerTerminal, providerVerified, receiptVerified, pidReused,
978
+ } = evidence;
979
+ if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
980
+ const operationOptions = cachedLegacyEvidenceOptions(options, evidence);
981
+ let result;
982
+ if (entry.lifecycleConflict) {
983
+ result = await authority.markLegacyBlocked(ctx, {
984
+ blockReason: 'legacy_conflict', snapshotHash, legacyEvidenceHash: entryHash,
985
+ startedAt: entry.startedAt || migrationStartedAt,
986
+ }, operationOptions);
987
+ } else if (pidState == null) {
988
+ result = await authority.markLegacyBlocked(ctx, {
989
+ blockReason: 'legacy_unreadable', snapshotHash, legacyEvidenceHash: entryHash,
990
+ startedAt: entry.startedAt || migrationStartedAt,
991
+ }, operationOptions);
992
+ } else if (pidState === true) {
993
+ result = await authority.markLegacyBlocked(ctx, {
994
+ blockReason: 'legacy_live_pid', pid, snapshotHash, legacyEvidenceHash: entryHash,
995
+ ...(providerTerminal && receiptVerified ? { terminalReceiptId: entry.terminalReceiptId } : {}),
996
+ startedAt: entry.startedAt || migrationStartedAt,
997
+ }, operationOptions);
998
+ } else if (entry.terminalReceiptId && providerTerminal && receiptVerified) {
999
+ result = await authority.importLegacyComplete(ctx, {
1000
+ snapshotHash, legacyEvidenceHash: entryHash, legacyPid: hasPid ? pid : null,
1001
+ terminalReceiptId: entry.terminalReceiptId, startedAt: entry.startedAt || migrationStartedAt,
1002
+ }, operationOptions);
1003
+ } else if (entry.terminalReceiptId && providerTerminal) {
1004
+ result = await authority.markLegacyBlocked(ctx, {
1005
+ blockReason: 'legacy_receipt_unverified', terminalReceiptId: entry.terminalReceiptId,
1006
+ snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
1007
+ }, operationOptions);
1008
+ } else if (!providerReadable) {
1009
+ result = await authority.markLegacyBlocked(ctx, {
1010
+ blockReason: 'legacy_unreadable', snapshotHash, legacyEvidenceHash: entryHash,
1011
+ startedAt: entry.startedAt || migrationStartedAt,
1012
+ }, operationOptions);
1013
+ } else if ((hasPid || pidReused) && providerState === 'open' && providerVerified
1014
+ && typeof authority.importLegacyStarting === 'function') {
1015
+ // A proven-reused PID is proof the legacy watcher is DEAD, so an open PR must
1016
+ // import as a recoverable starting row rather than falling through to the
1017
+ // permanent blocked/legacy_lossy branch below (only legacy_live_pid blocks are
1018
+ // ever rechecked, and any blocked row suppresses inline passes). The reused
1019
+ // number must not become the controller — that would defer recovery to an
1020
+ // unrelated live process — so this migrating controller adopts the row.
1021
+ result = await authority.importLegacyStarting(ctx, {
1022
+ snapshotHash, legacyEvidenceHash: entryHash, legacyPid: pid,
1023
+ controllerPid: pidReused ? migrationControllerPid : pid,
1024
+ providerEvidence: { state: 'open' }, startedAt: entry.startedAt || migrationStartedAt,
1025
+ }, operationOptions);
1026
+ } else if (providerState === 'open') {
1027
+ result = await authority.markLegacyBlocked(ctx, {
1028
+ blockReason: hasPid ? 'legacy_unreadable' : 'legacy_lossy',
1029
+ snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
1030
+ }, operationOptions);
1031
+ } else {
1032
+ result = await authority.markLegacyBlocked(ctx, {
1033
+ blockReason: providerState ? 'legacy_receipt_unverified' : 'legacy_lossy',
1034
+ snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
1035
+ }, operationOptions);
1036
+ }
1037
+ // A migration that crashed mid-import leaves durable rows it already wrote. If
1038
+ // the PR's provider state has since drifted (an open PR that has closed), the
1039
+ // resumed pass legitimately selects a DIFFERENT decision and the authority
1040
+ // rejects it against the surviving row as `owner_conflict`. That is ordinary
1041
+ // drift, not two writers disagreeing: the durable row carries THIS entry's
1042
+ // legacy evidence hash, so it is our own prior import. Adopt it rather than
1043
+ // escalating to a repo-wide migration conflict that would disable every
1044
+ // watcher launch and inline pass. Only a row whose legacy evidence hash
1045
+ // differs is genuinely divergent.
1046
+ const priorImport = !result?.ok && result?.reason === 'owner_conflict'
1047
+ && result.record && result.record.legacyEvidenceHash === entryHash
1048
+ ? result.record
1049
+ : null;
1050
+ if (priorImport) {
1051
+ expectedRows.push(ownerRereadProjection(priorImport));
1052
+ continue;
1053
+ }
1054
+ if (!result?.ok || !result.record) {
1055
+ await authority.publishMigrationConflict({
1056
+ snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
1057
+ }, options);
1058
+ return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
1059
+ }
1060
+ expectedRows.push(ownerRereadProjection(result.record));
1061
+ }
1062
+
1063
+ const rereadSnapshot = await readSnapshot();
1064
+ const rows = await authority.enumerateOwners({}, options);
1065
+ const gate = await authority.readMigrationGate({}, options);
1066
+ const exact = hashLegacySnapshot(rereadSnapshot) === snapshotHash
1067
+ && rows?.ok === true
1068
+ && gate?.ok === true
1069
+ && gate.gate?.state === 'quarantined'
1070
+ && gate.gate?.snapshot_hash === snapshotHash
1071
+ && ownerRowsMatch(expectedRows, rows.records);
1072
+ if (!exact) return { ok: false, state: 'quarantined', reason: 'legacy_reread_mismatch' };
1073
+ if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
1074
+ const completed = await authority.completeMigrationGate({ snapshotHash, updatedAt: now }, options);
1075
+ if (!completed?.ok) return { ok: false, state: 'quarantined', reason: completed?.reason || 'gate_mismatch' };
1076
+ try { await opts.cleanupLegacyEvidence?.(snapshot); } catch {
1077
+ return { ok: true, state: 'complete', snapshotHash, cleanupPending: true };
1078
+ }
1079
+ return { ok: true, state: 'complete', snapshotHash };
1080
+ }
1081
+
1082
+ async function defaultBuildBroker({ projectRoot, gitCommonDir }) {
1083
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
1084
+ const { createMonitorStore } = require('@forge/memory');
1085
+ const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
1086
+ const store = createMonitorStore(deps.kernelDriver);
1087
+ return {
1088
+ broker: deps.kernelBroker,
1089
+ driver: deps.kernelDriver,
1090
+ databaseConfig: { databasePath: deps.kernelDatabasePath },
1091
+ verifyTerminalReceipt: async (receiptId, ownerIdentity) => {
1092
+ const state = await store.readDeliveryState(legacyMonitorId(ownerIdentity.repo, ownerIdentity.pr));
1093
+ return state?.terminal_receipt?.object_id === receiptId;
1094
+ },
1095
+ };
489
1096
  }
490
1097
 
491
- /**
492
- * The singleton daemon: acquire the lease (exit if a live foreign owner holds it),
493
- * heartbeat, converge on a cadence, self-retire when no PRs remain. `opts.once`
494
- * runs a single converge (for tests); otherwise an interval loop + signal handlers.
495
- */
496
1098
  async function runDaemon(projectRoot, opts = {}) {
497
1099
  const gitCommonDir = opts.gitCommonDir || brokerMod.resolveGitCommonDir(projectRoot);
498
1100
  const acquire = opts.acquire || shepherdLease.acquire;
1101
+ const release = opts.release || shepherdLease.release;
499
1102
  const startHeartbeat = opts.startHeartbeat || shepherdLease.startHeartbeat;
500
1103
  const stopHeartbeat = opts.stopHeartbeat || shepherdLease.stopHeartbeat;
501
- const release = opts.release || shepherdLease.release;
502
- const converge = opts.convergeOnce || convergeOnce;
503
- const now = opts.now || (() => Date.now());
504
- // Tests that replace acquisition own the matching ownership seam as well.
505
- // Production always verifies the real shared lock by exact pid+token.
506
- const ownsLease = opts.ownsLease
507
- || (opts.acquire ? (() => true) : shepherdLease.owns);
508
- // Injectable exit so a lifecycle test can assert the daemon actually exits
509
- // (finding 4). `opts.exit === false` keeps the process alive (legacy test mode).
510
- const exit = typeof opts.exit === 'function'
511
- ? opts.exit
512
- : (opts.exit === false ? () => {} : (code) => process.exit(code));
513
-
514
- const res = acquire(projectRoot, { gitCommonDir });
515
- if (!res.ok) {
1104
+ const ownsLease = opts.ownsLease || (opts.acquire ? (() => true) : shepherdLease.owns);
1105
+ const exit = typeof opts.exit === 'function' ? opts.exit : (opts.exit === false ? () => {} : code => process.exit(code));
1106
+ const held = acquire(projectRoot, { gitCommonDir });
1107
+ if (!held.ok) {
1108
+ // Distinguish "someone else legitimately owns the lease" from "the lease
1109
+ // file is unreadable and its bytes were deliberately left in place" — the
1110
+ // latter needs an operator, not a retry, and migration has NOT run yet.
1111
+ const reason = held.legacyMigrationPending === true
1112
+ ? (held.reason || 'legacy-lease-unreadable')
1113
+ : 'foreign-lease';
1114
+ if (reason !== 'foreign-lease') recordDaemonDiagnostic(opts, gitCommonDir, reason);
516
1115
  exit(0);
517
- // A live, fresh foreign daemon owns this repo — exit immediately, spawn nothing.
518
- return { ok: false, reason: 'foreign-lease' };
1116
+ return { ok: false, reason };
519
1117
  }
520
- const token = res.token;
1118
+ const token = held.token;
521
1119
  const heartbeat = startHeartbeat(projectRoot, { gitCommonDir, token });
522
-
523
- // Build ONE real kernel broker for the daemon's lifetime — createLocalBroker-backed,
524
- // because listOpenPrs/upsertPr/retirePr are INSTANCE methods (the module namespace has
525
- // none). Injectable for tests via opts.broker/opts.buildBroker. `ownedDriver` is closed
526
- // on retire ONLY when we created it (Windows EBUSY guard — a leaked handle wedges the
527
- // sqlite file). A genuine build failure degrades to the watcher half (broker stays null).
528
- let broker = opts.broker || null;
529
- let ownedDriver = null;
530
- if (!broker) {
531
- try {
532
- const built = await (opts.buildBroker || defaultBuildBroker)({ projectRoot, gitCommonDir });
533
- broker = built.broker;
534
- ownedDriver = built.driver;
535
- } catch {
536
- /* kernel genuinely unavailable → run degraded (watcher convergence only) */
537
- }
1120
+ const ownsBroker = !opts.broker;
1121
+ let built = null;
1122
+ try {
1123
+ built = opts.broker
1124
+ ? { broker: opts.broker, driver: opts.driver, databaseConfig: opts.databaseConfig }
1125
+ : await (opts.buildBroker || defaultBuildBroker)({ projectRoot, gitCommonDir });
1126
+ } catch {
1127
+ built = null;
538
1128
  }
539
-
540
- const convergeArgs = { ...opts, gitCommonDir, token, broker };
541
-
542
- // retire() must NEVER throw: a release / stopHeartbeat / driver.close error must
543
- // not leave the daemon un-exited (finding 4). Each teardown step swallows its own
544
- // error so the caller's exit(0) always runs — no un-retired zombie.
545
1129
  const retire = async () => {
546
- try { release(projectRoot, { gitCommonDir, token }); } catch { /* best effort */ }
1130
+ try { release(projectRoot, { gitCommonDir, token }); } catch { /* token-guarded best effort */ }
547
1131
  try { stopHeartbeat(heartbeat); } catch { /* best effort */ }
548
- if (ownedDriver && typeof ownedDriver.close === 'function') {
549
- try { ownedDriver.close(); } catch { /* best effort */ }
1132
+ if (ownsBroker) {
1133
+ try { await built?.broker?.close?.(); } catch { /* best effort */ }
550
1134
  }
551
1135
  };
552
-
1136
+ if (!built?.driver) {
1137
+ await retire();
1138
+ return { ok: false, reason: 'authority-unavailable' };
1139
+ }
1140
+ const runGh = githubRunner({ ...opts, projectRoot });
1141
+ const repo = normalizeRepository(opts.repo) || resolveCanonicalRepository(runGh);
1142
+ if (!repo) {
1143
+ await retire();
1144
+ return { ok: false, reason: 'repository-unavailable' };
1145
+ }
1146
+ const args = {
1147
+ ...opts, gitCommonDir, broker: built.broker, driver: built.driver, repo, runGh,
1148
+ databaseConfig: built.databaseConfig,
1149
+ verifyTerminalReceipt: opts.verifyTerminalReceipt || built.verifyTerminalReceipt,
1150
+ token,
1151
+ };
1152
+ let migration;
1153
+ try {
1154
+ migration = await (opts.migrateLegacyAuthority || migrateLegacyAuthority)(projectRoot, args);
1155
+ } catch (error) {
1156
+ recordDaemonDiagnostic(opts, gitCommonDir, 'migration-failed', error);
1157
+ await retire();
1158
+ return { ok: false, reason: 'migration-failed' };
1159
+ }
1160
+ if (!migration?.ok) {
1161
+ recordDaemonDiagnostic(opts, gitCommonDir, 'migration-blocked', migration?.reason);
1162
+ await retire();
1163
+ return { ok: false, reason: migration?.reason || 'migration-blocked' };
1164
+ }
1165
+ const converge = opts.convergeOnce || convergeOnce;
553
1166
  if (opts.once) {
554
- const conv = await converge(projectRoot, convergeArgs);
555
- if (conv.desiredCount === 0) {
556
- recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
557
- await retire();
558
- }
559
- return { ok: true, token, ...conv };
1167
+ const result = await converge(projectRoot, args);
1168
+ if (daemonCanRetire(result)) await retire();
1169
+ return { ok: true, token, ...result };
560
1170
  }
561
1171
 
562
- const intervalMs = opts.intervalMs || 60000;
563
1172
  let stopped = false;
564
- let inFlight = false; // finding 5: re-entrancy guard — never run two passes at once
565
- let lastWatchers = []; // finding 2: thread the live watcher set across passes
1173
+ let inFlight = false;
566
1174
  let timer = null;
567
-
568
1175
  const retireForLeaseLoss = async () => {
569
1176
  stopped = true;
570
1177
  if (timer) clearInterval(timer);
@@ -572,271 +1179,140 @@ async function runDaemon(projectRoot, opts = {}) {
572
1179
  await retire();
573
1180
  exit(0);
574
1181
  };
575
-
576
- const stillOwnsLease = () => {
577
- try {
578
- return ownsLease(projectRoot, { gitCommonDir, token });
579
- } catch {
580
- return false;
581
- }
1182
+ const stillOwns = () => {
1183
+ try { return ownsLease(projectRoot, { gitCommonDir, token }); } catch { return false; }
582
1184
  };
583
-
584
1185
  const runPass = async () => {
585
- // A tick that fires while the previous pass is still in flight (converge slower
586
- // than intervalMs) returns immediately, so passes never race on start/stop/reap.
587
1186
  if (stopped || inFlight) return;
588
- if (!stillOwnsLease()) {
589
- await retireForLeaseLoss();
590
- return;
591
- }
1187
+ if (!stillOwns()) { await retireForLeaseLoss(); return; }
592
1188
  inFlight = true;
593
1189
  try {
594
- // Thread the live watcher set + a fresh heartbeat stamp so gatherObserved
595
- // observes the REAL live set each pass (finding 2) — without this the daemon
596
- // saw lease:null every tick and re-started a watcher for every PR forever.
597
- const passLock = { watchers: lastWatchers, heartbeatAt: new Date(now()).toISOString() };
598
- const conv = await converge(projectRoot, { ...convergeArgs, lock: passLock });
599
- if (!stillOwnsLease()) {
600
- await retireForLeaseLoss();
601
- return;
602
- }
603
- if (conv && Array.isArray(conv.watchers)) lastWatchers = conv.watchers;
604
- // Superseded: a newer daemon reclaimed our stale lease. Stop and exit — retire()
605
- // won't touch the foreign lock (release is token-guarded), so the new owner is
606
- // left intact; we just stop spawning/reaping behind it.
607
- if (conv && conv.leaseLost) {
608
- await retireForLeaseLoss();
609
- } else if (conv && conv.desiredCount === 0) {
1190
+ const result = await converge(projectRoot, args);
1191
+ if (!stillOwns()) { await retireForLeaseLoss(); return; }
1192
+ if (daemonCanRetire(result)) {
610
1193
  stopped = true;
611
1194
  if (timer) clearInterval(timer);
612
- recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
613
1195
  await retire();
614
1196
  exit(0);
615
1197
  }
616
1198
  } catch (error) {
617
1199
  recordDaemonDiagnostic(opts, gitCommonDir, 'converge-failed', error);
618
- if (!stillOwnsLease()) await retireForLeaseLoss();
619
- /* a bad converge pass never crashes the daemon — the next tick retries */
1200
+ if (!stillOwns()) await retireForLeaseLoss();
620
1201
  } finally {
621
1202
  inFlight = false;
622
1203
  }
623
1204
  };
624
-
625
- // finding 3: converge IMMEDIATELY on cold start — don't idle for up to intervalMs.
626
1205
  await runPass();
627
1206
  if (stopped) return { ok: true, token, retired: true };
628
-
629
- timer = setInterval(runPass, intervalMs);
630
- // The converge timer is intentionally left REF'd so it keeps the daemon process
631
- // alive between passes (the heartbeat timer is unref'd inside startHeartbeat).
632
-
1207
+ timer = setInterval(runPass, opts.intervalMs || 60_000);
633
1208
  const onSignal = async () => { await retire(); exit(0); };
634
1209
  process.on('SIGINT', onSignal);
635
1210
  process.on('SIGTERM', onSignal);
636
-
637
1211
  return { ok: true, token, heartbeat, timer };
638
1212
  }
639
1213
 
640
- /**
641
- * Launch the singleton daemon. Classify the execution home by CAPABILITY presence
642
- * (`ctx.harness.hasBgShell`), NEVER by harness name; uncertain → detached spawn
643
- * modeled on `startPrWatcherDetached`. Never throws.
644
- */
645
1214
  function launchDaemon(ctx = {}) {
646
- const harness = ctx.harness || {};
647
1215
  const commonRoot = path.basename(ctx.gitCommonDir || '').toLowerCase() === '.git'
648
- ? path.dirname(ctx.gitCommonDir)
649
- : ctx.projectRoot;
650
- if (harness.hasBgShell && typeof harness.runBgShell === 'function') {
1216
+ ? path.dirname(ctx.gitCommonDir) : ctx.projectRoot;
1217
+ let argv;
1218
+ let environment;
1219
+ try {
1220
+ argv = forgeArgs(['shepherd', 'daemon'], ctx);
1221
+ const env = githubWorkerEnvironment(commonRoot, ctx);
1222
+ environment = env ? { env } : {};
1223
+ } catch {
1224
+ recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed');
1225
+ return { launched: false };
1226
+ }
1227
+ if (ctx.harness?.hasBgShell && typeof ctx.harness.runBgShell === 'function') {
651
1228
  try {
652
- harness.runBgShell([process.execPath, forgeBin(), 'shepherd', 'daemon'], { cwd: commonRoot });
1229
+ ctx.harness.runBgShell([process.execPath, ...argv], { cwd: commonRoot, ...environment });
653
1230
  return { launched: true, via: 'bg-shell' };
654
- } catch {
655
- /* fall through to the detached fail-safe */
656
- }
1231
+ } catch { /* detached fallback */ }
657
1232
  }
658
- const spawnFn = ctx.spawnProcess || spawn;
659
1233
  try {
660
- const child = spawnFn(
661
- process.execPath,
662
- [forgeBin(), 'shepherd', 'daemon'],
663
- { cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true },
664
- );
665
- if (child && typeof child.on === 'function') {
666
- child.on('error', (error) => {
667
- recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
668
- });
669
- }
670
- if (child && typeof child.unref === 'function') child.unref();
671
- return { launched: true, via: 'detached', pid: child && child.pid != null ? child.pid : null };
672
- } catch (error) {
673
- recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
1234
+ const child = (ctx.spawnProcess || spawn)(process.execPath, argv, {
1235
+ cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true, ...environment,
1236
+ });
1237
+ child?.on?.('error', () => recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed'));
1238
+ child?.unref?.();
1239
+ return { launched: true, via: 'detached', pid: child?.pid ?? null };
1240
+ } catch {
1241
+ recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed');
674
1242
  return { launched: false };
675
1243
  }
676
1244
  }
677
1245
 
678
- /**
679
- * Build a live, migrated kernel broker (+ its owned driver) for the daemon. Uses the
680
- * same createLocalBroker-backed factory the CLI uses, so listOpenPrs/upsertPr/retirePr
681
- * are the real instance methods. The caller closes `driver` on retire.
682
- */
683
- async function defaultBuildBroker({ projectRoot, gitCommonDir }) {
684
- const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
685
- const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
686
- return { broker: deps.kernelBroker, driver: deps.kernelDriver };
687
- }
688
-
689
- /**
690
- * Whether the default-ON `rail.auto_shepherd` gate permits the autonomous trigger.
691
- * Reuses ship.js's `autoShepherdRailEnabled` — the SAME resolver `forge push`,
692
- * `forge ship`, and `forge shepherd adopt` honor — so one `forge gate disable
693
- * rail.auto_shepherd` turns the whole autonomous surface off. Lazy-required to keep
694
- * the per-command trigger cheap and avoid an eager/circular load; FAIL-OPEN (returns
695
- * enabled) if the resolver can't be read, and never throws.
696
- */
697
1246
  function railAutoShepherdEnabled(projectRoot) {
698
- try {
699
- return require('../commands/ship').autoShepherdRailEnabled(projectRoot);
700
- } catch {
701
- return true; // config unreadable → fail open (default-ON), never block the trigger's own path
702
- }
1247
+ try { return require('../commands/ship').autoShepherdRailEnabled(projectRoot); }
1248
+ catch { return true; }
703
1249
  }
704
1250
 
705
- /**
706
- * True iff a kernel DB already exists for `projectRoot` — the SAME no-lazy-create
707
- * invariant `forge prime` honors (orientation.hasExistingKernelDb). The trigger must
708
- * CREATE NOTHING in an uninitialized or setup/init TARGET repo (else `setup --dry-run`
709
- * and `init` would sprout a shepherd.lock and pollute output). SILENT: a no-op `warn`
710
- * suppresses resolveGitCommonDir's fallback message on a non-git dir. Never throws.
711
- */
712
1251
  function kernelInitialized(projectRoot) {
713
1252
  try {
714
- // Resolve the git-common-dir with a FAST, SUBPROCESS-FREE read — NEVER `git
715
- // rev-parse` (its 30s timeout would block every registry command on the dispatch
716
- // finally, and a git subprocess pollutes bare-repo command tests). Common checkout:
717
- // <root>/.git is a dir. Linked worktree: <root>/.git is a file `gitdir: …/worktrees/x`
718
- // whose common dir is the part before `/worktrees/`.
719
1253
  const gitPath = path.join(projectRoot, '.git');
720
- const st = fs.statSync(gitPath); // throws if absent → not a repo → false
721
- let commonDir;
722
- if (st.isDirectory()) {
723
- commonDir = gitPath;
724
- } else {
725
- const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
726
- if (!m) return false;
727
- const wtGitDir = path.resolve(projectRoot, m[1].trim());
1254
+ const stat = fs.statSync(gitPath);
1255
+ let commonDir = gitPath;
1256
+ if (!stat.isDirectory()) {
1257
+ const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
1258
+ if (!match) return false;
1259
+ const worktreeGitDir = path.resolve(projectRoot, match[1].trim());
728
1260
  const marker = `${path.sep}worktrees${path.sep}`;
729
- const idx = wtGitDir.lastIndexOf(marker);
730
- commonDir = idx >= 0 ? wtGitDir.slice(0, idx) : wtGitDir;
1261
+ const index = worktreeGitDir.lastIndexOf(marker);
1262
+ commonDir = index >= 0 ? worktreeGitDir.slice(0, index) : worktreeGitDir;
731
1263
  }
732
1264
  return fs.existsSync(path.join(commonDir, 'forge', 'kernel.sqlite'));
733
- } catch {
734
- return false;
735
- }
736
- }
737
-
738
- /** Empty enumeration for a cold-tick loser that lost the lease race (backs off). */
739
- function emptyEnum(gitCommonDir) {
740
- return {
741
- desired: { openPrs: [], gitCommonDir },
742
- observed: { lease: null, leaseFresh: false, prRows: [], liveWatcherPids: [] },
743
- };
1265
+ } catch { return false; }
744
1266
  }
745
1267
 
746
- /**
747
- * The session-start / successful-push / successful-ship trigger. Runs the `tick()` debounce; the hot
748
- * path (a fresh daemon lease) short-circuits in-process with a single lock read
749
- * and no spawn. Only on the cold (G3) path does it ARBITRATE via the O_EXCL lease:
750
- * the acquire-winner launches the singleton daemon (which does the real
751
- * `gh pr list` enumeration + converge), and a loser backs off — no spawn. The
752
- * arbitration lease is released immediately after launch so the spawned daemon can
753
- * take sole ownership; the daemon's own `acquire` is the final singleton authority,
754
- * so even a race that double-launches still yields exactly one live daemon.
755
- *
756
- * The gh enumeration deliberately lives in the DAEMON, not here, so this trigger
757
- * NEVER runs a blocking subprocess on the command's critical path.
758
- *
759
- * CONTRACT: never throws, never blocks (no await), never affects the command.
760
- */
761
1268
  function fireAndForget(ctx = {}) {
762
1269
  try {
763
1270
  const env = ctx.env || process.env;
764
- // Operator kill-switch (agent-agnostic): a set FORGE_SHEPHERD_DISABLE turns the
765
- // autonomous trigger fully inert — no lease, no enumeration, no daemon spawn.
766
- if (
767
- env.FORGE_SHEPHERD_DISABLE
768
- || env.NODE_ENV === 'test'
769
- || env.BUN_ENV === 'test'
770
- || env.CI
771
- || env.GITHUB_ACTIONS
772
- || env.GITLAB_CI
773
- ) return;
774
- const projectRoot = ctx.projectRoot;
775
- if (!projectRoot) return;
776
- // A dry-run must have ZERO side effects, and the trigger must CREATE NOTHING in an
777
- // uninitialized / setup-or-init TARGET repo (no-lazy-create invariant, same as prime).
778
- // Both guards run BEFORE any git/lock touch so `setup --dry-run` / `init` stay
779
- // side-effect- AND output-clean (kernelInitialized is silent). Checked here, not the
780
- // caller, so every approved trigger site is covered uniformly.
781
- if (ctx.dryRun) return;
782
- if (!(ctx.kernelInitialized || kernelInitialized)(projectRoot)) return;
783
- // Config kill-switch (same gate ship/push/adopt honor): a maintainer who ran
784
- // `forge gate disable rail.auto_shepherd` gets a fully inert trigger — no lease,
785
- // no enumeration, no daemon spawn. Cheap + fail-open, inside the dispatch try.
786
- const railEnabled = ctx.railEnabled || railAutoShepherdEnabled;
787
- if (!railEnabled(projectRoot)) return;
788
- let gitCommonDir = ctx.gitCommonDir;
789
- if (!gitCommonDir) {
790
- try {
791
- gitCommonDir = brokerMod.resolveGitCommonDir(projectRoot, { warn: () => {} });
792
- } catch {
793
- return;
794
- }
795
- }
1271
+ if (env.FORGE_SHEPHERD_DISABLE || env.NODE_ENV === 'test' || env.BUN_ENV === 'test'
1272
+ || env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || ctx.dryRun || !ctx.projectRoot) return;
1273
+ if (!(ctx.kernelInitialized || kernelInitialized)(ctx.projectRoot)) return;
1274
+ if (!(ctx.railEnabled || railAutoShepherdEnabled)(ctx.projectRoot)) return;
1275
+ const gitCommonDir = ctx.gitCommonDir
1276
+ || brokerMod.resolveGitCommonDir(ctx.projectRoot, { warn: () => {} });
796
1277
  const acquire = ctx.acquire || shepherdLease.acquire;
797
1278
  const release = ctx.release || shepherdLease.release;
798
- const launch = ctx.launch || launchDaemon;
799
- const tickFn = ctx.tick || defaultTick;
800
-
801
1279
  let token = null;
1280
+ let legacyMigrationPending = false;
802
1281
  const enumerate = () => {
803
- // COLD path only: arbitrate the singleton. The O_EXCL acquire is the atomic
804
- // arbiter — exactly one concurrent trigger wins; the rest get {ok:false}.
805
- const res = acquire(projectRoot, { gitCommonDir });
806
- if (res.ok) token = res.token;
807
- return emptyEnum(gitCommonDir);
1282
+ const result = acquire(ctx.projectRoot, { gitCommonDir, preserveLegacy: true });
1283
+ if (result.ok) token = result.token;
1284
+ legacyMigrationPending = result.legacyMigrationPending === true;
1285
+ return { desired: { openPrs: [], gitCommonDir }, observed: { ownerRows: [], ownerRowsOk: false, prRows: [] } };
808
1286
  };
809
- const execute = () => {
810
- if (token == null) return; // loser: no daemon launch
811
- // RELEASE the arbitration lease BEFORE launching so the spawned daemon can
812
- // acquire it. Holding it during launch races the child's runDaemon().acquire():
813
- // the child would see a fresh foreign owner and exit, and after we then release,
814
- // the bumped cold-tick sentinel suppresses re-launch until the next throttle
815
- // window — leaving NO daemon running.
1287
+ const executeTick = () => {
1288
+ if (token == null && !legacyMigrationPending) return;
816
1289
  const held = token;
817
1290
  token = null;
818
- try { release(projectRoot, { gitCommonDir, token: held }); } catch { /* best effort */ }
819
- try { launch({ ...ctx, projectRoot, gitCommonDir }); } catch { /* best effort */ }
1291
+ legacyMigrationPending = false;
1292
+ if (held != null) {
1293
+ try { release(ctx.projectRoot, { gitCommonDir, token: held }); } catch { /* best effort */ }
1294
+ }
1295
+ try { (ctx.launch || launchDaemon)({ ...ctx, gitCommonDir }); } catch { /* best effort */ }
820
1296
  };
821
-
822
- tickFn({ gitCommonDir, now: ctx.now, enumerate, execute, minInterval: ctx.minInterval });
823
- } catch {
824
- /* NEVER affect the command result or exit code */
825
- }
1297
+ (ctx.tick || defaultTick)({
1298
+ gitCommonDir, now: ctx.now, enumerate, execute: executeTick, minInterval: ctx.minInterval,
1299
+ });
1300
+ } catch { /* never affect triggering command */ }
826
1301
  }
827
1302
 
828
1303
  module.exports = {
829
- normalizeWatcher,
830
- writeClaimMarker,
831
- readClaimMarker,
1304
+ normalizeRepository,
832
1305
  gatherDesired,
833
1306
  gatherObserved,
834
- verifiedKill,
835
1307
  execute,
836
1308
  convergeOnce,
837
1309
  runDaemon,
838
1310
  launchDaemon,
839
1311
  writeDaemonDiagnostic,
840
1312
  defaultBuildBroker,
1313
+ migrateLegacyAuthority,
1314
+ defaultReadLegacySnapshot,
1315
+ defaultReadProviderState,
1316
+ hashLegacySnapshot,
841
1317
  fireAndForget,
842
1318
  };