spexcode 0.5.1 → 0.5.3

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 (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -1,16 +1,17 @@
1
1
  import { streamSSE } from 'hono/streaming'
2
2
  import type { Context } from 'hono'
3
3
  import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
4
- import { join, dirname } from 'node:path'
4
+ import { join, dirname, relative, resolve } from 'node:path'
5
5
  import { sessionsRoot, gitCommonDir } from './layout.js'
6
- import { hotSignature, warmSignature } from './sessions.js'
7
- import { getBoard, invalidateBoard } from './graphCache.js'
6
+ import { hotSignature, warmSignature, listSessions } from './sessions.js'
7
+ import { getBoard, invalidateBoard, patrolBoard } from './graphCache.js'
8
8
  import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
9
9
  import {
10
10
  holdSessionEvalProjectionObserver,
11
11
  invalidateSessionEvalProjections,
12
12
  releaseSessionEvalProjectionObserver,
13
13
  setSessionEvalProjectionNotify,
14
+ setSessionEvalProjectionWarmup,
14
15
  } from '../../spec-eval/src/sessioneval.js'
15
16
 
16
17
  // @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
@@ -30,15 +31,202 @@ import {
30
31
  // worktree REGISTRY (+ each live worktree root and gitdir index) — dirty source/spec/sidecar/rename/stage → 'full';
31
32
  // (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
32
33
  // a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
33
- // PATROL that invalidates FULL, rebuilds and diffs the self-heal authority that catches whatever every
34
- // leaf watcher missed (and is loud when it has to: see the repair accounting below) → 'full'. Plain mode
35
- // without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
34
+ // PATROL that asks graph-cache to validate its owned input revision unchanged inputs reuse the anchor,
35
+ // while moved inputs select sessions/full there and missed leaf signals stay loud through repair accounting.
36
+ // Plain mode without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
36
37
 
37
38
  type Scope = 'sessions' | 'full'
38
39
  type EvalTarget = 'all' | { id?: string; path?: string }
39
40
  type Notify = () => void
40
41
  type Frame = { event: string; data: string }
41
42
  type DeltaSend = (frame: Frame) => void
43
+ type TreeWatchCallback = (event: 'rename' | 'change', filename: string | Buffer | null) => void
44
+ // the one filesystem primitive the registry is allowed to call — `fs.watch`'s overloads narrowed to the
45
+ // exact shape both transports use, so an injected fake is the same three arguments.
46
+ type WatchFactory = (path: string, options: { recursive?: boolean }, callback: TreeWatchCallback) => FSWatcher
47
+ type WatchTransport = 'consolidated-recursive' | 'exact-directory'
48
+
49
+ type TreeWatcherOptions = {
50
+ root: string
51
+ source: string
52
+ scope: Scope
53
+ recursive?: boolean
54
+ ignore?: (relativePath: string) => boolean
55
+ watchFactory?: WatchFactory
56
+ transport?: WatchTransport
57
+ onInput: (event: 'rename' | 'change', relativePath: string) => void
58
+ onFailure: (error: Error) => void
59
+ }
60
+
61
+ // @@@ watch transport - the ONE place the platform appears. Observing a tree is a single product-level
62
+ // registration; how many registrations the OS actually holds for it is the transport's business.
63
+ // consolidated-recursive — the OS itself observes a whole subtree from one registration (Darwin
64
+ // FSEvents, Windows ReadDirectoryChangesW). It reports by PATH, so an atomic replacement inside the
65
+ // tree stays visible, and exclusions filter on delivery because nothing is consumed per directory.
66
+ // exact-directory — no such observer exists (Linux), and Node's `recursive` option is a USERSPACE
67
+ // fan-out registering one inotify watch per file AND directory (measured: 201 directories -> 801
68
+ // watches) which, watching files by inode, goes blind the moment a file is atomically replaced. So
69
+ // we enumerate directories once and install one NON-recursive watch each, all multiplexed onto the
70
+ // loop's single shared inotify descriptor, excluding .git/node_modules at traversal time.
71
+ export const consolidatedRecursiveWatch = (platform: NodeJS.Platform = process.platform): boolean =>
72
+ platform === 'darwin' || platform === 'win32'
73
+
74
+ // The live census across every registry: `sources` is what graph-stream asked the platform for (one per
75
+ // canonical root), `registrations` is what the platform is actually holding for them. Both are read by the
76
+ // watcher-budget tests and logged under SPEXCODE_BOARD_DEBUG, so a plateau is observable on every platform
77
+ // rather than only where /proc exposes inotify descriptors.
78
+ const liveRegistries = new Set<TreeWatcherRegistry>()
79
+ let liveRegistrations = 0
80
+ export function graphWatcherCensus(): { sources: number; registrations: number } {
81
+ return { sources: liveRegistries.size, registrations: liveRegistrations }
82
+ }
83
+
84
+ export class TreeWatcherRegistry {
85
+ readonly root: string
86
+ readonly source: string
87
+ readonly scope: Scope
88
+ readonly transport: WatchTransport
89
+ private readonly recursive: boolean
90
+ private readonly ignore: (relativePath: string) => boolean
91
+ private readonly watchFactory: WatchFactory
92
+ private readonly onInput: TreeWatcherOptions['onInput']
93
+ private readonly onFailure: TreeWatcherOptions['onFailure']
94
+ private readonly handles = new Map<string, FSWatcher>()
95
+ private refreshImmediate: ReturnType<typeof setImmediate> | null = null
96
+ private failed = false
97
+
98
+ constructor(options: TreeWatcherOptions) {
99
+ this.root = resolve(options.root)
100
+ this.source = options.source
101
+ this.scope = options.scope
102
+ this.recursive = options.recursive !== false
103
+ this.transport = options.transport
104
+ ?? (this.recursive && consolidatedRecursiveWatch() ? 'consolidated-recursive' : 'exact-directory')
105
+ this.ignore = options.ignore ?? (() => false)
106
+ this.watchFactory = options.watchFactory ?? (watch as unknown as WatchFactory)
107
+ this.onInput = options.onInput
108
+ this.onFailure = options.onFailure
109
+ }
110
+
111
+ get size(): number { return this.handles.size }
112
+ paths(): string[] { return [...this.handles.keys()].sort() }
113
+ // one kernel-side observer covers the subtree: the root is the only path we register, and no rename can
114
+ // make the desired set drift, so there is nothing to re-walk.
115
+ private get consolidated(): boolean { return this.recursive && this.transport === 'consolidated-recursive' }
116
+
117
+ private desiredDirectories(): Set<string> {
118
+ const desired = new Set<string>()
119
+ const visit = (dir: string): void => {
120
+ desired.add(dir)
121
+ if (!this.recursive || this.consolidated) return
122
+ let entries: import('node:fs').Dirent[]
123
+ try { entries = readdirSync(dir, { withFileTypes: true }) }
124
+ catch (error) {
125
+ if (dir !== this.root && (error as NodeJS.ErrnoException).code === 'ENOENT') {
126
+ desired.delete(dir)
127
+ return
128
+ }
129
+ throw error
130
+ }
131
+ for (const entry of entries) {
132
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue
133
+ const path = join(dir, entry.name)
134
+ const rel = relative(this.root, path)
135
+ if (!this.ignore(rel)) visit(path)
136
+ }
137
+ }
138
+ visit(this.root)
139
+ return desired
140
+ }
141
+
142
+ private closeHandles(): void {
143
+ if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
144
+ const handles = [...this.handles.values()]
145
+ this.handles.clear()
146
+ liveRegistrations -= handles.length
147
+ liveRegistries.delete(this)
148
+ for (const handle of handles) { try { handle.close() } catch { /* already gone */ } }
149
+ }
150
+
151
+ private sourceError(path: string, error: unknown): Error {
152
+ const err = error as NodeJS.ErrnoException
153
+ const reason = error instanceof Error ? error.message : String(error)
154
+ // source + path + errno, always — an exhausted platform must name which syscall budget it refused.
155
+ const errno = err?.code && !reason.includes(err.code) ? ` (errno ${err.code})` : ''
156
+ return new Error(`spec-cli: graph watcher '${this.source}' failed at ${path}: ${reason}${errno}`)
157
+ }
158
+
159
+ private fail(path: string, error: unknown): false {
160
+ if (this.failed) return false
161
+ this.failed = true
162
+ this.closeHandles()
163
+ this.onFailure(this.sourceError(path, error))
164
+ return false
165
+ }
166
+
167
+ private scheduleRefresh(): void {
168
+ if (this.refreshImmediate || this.failed) return
169
+ this.refreshImmediate = setImmediate(() => {
170
+ this.refreshImmediate = null
171
+ this.refresh()
172
+ })
173
+ this.refreshImmediate.unref?.()
174
+ }
175
+
176
+ refresh(): boolean {
177
+ if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
178
+ this.failed = false
179
+ let desired: Set<string>
180
+ try { desired = this.desiredDirectories() }
181
+ catch (error) { return this.fail(this.root, error) }
182
+
183
+ const before = this.handles.size
184
+ for (const [path, handle] of [...this.handles]) {
185
+ if (desired.has(path)) continue
186
+ this.handles.delete(path)
187
+ liveRegistrations--
188
+ try { handle.close() } catch { /* already gone */ }
189
+ }
190
+ const options = this.consolidated ? { recursive: true } : {}
191
+ for (const path of desired) {
192
+ if (this.handles.has(path)) continue
193
+ try {
194
+ let handle: FSWatcher
195
+ const callback: TreeWatchCallback = (event, filename) => {
196
+ if (this.handles.get(path) !== handle) return
197
+ if (filename == null) { this.fail(path, new Error('pathless filesystem event')); return }
198
+ const inputPath = resolve(path, String(filename))
199
+ const rel = relative(this.root, inputPath)
200
+ if (rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || rel === '..') {
201
+ this.fail(path, new Error(`event escaped watcher root: ${String(filename)}`))
202
+ return
203
+ }
204
+ if (this.ignore(rel)) return
205
+ this.onInput(event, rel)
206
+ // only the userspace transport's desired set can drift: a new directory under a consolidated
207
+ // observer is already covered by the kernel.
208
+ if (this.recursive && !this.consolidated && event === 'rename') this.scheduleRefresh()
209
+ }
210
+ handle = this.watchFactory(path, options, callback)
211
+ this.handles.set(path, handle)
212
+ liveRegistrations++
213
+ handle.on('error', (error) => {
214
+ if (this.handles.get(path) === handle) this.fail(path, error)
215
+ })
216
+ } catch (error) { return this.fail(path, error) }
217
+ }
218
+ if (this.handles.size) liveRegistries.add(this)
219
+ if (DEBUG && this.handles.size !== before)
220
+ console.warn(`spec-cli: graph watchers — sources=${liveRegistries.size} registrations=${liveRegistrations} (${this.source}: ${this.transport} ${this.handles.size})`)
221
+ return true
222
+ }
223
+
224
+ close(): void {
225
+ this.failed = false
226
+ this.closeHandles()
227
+ }
228
+ }
229
+
42
230
  const plainSubs = new Set<Notify>()
43
231
  const deltaSubs = new Set<DeltaSend>()
44
232
  let debounce: ReturnType<typeof setTimeout> | null = null
@@ -72,23 +260,42 @@ let lastTag = ''
72
260
  let lastFullFrame: Frame | null = null
73
261
  let building = false
74
262
  let dirty = false
263
+ let patrolPending = false
75
264
 
76
- async function rebuildAndBroadcast(): Promise<void> {
265
+ async function rebuildAndBroadcast(patrol = false): Promise<void> {
266
+ if (patrol) patrolPending = true
77
267
  if (building) { dirty = true; return }
78
268
  building = true
79
269
  try {
80
270
  do {
81
271
  dirty = false
272
+ const validate = patrolPending
273
+ patrolPending = false
82
274
  let board: unknown
83
275
  // share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
84
276
  // cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
85
- // already started).
277
+ // already started). The patrol instead asks that same cache flight to validate its input revision;
278
+ // equal inputs return the anchor without invoking a producer.
86
279
  const t0 = Date.now()
87
- try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
280
+ try { board = await (validate ? patrolBoard() : getBoard()) }
281
+ catch {
282
+ // A failed refresh consumes no cause: graph-cache restores the producer scope, so its stream-side
283
+ // attribution must remain owed too. This also retains watcher causes that arrived while the failed
284
+ // flight was occupied. A later patrol may recover the work, but it is then one cause alongside those
285
+ // healthy leaf signals rather than a false patrol-only blind-watcher repair.
286
+ for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
287
+ continue
288
+ }
88
289
  const buildMs = Date.now() - t0
89
290
  const boardJson = JSON.stringify(board)
90
291
  const { units, ok } = unitize(board as Record<string, unknown>)
91
292
  const tag = tagOf(units)
293
+ // the trigger set describes who caused THIS rebuild, so it is consumed by the rebuild — not by the
294
+ // broadcast. Clearing it only when content moved let a no-op fire (every poller's first sample is
295
+ // one) leave its tag behind forever, and the next genuine patrol repair then read as leaf-signalled
296
+ // and went silent — the alarm suppressing itself on the very machines that need it.
297
+ const tags = [...triggerTags]
298
+ triggerTags.clear()
92
299
  if (tag === lastTag) continue
93
300
  // the changed unit keys — computed against the prior anchor when we have one (a first paint has no
94
301
  // anchor, so no repair claim can be made against it).
@@ -113,12 +320,10 @@ async function rebuildAndBroadcast(): Promise<void> {
113
320
  // means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
114
321
  // it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
115
322
  // broadcast logs its changed keys + triggers + build ms.
116
- const tags = [...triggerTags]
117
323
  if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
118
324
  console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`)
119
325
  if (DEBUG)
120
326
  console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`)
121
- triggerTags.clear()
122
327
  } while (dirty)
123
328
  } finally { building = false }
124
329
  }
@@ -162,280 +367,469 @@ export const notifyBoardChanged = (scope: Scope = 'full'): void =>
162
367
  // Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
163
368
  setSessionEvalProjectionNotify(() => fireChanged('sessions'))
164
369
 
370
+ // ---- ONE repair scheduler for every filesystem source ----
371
+ // A source the platform refuses keeps its observer hold and is retried by THIS timer alone — never by a
372
+ // graph build, an HTTP read, a poller tick or a registry event. That is the whole anti-storm rule: the
373
+ // resource being exhausted (a registration budget, a descriptor table) is PROCESS-wide, so its backoff
374
+ // belongs to the process, not to each source racing its own retry and re-walking the corpus every time.
375
+ // `heldSources` is what an ordinary pass may not re-attempt; only the scheduled pass lifts that.
376
+ const heldSources = new Set<string>()
377
+ let repairTimer: ReturnType<typeof setTimeout> | null = null
378
+ let repairStep = 0
379
+ let repairing = false
380
+
381
+ const mayAttach = (source: string): boolean => repairing || !heldSources.has(source)
382
+
383
+ // The failure is loud ONCE per episode and names source + path + errno (the registry built that message);
384
+ // every later source felled by the same exhausted budget is counted, not re-printed, and the repair line
385
+ // reports the total. A half-attached registry is already closed by the registry itself before we get here.
386
+ function noteSourceFailure(source: string, error: unknown): void {
387
+ const known = heldSources.has(source)
388
+ heldSources.add(source)
389
+ if (!known && heldSources.size === 1) console.error(error instanceof Error ? error.message : String(error))
390
+ scheduleWatcherRepair()
391
+ }
392
+
393
+ function noteSourceHealthy(source: string): void {
394
+ if (!heldSources.delete(source)) return
395
+ if (heldSources.size === 0) repairStep = 0
396
+ }
397
+
398
+ function scheduleWatcherRepair(): void {
399
+ if (repairTimer || !heldSources.size) return
400
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(repairStep, 7))
401
+ repairStep++
402
+ const era = watcherEra
403
+ console.error(`spec-cli: graph watcher repair — ${heldSources.size} source(s) held, retrying in ${delay}ms; the cold-tick patrol covers the gap until they reattach`)
404
+ repairTimer = setTimeout(() => {
405
+ repairTimer = null
406
+ if (era !== watcherEra) return
407
+ repairing = true
408
+ void ensureBoardFileWatchers()
409
+ .catch((error) => console.error(`spec-cli: graph watcher repair failed — ${error instanceof Error ? error.message : String(error)}`))
410
+ .finally(() => {
411
+ repairing = false
412
+ if (heldSources.size) scheduleWatcherRepair()
413
+ })
414
+ }, delay)
415
+ repairTimer.unref?.()
416
+ }
417
+
165
418
  // ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
166
- let watcher: FSWatcher | null = null
167
- function ensureWatcher(): void {
168
- if (watcher) return
169
- if (isDisabled('store')) return
170
- const root = sessionsRoot()
171
- try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
172
- try { watcher = watch(root, { recursive: true }, () => fireChanged('sessions')) } catch { watcher = null }
419
+ let activeStoreRoot: string | null = null
420
+ let activeCommonRoot: string | null = null
421
+ let watcherEra = 0
422
+ let storeWatcher: TreeWatcherRegistry | null = null
423
+
424
+ function ensureWatcher(root: string): void {
425
+ if (storeWatcher?.root === root) return
426
+ if (storeWatcher) { storeWatcher.close(); storeWatcher = null }
427
+ if (isDisabled('store') || !mayAttach('store')) return
428
+ try { mkdirSync(root, { recursive: true }) }
429
+ catch (error) {
430
+ console.error(`spec-cli: graph watcher 'store' could not create ${root}: ${error instanceof Error ? error.message : String(error)}`)
431
+ }
432
+ const registry = new TreeWatcherRegistry({
433
+ root,
434
+ source: 'store',
435
+ scope: 'sessions',
436
+ onInput: () => fireChanged('sessions'),
437
+ onFailure: (error) => {
438
+ if (storeWatcher === registry) storeWatcher = null
439
+ noteSourceFailure('store', error)
440
+ fireChanged('sessions')
441
+ },
442
+ })
443
+ storeWatcher = registry
444
+ if (!registry.refresh()) {
445
+ if (storeWatcher === registry) storeWatcher = null
446
+ return
447
+ }
448
+ noteSourceHealthy('store')
173
449
  }
174
450
 
175
451
  // ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
176
452
  // refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
177
453
  // for packed-refs rewrites and HEAD flips. Ordinary graph units still have the patrol; eval projections are
178
454
  // observer-held across a failure and only become current after a replacement watch authorizes a rescan.
179
- let refsWatchers: FSWatcher[] | null = null
455
+ type RegistryGroup = {
456
+ root: string
457
+ close(): void
458
+ }
459
+ let refsWatchers: RegistryGroup | null = null
180
460
  const REFS_OBSERVER = 'graph:refs'
181
461
 
182
462
  export function watchSessionEvalRefs(
183
463
  common: string,
184
464
  onInput: () => void,
185
- onFailure: () => void,
186
- ): FSWatcher[] {
187
- const attached: FSWatcher[] = []
465
+ onFailure: (error: Error) => void,
466
+ ): RegistryGroup {
467
+ let attached: TreeWatcherRegistry[] = []
188
468
  let failed = false
469
+ let ready = false
470
+ let attachError: Error | null = null
189
471
  const close = () => {
190
- for (const watcher of attached) { try { watcher.close() } catch { /* already gone */ } }
472
+ const registries = attached
473
+ attached = []
474
+ for (const registry of registries) registry.close()
191
475
  }
192
- const fail = () => {
476
+ const fail = (error: Error) => {
193
477
  if (failed) return
194
478
  failed = true
195
479
  close()
196
- onFailure()
197
- }
198
- try {
199
- attached.push(watch(join(common, 'refs'), { recursive: true }, (_event, file) => {
200
- if (file == null) { fail(); return }
201
- onInput()
202
- }))
203
- attached.push(watch(common, (_event, file) => {
204
- if (file == null) { fail(); return }
205
- if (file === 'packed-refs' || file === 'HEAD') onInput()
206
- }))
207
- for (const watcher of attached) watcher.on('error', fail)
208
- return attached
209
- } catch (error) {
210
- close()
211
- throw error
480
+ if (ready) onFailure(error)
481
+ else attachError = error
212
482
  }
483
+ const refs = new TreeWatcherRegistry({
484
+ root: join(common, 'refs'),
485
+ source: 'refs',
486
+ scope: 'full',
487
+ onInput: () => onInput(),
488
+ onFailure: fail,
489
+ })
490
+ attached.push(refs)
491
+ if (!refs.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs' failed at ${refs.root}`)
492
+
493
+ const commonFiles = new TreeWatcherRegistry({
494
+ root: common,
495
+ source: 'refs-common',
496
+ scope: 'full',
497
+ recursive: false,
498
+ onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() },
499
+ onFailure: fail,
500
+ })
501
+ attached.push(commonFiles)
502
+ if (!commonFiles.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs-common' failed at ${common}`)
503
+ ready = true
504
+ return { root: resolve(common), close }
213
505
  }
214
506
 
215
- function refsWatcherFailed(): void {
507
+ function refsWatcherFailed(error: Error): void {
216
508
  refsWatchers = null
509
+ noteSourceFailure('refs', error)
217
510
  if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
218
- setImmediate(() => ensureRefsWatcher(false))
219
511
  }
220
512
 
221
- function ensureRefsWatcher(retry = true): void {
222
- if (refsWatchers) return
513
+ function ensureRefsWatcher(common = activeCommonRoot): void {
514
+ if (!common) return
515
+ if (refsWatchers?.root === common) return
516
+ if (refsWatchers) { refsWatchers.close(); refsWatchers = null }
223
517
  if (isDisabled('refs')) {
224
518
  if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
225
519
  return
226
520
  }
521
+ if (!mayAttach('refs')) return
227
522
  try {
228
- const common = gitCommonDir()
229
523
  refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full', 'all'), refsWatcherFailed)
524
+ noteSourceHealthy('refs')
230
525
  if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
231
- } catch {
526
+ } catch (error) {
232
527
  refsWatchers = null
528
+ noteSourceFailure('refs', error)
233
529
  if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
234
- if (retry) setImmediate(() => ensureRefsWatcher(false))
235
530
  }
236
531
  }
237
532
 
238
533
  // ---- event source 3: worktree registry + working roots + per-worktree indexes → 'full' ----
239
- // Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each registry row
240
- // therefore owns a recursive working-root watcher plus a non-recursive watcher on git's worktree metadata
241
- // dir (`index`). A delivered event advances that worktree's eval generation before the graph rebuild.
242
- let registryWatcher: FSWatcher | null = null
243
- type WorktreeWatch = { path: string; root: FSWatcher; index: FSWatcher }
534
+ // Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each live
535
+ // worktree is therefore TWO canonical roots its working tree and git's metadata dir for it (`index`) —
536
+ // and that pair is the whole per-worktree registration cost, whatever the corpus inside it holds.
537
+ let registryWatcher: TreeWatcherRegistry | null = null
538
+ let registryReady = false
539
+ type WorktreeWatch = {
540
+ path: string
541
+ root: TreeWatcherRegistry
542
+ index: TreeWatcherRegistry
543
+ close(): void
544
+ }
244
545
  const worktreeWatchers = new Map<string, WorktreeWatch>()
245
- const worktreeRetryAttempted = new Set<string>()
246
- const worktreeRetryCount = new Map<string, number>()
247
546
  const worktreeObserver = (name: string): string => `graph:worktree:${name}`
248
-
249
- export function scheduleWorktreeResubscribe(
250
- name: string,
251
- attempted: Set<string>,
252
- retry: () => void,
253
- delayMs = 0,
254
- ): boolean {
255
- if (attempted.has(name)) return false
256
- attempted.add(name)
257
- const timer = setTimeout(() => {
258
- attempted.delete(name)
259
- retry()
260
- }, delayMs)
261
- timer.unref?.()
262
- return true
263
- }
264
-
265
- function scheduleWorktreeRetry(name: string): boolean {
266
- if (worktreeRetryAttempted.has(name)) return false
267
- const attempt = (worktreeRetryCount.get(name) ?? 0) + 1
268
- worktreeRetryCount.set(name, attempt)
269
- const delayMs = Math.min(1_000, 25 * (2 ** Math.min(attempt - 1, 5)))
270
- return scheduleWorktreeResubscribe(name, worktreeRetryAttempted, reconcileWorktrees, delayMs)
271
- }
547
+ const worktreeSource = (name: string): string => `worktree:${name}`
272
548
 
273
549
  const ignoredWorktreePath = (file: string): boolean =>
274
- file === '.git' || file.startsWith('.git/') || file === 'node_modules' || file.startsWith('node_modules/')
550
+ file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules')
275
551
 
276
552
  export function watchSessionEvalWorktree(
277
553
  wtPath: string,
278
554
  gitDir: string,
279
555
  onInput: () => void,
280
- onFailure: () => void,
281
- ): { root: FSWatcher; index: FSWatcher } {
556
+ onFailure: (error: Error) => void,
557
+ ): WorktreeWatch {
282
558
  let failed = false
283
- let root: FSWatcher | null = null
284
- let index: FSWatcher | null = null
285
- const fail = () => {
559
+ let ready = false
560
+ let attachError: Error | null = null
561
+ let root: TreeWatcherRegistry | null = null
562
+ let index: TreeWatcherRegistry | null = null
563
+ const close = () => {
564
+ root?.close()
565
+ index?.close()
566
+ }
567
+ const fail = (error: Error) => {
286
568
  if (failed) return
287
569
  failed = true
288
- try { root?.close() } catch { /* already gone */ }
289
- try { index?.close() } catch { /* already gone */ }
290
- onFailure()
291
- }
292
- try {
293
- root = watch(wtPath, { recursive: true }, (_event, filename) => {
294
- if (filename == null) { fail(); return }
295
- const file = String(filename)
296
- if (!ignoredWorktreePath(file)) onInput()
297
- })
298
- index = watch(gitDir, (_event, filename) => {
299
- if (filename == null) { fail(); return }
300
- if (String(filename) === 'index') onInput()
301
- })
302
- root.on('error', fail)
303
- index.on('error', fail)
304
- return { root, index }
305
- } catch (error) {
306
- fail()
307
- throw error
570
+ close()
571
+ if (ready) onFailure(error)
572
+ else attachError = error
308
573
  }
574
+ root = new TreeWatcherRegistry({
575
+ root: wtPath,
576
+ source: `worktree:${resolve(wtPath)}`,
577
+ scope: 'full',
578
+ ignore: ignoredWorktreePath,
579
+ onInput: () => onInput(),
580
+ onFailure: fail,
581
+ })
582
+ if (!root.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree' failed at ${wtPath}`)
583
+ index = new TreeWatcherRegistry({
584
+ root: gitDir,
585
+ source: `worktree-index:${resolve(wtPath)}`,
586
+ scope: 'full',
587
+ recursive: false,
588
+ onInput: (_event, file) => { if (file === 'index') onInput() },
589
+ onFailure: fail,
590
+ })
591
+ if (!index.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-index' failed at ${gitDir}`)
592
+ ready = true
593
+ return { path: resolve(wtPath), root, index, close }
309
594
  }
310
595
 
311
596
  function dropWorktreeWatcher(name: string): WorktreeWatch | null {
312
597
  const row = worktreeWatchers.get(name)
313
598
  if (!row) return null
314
599
  worktreeWatchers.delete(name)
315
- try { row.root.close() } catch { /* already gone */ }
316
- try { row.index.close() } catch { /* already gone */ }
600
+ row.close()
317
601
  return row
318
602
  }
319
603
 
320
- function watcherFailed(name: string, path: string): void {
321
- // Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and immediately
322
- // resubscribe; the authorized summary build is the one authoritative rescan, never a periodic sweep.
604
+ function watcherFailed(name: string, path: string, error: Error): void {
605
+ // Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and hand the
606
+ // reattach to the one repair scheduler; the authorized summary build is the one authoritative rescan,
607
+ // never a periodic sweep and never a re-walk driven by whatever read noticed the failure.
323
608
  dropWorktreeWatcher(name)
609
+ noteSourceFailure(worktreeSource(name), error)
324
610
  if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path })) fireChanged('full')
325
- scheduleWorktreeRetry(name)
326
611
  }
327
612
 
328
- function reconcileWorktrees(): void {
329
- let dir: string
330
- try { dir = join(gitCommonDir(), 'worktrees') } catch { return }
613
+ const forcedWorktreeSessions = new Set<string>()
614
+ let worktreeReconcileFlight: Promise<void> | null = null
615
+
616
+ export function sessionWorktreeWatchPaths(
617
+ sessions: { id: string; path: string; liveness?: string }[],
618
+ forcedSessions: Set<string> = new Set(),
619
+ ): Set<string> {
620
+ return new Set(sessions
621
+ .filter((session) => session.liveness !== 'offline' || forcedSessions.has(session.id))
622
+ .map((session) => resolve(session.path)))
623
+ }
624
+
625
+ async function reconcileWorktreePass(forcedSessions: Set<string>, era: number, common: string): Promise<void> {
626
+ const dir = join(common, 'worktrees')
627
+ let sessions: Awaited<ReturnType<typeof listSessions>>
628
+ try { sessions = await listSessions() } catch { return }
629
+ if (era !== watcherEra) return
630
+ const wantedPaths = sessionWorktreeWatchPaths(sessions, forcedSessions)
331
631
  let ents: import('node:fs').Dirent[] = []
332
632
  try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
333
- const live = new Set<string>()
633
+ const wantedNames = new Set<string>()
334
634
  let released = false
335
635
  for (const e of ents) {
336
636
  if (!e.isDirectory()) continue
337
- live.add(e.name)
338
- if (worktreeWatchers.has(e.name) || worktreeRetryAttempted.has(e.name)) continue
637
+ let wtPath: string
638
+ try { wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { continue }
639
+ const normalizedPath = resolve(wtPath)
640
+ if (!wantedPaths.has(normalizedPath)) {
641
+ if (dropWorktreeWatcher(e.name)) released = releaseSessionEvalProjectionObserver(worktreeObserver(e.name)) || released
642
+ noteSourceHealthy(worktreeSource(e.name))
643
+ continue
644
+ }
645
+ wantedNames.add(e.name)
646
+ const existing = worktreeWatchers.get(e.name)
647
+ if (existing?.path === normalizedPath) continue
648
+ const rootChanged = existing != null
649
+ if (existing) dropWorktreeWatcher(e.name)
650
+ // a held worktree is the repair scheduler's to reattach — an ordinary reconcile pass must not re-walk
651
+ // it, which is what turned one refused registration into a per-read attach storm.
652
+ if (!mayAttach(worktreeSource(e.name))) continue
339
653
  try {
340
654
  // the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
341
- const wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim())
342
- const { root, index } = watchSessionEvalWorktree(
655
+ const row = watchSessionEvalWorktree(
343
656
  wtPath,
344
657
  join(dir, e.name),
345
658
  () => fireChanged('full', { path: wtPath }),
346
- () => watcherFailed(e.name, wtPath),
659
+ (error) => watcherFailed(e.name, wtPath, error),
347
660
  )
348
- const row = { path: wtPath, root, index }
661
+ if (era !== watcherEra) { row.close(); return }
349
662
  worktreeWatchers.set(e.name, row)
350
- worktreeRetryAttempted.delete(e.name)
351
- worktreeRetryCount.delete(e.name)
663
+ noteSourceHealthy(worktreeSource(e.name))
664
+ if (rootChanged) fireChanged('full', { path: wtPath })
352
665
  // The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
353
666
  // so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
354
667
  if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name))) fireChanged('full')
355
- } catch {
356
- // An attach failure is observable: mark unknown/full now. The next registry change or explicit graph
357
- // source setup retries; no patrol is allowed to call the eval projection current.
358
- let path: string | null = null
359
- try { path = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { /* broken row */ }
360
- if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), path ? { path } : 'all')) fireChanged('full')
361
- scheduleWorktreeRetry(e.name)
668
+ } catch (error) {
669
+ // An attach failure is observable: mark unknown/full now. The repair scheduler owns the reattach; no
670
+ // patrol is allowed to call the eval projection current meanwhile.
671
+ noteSourceFailure(worktreeSource(e.name), error)
672
+ if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), { path: wtPath })) fireChanged('full')
362
673
  }
363
674
  }
364
- for (const name of worktreeWatchers.keys()) if (!live.has(name)) {
675
+ for (const name of worktreeWatchers.keys()) if (!wantedNames.has(name)) {
365
676
  dropWorktreeWatcher(name)
366
677
  released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
367
678
  }
368
- for (const name of worktreeRetryCount.keys()) if (!live.has(name)) {
369
- worktreeRetryAttempted.delete(name)
370
- worktreeRetryCount.delete(name)
371
- released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
679
+ for (const source of [...heldSources]) {
680
+ const name = source.startsWith('worktree:') ? source.slice('worktree:'.length) : null
681
+ if (name && !wantedNames.has(name)) {
682
+ noteSourceHealthy(source)
683
+ released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
684
+ }
372
685
  }
373
686
  if (released) fireChanged('full')
374
687
  }
688
+
689
+ function reconcileWorktrees(forceSessionId?: string): Promise<void> {
690
+ // Blinding a leaf must blind it from EVERY entry point. Reconciliation is reached from the liveness
691
+ // poller and from registry events as well as from the ensure pass, so gating only the ensure pass left
692
+ // the per-worktree observers attaching anyway — the injection looked applied while the leaf still saw
693
+ // everything, which makes the patrol's accountability untestable rather than merely untested.
694
+ if (isDisabled('worktrees')) return Promise.resolve()
695
+ if (forceSessionId) forcedWorktreeSessions.add(forceSessionId)
696
+ if (worktreeReconcileFlight) return worktreeReconcileFlight
697
+ const era = watcherEra
698
+ const common = activeCommonRoot
699
+ if (!common) return Promise.resolve()
700
+ const flight = (async () => {
701
+ do {
702
+ if (era !== watcherEra) return
703
+ const forced = new Set(forcedWorktreeSessions)
704
+ forcedWorktreeSessions.clear()
705
+ await reconcileWorktreePass(forced, era, common)
706
+ } while (forcedWorktreeSessions.size)
707
+ })().finally(() => { if (worktreeReconcileFlight === flight) worktreeReconcileFlight = null })
708
+ worktreeReconcileFlight = flight
709
+ void flight.catch((error) => {
710
+ console.warn(`spec-cli: worktree watcher reconciliation failed — ${error instanceof Error ? error.message : String(error)}`)
711
+ })
712
+ return flight
713
+ }
375
714
  const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
376
715
 
377
716
  export function watchSessionEvalRegistry(
378
717
  dir: string,
379
718
  onInput: () => void,
380
- onFailure: () => void,
381
- ): FSWatcher {
382
- let watcher: FSWatcher | null = null
383
- let failed = false
384
- const fail = () => {
385
- if (failed) return
386
- failed = true
387
- try { watcher?.close() } catch { /* already gone */ }
388
- onFailure()
389
- }
390
- try {
391
- watcher = watch(dir, (_event, file) => {
392
- if (file == null) { fail(); return }
393
- onInput()
394
- })
395
- watcher.on('error', fail)
396
- return watcher
397
- } catch (error) {
398
- try { watcher?.close() } catch { /* partial attach */ }
399
- throw error
400
- }
719
+ onFailure: (error: Error) => void,
720
+ ): TreeWatcherRegistry {
721
+ let ready = false
722
+ let attachError: Error | null = null
723
+ const registry = new TreeWatcherRegistry({
724
+ root: dir,
725
+ source: 'worktree-registry',
726
+ scope: 'full',
727
+ recursive: false,
728
+ onInput: () => onInput(),
729
+ onFailure: (error) => {
730
+ if (ready) onFailure(error)
731
+ else attachError = error
732
+ },
733
+ })
734
+ if (!registry.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-registry' failed at ${dir}`)
735
+ ready = true
736
+ return registry
401
737
  }
402
738
 
403
- function registryWatcherFailed(): void {
739
+ function registryWatcherFailed(error: Error): void {
404
740
  registryWatcher = null
741
+ registryReady = false
742
+ noteSourceFailure('worktree-registry', error)
405
743
  if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
406
- setImmediate(() => ensureWorktreeRegistry(false))
407
744
  }
408
745
 
409
- function ensureWorktreeRegistry(retry = true): void {
410
- if (registryWatcher) { reconcileWorktrees(); return }
746
+ async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
747
+ const common = activeCommonRoot
748
+ if (!common) return
749
+ const dir = resolve(join(common, 'worktrees'))
750
+ // The registry watcher already reconciles add/remove events. Re-scanning every ordinary graph/evals read
751
+ // turns a large worktree registry into an artificial request latency floor; only a scoped read may demand
752
+ // one target after startup, while the unscoped hot path reuses the attached live watchers.
753
+ // an attached registry short-circuits the scan for ordinary reads, but a REPAIR pass exists precisely to
754
+ // reattach held worktrees, so it must reach the reconcile even when the registry itself is healthy.
755
+ if (registryWatcher?.root === dir) {
756
+ if (forceSessionId || repairing) await reconcileWorktrees(forceSessionId)
757
+ return
758
+ }
759
+ if (registryWatcher) { registryWatcher.close(); registryWatcher = null; registryReady = false }
760
+ // A platform may reject registry watches. Once the initial reconciliation has run, ordinary
761
+ // reads must not repeat its full worktree scan while the repair pass is pending; scoped reads can
762
+ // still demand their target explicitly.
763
+ if (registryReady && !forceSessionId && !repairing) return
764
+ if (registryReady && (forceSessionId || repairing)) { await reconcileWorktrees(forceSessionId); return }
411
765
  if (isDisabled('worktrees')) {
766
+ registryReady = true
412
767
  if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
413
768
  return
414
769
  }
770
+ if (!mayAttach('worktree-registry')) return
771
+ registryReady = true
415
772
  try {
416
- const dir = join(gitCommonDir(), 'worktrees')
417
- try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ }
773
+ try { mkdirSync(dir, { recursive: true }) }
774
+ catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
418
775
  // a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
419
776
  // reconcile the per-worktree `.spec` watchers on every registry event.
420
777
  registryWatcher = watchSessionEvalRegistry(dir, () => {
421
- reconcileWorktrees()
778
+ void reconcileWorktrees()
422
779
  fireChanged('full', 'all')
423
780
  }, registryWatcherFailed)
424
- } catch {
781
+ noteSourceHealthy('worktree-registry')
782
+ } catch (error) {
425
783
  registryWatcher = null
784
+ registryReady = false
785
+ noteSourceFailure('worktree-registry', error)
426
786
  if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
427
- if (retry) setImmediate(() => ensureWorktreeRegistry(false))
428
787
  }
429
- reconcileWorktrees() // attach for the worktrees that already exist when the source starts
788
+ await reconcileWorktrees(forceSessionId) // attach for the live/demanded worktrees that already exist
430
789
  if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)) fireChanged('full')
431
790
  }
432
791
 
433
792
  // Attach the canonical filesystem sources before an HTTP snapshot starts summary work. This closes the
434
- // request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur.
435
- export function ensureBoardFileWatchers(): void {
436
- ensureWatcher()
437
- ensureRefsWatcher()
438
- ensureWorktreeRegistry()
793
+ // request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur. A source
794
+ // the platform refused is NOT retried here — it is held for the repair scheduler, so an HTTP read never
795
+ // becomes a reattach loop.
796
+ export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<void> {
797
+ const storeRoot = resolve(sessionsRoot())
798
+ const commonRoot = resolve(gitCommonDir())
799
+ if ((activeStoreRoot && activeStoreRoot !== storeRoot) || (activeCommonRoot && activeCommonRoot !== commonRoot))
800
+ closeBoardFileWatchers()
801
+ activeStoreRoot = storeRoot
802
+ activeCommonRoot = commonRoot
803
+ ensureWatcher(storeRoot)
804
+ ensureRefsWatcher(commonRoot)
805
+ await ensureWorktreeRegistry(forceSessionId)
806
+ }
807
+
808
+ export function closeBoardFileWatchers(): void {
809
+ watcherEra++
810
+ if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
811
+ heldSources.clear()
812
+ repairStep = 0
813
+ repairing = false
814
+ forcedWorktreeSessions.clear()
815
+ worktreeReconcileFlight = null
816
+
817
+ storeWatcher?.close()
818
+ storeWatcher = null
819
+ refsWatchers?.close()
820
+ refsWatchers = null
821
+ registryWatcher?.close()
822
+ registryWatcher = null
823
+ registryReady = false
824
+ for (const [name, row] of worktreeWatchers) {
825
+ row.close()
826
+ releaseSessionEvalProjectionObserver(worktreeObserver(name))
827
+ }
828
+ worktreeWatchers.clear()
829
+ releaseSessionEvalProjectionObserver(REFS_OBSERVER)
830
+ releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)
831
+ activeStoreRoot = null
832
+ activeCommonRoot = null
439
833
  }
440
834
 
441
835
  // ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
@@ -448,7 +842,9 @@ let lastHot = ''
448
842
  let lastWarm = ''
449
843
  function ensurePollers(): void {
450
844
  if (!hotPoller) hotPoller = setInterval(() => {
451
- void hotSignature().then((sig) => { if (sig !== lastHot) { lastHot = sig; fireChanged('sessions') } }).catch(() => {})
845
+ void hotSignature().then((sig) => {
846
+ if (sig !== lastHot) { lastHot = sig; void reconcileWorktrees(); fireChanged('sessions') }
847
+ }).catch(() => {})
452
848
  }, 100)
453
849
  if (!warmPoller) warmPoller = setInterval(() => {
454
850
  void warmSignature().then((sig) => { if (sig !== lastWarm) { lastWarm = sig; fireChanged('sessions') } }).catch(() => {})
@@ -456,19 +852,17 @@ function ensurePollers(): void {
456
852
  }
457
853
 
458
854
  // ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
459
- // poll, AND the self-heal authority. Rebuild+diff on a relaxed timer so what NO watcher saw (an uncommitted
460
- // worktree spec edit a registry watch missed, a forge issue refresh) still lands. It INVALIDATES FULL first
461
- // otherwise getBoard() serves the stale cache and the patrol is a no-op (the real bug this fixes) — and
462
- // tags the window 'patrol' so the repair accounting can flag a change only it caught. Delta-gated: plain-only
463
- // clients keep their own client-side fallback, so without delta subscribers this must not burn builds.
855
+ // poll, AND the self-heal authority. A relaxed tick asks graph-cache's ONE flight to compare its board-input
856
+ // revision: unchanged state returns the anchor without assembly, while an unobserved ref/worktree/store move
857
+ // escalates there to a full rebuild. The 'patrol' tag keeps that repair accountable. Delta-gated: plain-only
858
+ // clients keep their own client-side fallback, so without delta subscribers this does no validation work.
464
859
  let coldTick: ReturnType<typeof setInterval> | null = null
465
860
  function ensureColdTick(): void {
466
861
  if (coldTick) return
467
862
  coldTick = setInterval(() => {
468
863
  if (!deltaSubs.size) return
469
- invalidateBoard('full')
470
864
  triggerTags.add('patrol')
471
- void rebuildAndBroadcast()
865
+ void rebuildAndBroadcast(true)
472
866
  }, 15000)
473
867
  }
474
868
 
@@ -477,7 +871,8 @@ function stopSourcesIfIdle(): void {
477
871
  // never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
478
872
  // first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
479
873
  // client's recovery lanes can latch each other out — issue #70). A new era opens on a fresh build instead.
480
- if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null }
874
+ if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null; patrolPending = false }
875
+ if (deltaSubs.size === 0) setSessionEvalProjectionWarmup(false)
481
876
  if (plainSubs.size + deltaSubs.size > 0) return
482
877
  if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
483
878
  if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
@@ -488,14 +883,18 @@ function stopSourcesIfIdle(): void {
488
883
  // idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
489
884
  // auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
490
885
  // chain re-anchors with no client-side repair logic.
491
- export function boardStream(c: Context) {
886
+ export async function boardStream(c: Context) {
492
887
  const delta = c.req.query('mode') === 'delta'
493
- ensureBoardFileWatchers()
888
+ await ensureBoardFileWatchers()
494
889
  return streamSSE(c, async (stream) => {
495
890
  let aborted = false
496
891
  const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
497
892
  const notify: Notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => {}) }
498
- if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
893
+ if (delta) {
894
+ deltaSubs.add(send)
895
+ setSessionEvalProjectionWarmup(true)
896
+ ensureColdTick()
897
+ } else { plainSubs.add(notify) }
499
898
  ensurePollers()
500
899
  const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
501
900
  stream.onAbort(() => { aborted = true; unsub() })