@ianwremmel/dispatch 0.32.1-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,360 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {assertInstant} from '../db/time.mts';
3
+ import {DataError, ensure} from '../errors/index.mts';
4
+ import type {Observation} from '../watch/diff.mts';
5
+ import type {PrSnapshot} from '../watch/snapshot.mts';
6
+ import {findNode} from './materialize.mts';
7
+
8
+ /* eslint-disable @typescript-eslint/require-await --
9
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
10
+
11
+ export interface DueWatch {
12
+ node: string;
13
+ repo: string;
14
+ prNumber: number;
15
+ /** The last observation, or null when none has been taken yet. */
16
+ snapshot: PrSnapshot | null;
17
+ /** Identity token for `observe`: a replacement watch mints a new one. */
18
+ createdAt: string;
19
+ /**
20
+ * Past its expiry. The snapshot sees only the forge, so a signal outside it
21
+ * — an approval given on the ticket, a reaction, an out-of-band go-ahead —
22
+ * would otherwise never reach the worker. An expired watch fires on no diff
23
+ * at all, reporting `watch_expired`, which tells the worker to go look for
24
+ * itself.
25
+ *
26
+ * Never true for a parked item: expiry addresses a worker, and an item
27
+ * whose outcome is recorded has none. Its watch keeps running until a real
28
+ * diff fires it.
29
+ */
30
+ expired: boolean;
31
+ }
32
+
33
+ /**
34
+ * A worker's PR wait, handed to the server. The worker records what it waits
35
+ * on and returns; the server snapshots the PR on its tick, diffs against the
36
+ * stored snapshot, and fires the row when something a worker would act on
37
+ * changed. The row survives dispatch — a crashed resume still reads as a wait
38
+ * to pick up — and is removed when the item's outcome is recorded.
39
+ *
40
+ * `human-blocked` is the exception both ways: it keeps its watch, because a
41
+ * park is a wait handed to the operator rather than a conclusion, and
42
+ * `dispatch outcome rm` is what finally drops it.
43
+ */
44
+ export class WatchStore {
45
+ readonly #db: Database;
46
+
47
+ constructor(db: Database) {
48
+ this.#db = db;
49
+ }
50
+
51
+ async set(input: {
52
+ node: string;
53
+ intervalSeconds: number;
54
+ at: string;
55
+ /**
56
+ * The deadline this wait fires at whatever the diff says. Arming is the
57
+ * only thing that sets it: a PR polled more often than the expiry window
58
+ * never reaches a deadline a poll can push out.
59
+ */
60
+ expiresAt: string;
61
+ /**
62
+ * The PR as of arming. Recording it here is what closes the gap between
63
+ * the worker's last look and the first poll; null (the read failed)
64
+ * degrades to priming on the first successful poll, which can miss a
65
+ * change that landed in between.
66
+ */
67
+ snapshot: PrSnapshot | null;
68
+ /** The session whose worker armed this wait; only it can route the events. */
69
+ session: string | null;
70
+ /**
71
+ * Release this session's claim in the same transaction. The two must not
72
+ * be separable: between a released claim and an installed watch the item
73
+ * is neither claimed nor watching, so another server can claim and
74
+ * dispatch it — and the late arm then hides that live worker's claim
75
+ * behind a watching row.
76
+ */
77
+ releaseClaimFor?: string | null;
78
+ }): Promise<void> {
79
+ assertInstant(input.at, 'at');
80
+ assertInstant(input.expiresAt, 'expiresAt');
81
+ await this.#db.transaction(() => {
82
+ const node = findNode(this.#db, input.node);
83
+ ensure(
84
+ node !== null,
85
+ () =>
86
+ new DataError(`no node "${input.node}" to watch`, {
87
+ hint: 'a watch is set on a PR item the graph already holds.',
88
+ })
89
+ );
90
+ if (input.releaseClaimFor != null) {
91
+ this.#db.run('DELETE FROM claim WHERE node_id = ? AND session_id = ?', [
92
+ node.id,
93
+ input.releaseClaimFor,
94
+ ]);
95
+ }
96
+ // Re-arming starts a new wait, so anything the previous one observed
97
+ // and never delivered describes a wait nobody is in any more.
98
+ this.#db.run('DELETE FROM pr_event WHERE node_id = ?', [node.id]);
99
+ this.#db.run(
100
+ `INSERT INTO watch (node_id, state, snapshot, interval_s, session_id, created_at, expires_at)
101
+ VALUES (?, 'watching', ?, ?, ?, ?, ?)
102
+ ON CONFLICT(node_id) DO UPDATE SET
103
+ state = 'watching',
104
+ snapshot = excluded.snapshot,
105
+ interval_s = excluded.interval_s, checked_at = NULL,
106
+ session_id = excluded.session_id,
107
+ created_at = excluded.created_at, expires_at = excluded.expires_at`,
108
+ [
109
+ node.id,
110
+ input.snapshot === null ? null : JSON.stringify(input.snapshot),
111
+ input.intervalSeconds,
112
+ input.session,
113
+ input.at,
114
+ input.expiresAt,
115
+ ]
116
+ );
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Watching rows ready for a poll — expired, never checked, or past their
122
+ * interval — capped so one pass stays short. A row whose item lacks PR
123
+ * coordinates is skipped; there is nothing to read yet.
124
+ *
125
+ * A parked row (an outcome is recorded, so the only watch left is the one
126
+ * `human-blocked` keeps) is never expired and is never made due by expiry:
127
+ * it comes round on its interval alone. Otherwise the pass would fire it
128
+ * into a `resume` nobody prompted, on a schedule rather than on an answer.
129
+ *
130
+ * Expired rows come first, then oldest check. An expired row has been
131
+ * checked, so under oldest-check order alone a steady influx of new watches
132
+ * holds it outside the cap indefinitely.
133
+ */
134
+ async due(now: string, limit: number): Promise<DueWatch[]> {
135
+ assertInstant(now, 'now');
136
+ return this.#db
137
+ .all(
138
+ `SELECT n.external_id AS node, pr.repo, pr.pr_number,
139
+ w.snapshot, w.created_at,
140
+ (unixepoch(?) >= unixepoch(w.expires_at)
141
+ AND o.node_id IS NULL) AS expired
142
+ FROM watch w
143
+ JOIN node n ON n.id = w.node_id
144
+ JOIN pr ON pr.node_id = w.node_id
145
+ LEFT JOIN outcome o ON o.node_id = w.node_id
146
+ WHERE w.state = 'watching'
147
+ AND pr.repo IS NOT NULL AND pr.pr_number IS NOT NULL
148
+ AND ((unixepoch(?) >= unixepoch(w.expires_at)
149
+ AND o.node_id IS NULL)
150
+ OR w.checked_at IS NULL
151
+ OR unixepoch(?) - unixepoch(w.checked_at) >= w.interval_s)
152
+ ORDER BY expired DESC, w.checked_at IS NOT NULL, w.checked_at,
153
+ n.external_id
154
+ LIMIT ?`,
155
+ [now, now, now, limit]
156
+ )
157
+ .map((row) => ({
158
+ node: String(row.node),
159
+ repo: String(row.repo),
160
+ prNumber: Number(row.pr_number),
161
+ // The column is TEXT, so sqlite hands back a string or null; the
162
+ // typing is `unknown` and the guard is what narrows it.
163
+ snapshot:
164
+ typeof row.snapshot === 'string'
165
+ ? (JSON.parse(row.snapshot) as PrSnapshot)
166
+ : null,
167
+ createdAt: String(row.created_at),
168
+ expired: Number(row.expired) === 1,
169
+ }));
170
+ }
171
+
172
+ /**
173
+ * Record one poll's snapshot and the events it produced, and fire the watch
174
+ * if the poll says so — all in one transaction. Splitting them lets a crash
175
+ * land events for a wait that never fired, which the next tick would then
176
+ * re-derive from the same unchanged snapshot and record a second time.
177
+ *
178
+ * `expires_at` belongs to the wait, not to the observation, so it is not
179
+ * written here: a poll that extends the deadline makes it unreachable for
180
+ * exactly the quiet PRs it exists to rescue.
181
+ *
182
+ * A row replaced mid-poll (`createdAt` differs) is left alone, events and
183
+ * all: the observation belongs to a wait that no longer exists.
184
+ */
185
+ async observe(input: {
186
+ node: string;
187
+ snapshot: PrSnapshot;
188
+ at: string;
189
+ createdAt: string;
190
+ fire: boolean;
191
+ intervalSeconds: number;
192
+ events: readonly Observation[];
193
+ }): Promise<'recorded' | 'fired' | 'stale'> {
194
+ assertInstant(input.at, 'at');
195
+ return this.#db.transaction(() => {
196
+ const row = this.#db.get(
197
+ `SELECT w.node_id, w.session_id FROM watch w
198
+ JOIN node n ON n.id = w.node_id
199
+ WHERE n.external_id = ? AND w.state = 'watching' AND w.created_at = ?`,
200
+ [input.node, input.createdAt]
201
+ );
202
+ if (row === undefined) return 'stale';
203
+ const nodeId = Number(row.node_id);
204
+ for (const event of input.events) {
205
+ this.#db.run(
206
+ `INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
207
+ VALUES (?, ?, ?, ?, ?, ?)`,
208
+ [
209
+ nodeId,
210
+ event.kind,
211
+ event.summary,
212
+ JSON.stringify(event.meta),
213
+ typeof row.session_id === 'string' ? row.session_id : null,
214
+ input.at,
215
+ ]
216
+ );
217
+ }
218
+ this.#db.run(
219
+ `UPDATE watch SET snapshot = ?, checked_at = ?, state = ?,
220
+ interval_s = ?
221
+ WHERE node_id = ?`,
222
+ [
223
+ JSON.stringify(input.snapshot),
224
+ input.at,
225
+ input.fire ? 'fired' : 'watching',
226
+ input.intervalSeconds,
227
+ nodeId,
228
+ ]
229
+ );
230
+ return input.fire ? 'fired' : 'recorded';
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Fire a watch outright (expiry); same generation guard as `observe`.
236
+ *
237
+ * The `watch_expired` event is what makes the deadline mean anything: a
238
+ * fired row is no longer polled, and a yielded worker whose session is live
239
+ * keeps the item out of the queue, so a silent fire strands it. The event
240
+ * carries the watch's session, which routes it to that worker.
241
+ *
242
+ * A parked item is not fired at all: any event revives it, and a deadline is
243
+ * not the answer a park waits for. The guard sits on the generation read, so
244
+ * a park landing between `due` and here leaves the row watching rather than
245
+ * fired-with-no-event — which is neither polled nor revivable.
246
+ */
247
+ async fire(
248
+ node: string,
249
+ at: string,
250
+ createdAt: string
251
+ ): Promise<'fired' | 'stale'> {
252
+ assertInstant(at, 'at');
253
+ return this.#db.transaction(() => {
254
+ const row = this.#db.get(
255
+ `SELECT w.node_id, w.session_id FROM watch w
256
+ JOIN node n ON n.id = w.node_id
257
+ WHERE n.external_id = ? AND w.state = 'watching' AND w.created_at = ?
258
+ AND NOT EXISTS (SELECT 1 FROM outcome o WHERE o.node_id = w.node_id)`,
259
+ [node, createdAt]
260
+ );
261
+ if (row === undefined) return 'stale';
262
+ const nodeId = Number(row.node_id);
263
+ this.#db.run(
264
+ `UPDATE watch SET state = 'fired', checked_at = ? WHERE node_id = ?`,
265
+ [at, nodeId]
266
+ );
267
+ this.#db.run(
268
+ `INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
269
+ VALUES (?, 'watch_expired', ?, '{}', ?, ?)`,
270
+ [
271
+ nodeId,
272
+ 'The watch reached its deadline with nothing changed on the forge. Look for a signal the snapshot cannot see.',
273
+ typeof row.session_id === 'string' ? row.session_id : null,
274
+ at,
275
+ ]
276
+ );
277
+ return 'fired';
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Push a failed poll's retry out by one interval; same generation guard as
283
+ * `observe`, so a failure observed against a replaced watch cannot delay
284
+ * the replacement's first poll.
285
+ */
286
+ async touch(node: string, at: string, createdAt: string): Promise<void> {
287
+ assertInstant(at, 'at');
288
+ this.#db.run(
289
+ `UPDATE watch SET checked_at = ?
290
+ WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
291
+ AND created_at = ?`,
292
+ [at, node, createdAt]
293
+ );
294
+ }
295
+
296
+ /** The last snapshot a poll stored for a node, or null. */
297
+ async latestSnapshot(node: string): Promise<PrSnapshot | null> {
298
+ const row = this.#db.get(
299
+ `SELECT w.snapshot FROM watch w
300
+ JOIN node n ON n.id = w.node_id
301
+ WHERE n.external_id = ?`,
302
+ [node]
303
+ );
304
+ return typeof row?.snapshot === 'string'
305
+ ? (JSON.parse(row.snapshot) as PrSnapshot)
306
+ : null;
307
+ }
308
+
309
+ async clear(node: string): Promise<boolean> {
310
+ return (
311
+ this.#db.run(
312
+ `DELETE FROM watch
313
+ WHERE node_id = (SELECT id FROM node WHERE external_id = ?)`,
314
+ [node]
315
+ ) > 0
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Open a watch on every PR item that has a PR and is not already watched and
321
+ * has not concluded. A PR moves whether or not a worker asked anyone to
322
+ * look, and an unwatched item is exactly the one whose change goes
323
+ * unnoticed.
324
+ *
325
+ * A `human-blocked` outcome counts as unconcluded: the item is waiting on a
326
+ * person, and the answer usually arrives on the PR. This is also what picks
327
+ * up an item parked before the watch survived its report — without it, a
328
+ * park that predates that behaviour stays unwatched for good.
329
+ */
330
+ async ensureForLiveItems(at: string, expirySeconds: number): Promise<number> {
331
+ assertInstant(at, 'at');
332
+ const expiresAt = new Date(
333
+ Date.parse(at) + expirySeconds * 1_000
334
+ ).toISOString();
335
+ return this.#db.run(
336
+ `INSERT INTO watch (node_id, state, snapshot, interval_s, session_id, created_at, expires_at)
337
+ SELECT pr.node_id, 'watching', NULL, 60, NULL, ?, ?
338
+ FROM pr
339
+ LEFT JOIN watch w ON w.node_id = pr.node_id
340
+ LEFT JOIN outcome o ON o.node_id = pr.node_id
341
+ WHERE pr.repo IS NOT NULL AND pr.pr_number IS NOT NULL
342
+ AND w.node_id IS NULL
343
+ AND (o.node_id IS NULL OR o.outcome = 'human-blocked')`,
344
+ [at, expiresAt]
345
+ );
346
+ }
347
+
348
+ async get(node: string): Promise<{state: 'watching' | 'fired'} | null> {
349
+ const row = this.#db.get(
350
+ `SELECT w.state FROM watch w
351
+ JOIN node n ON n.id = w.node_id
352
+ WHERE n.external_id = ?`,
353
+ [node]
354
+ );
355
+ if (row === undefined) return null;
356
+ return {state: row.state as 'watching' | 'fired'};
357
+ }
358
+ }
359
+
360
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,121 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {assertInstant} from '../db/time.mts';
3
+ import {DataError, ensure} from '../errors/index.mts';
4
+ import {findNode} from './materialize.mts';
5
+
6
+ /* eslint-disable @typescript-eslint/require-await --
7
+ * Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
8
+
9
+ /**
10
+ * Where a node's worker can be reached. The orchestrate session records the
11
+ * agent ref it got back from a launch; the tick stamps it onto events for the
12
+ * node so the session can relay instead of cold-starting a resume pass.
13
+ *
14
+ * A row lives from launch to outcome — not to yield. A yielded worker has
15
+ * returned but is resumable with its context intact, and waking it with the
16
+ * event that ends its wait is the whole point of routing. Death is covered
17
+ * twice over: the row cascades with its session, and a stale ref relayed to a
18
+ * gone agent is a no-op the resume pass then catches.
19
+ */
20
+ export class WorkerStore {
21
+ readonly #db: Database;
22
+
23
+ constructor(db: Database) {
24
+ this.#db = db;
25
+ }
26
+
27
+ async set(input: {
28
+ node: string;
29
+ session: string;
30
+ agentRef: string;
31
+ at: string;
32
+ }): Promise<void> {
33
+ assertInstant(input.at, 'at');
34
+ const agentRef = input.agentRef.trim();
35
+ ensure(
36
+ agentRef !== '',
37
+ () =>
38
+ new DataError('an empty agent ref is not an address', {
39
+ hint: 'pass the ref the launch returned.',
40
+ })
41
+ );
42
+ await this.#db.transaction(() => {
43
+ const node = findNode(this.#db, input.node);
44
+ ensure(
45
+ node !== null,
46
+ () =>
47
+ new DataError(`no node "${input.node}" to register a worker on`, {
48
+ hint: 'a worker is recorded for a node the graph already holds.',
49
+ })
50
+ );
51
+ // The address belongs to a dispatched worker, so the recorder must
52
+ // still hold the claim its launch took. This is also what closes the
53
+ // fast-worker race: an outcome recorded before the address deletes the
54
+ // claim, and the late `worker set` is then refused instead of
55
+ // recreating a row for an agent that already finished.
56
+ const claim = this.#db.get(
57
+ 'SELECT session_id FROM claim WHERE node_id = ?',
58
+ [node.id]
59
+ );
60
+ ensure(
61
+ claim?.session_id === input.session,
62
+ () =>
63
+ new DataError(
64
+ `this session holds no claim on "${input.node}", so there is no dispatched worker to address`,
65
+ {
66
+ hint: 'record the address right after the launch, before anything else; if the worker already reported, there is nothing to route to.',
67
+ }
68
+ )
69
+ );
70
+ this.#db.run(
71
+ `INSERT INTO worker (node_id, session_id, agent_ref, launched_at)
72
+ VALUES (?, ?, ?, ?)
73
+ ON CONFLICT(node_id) DO UPDATE SET
74
+ session_id = excluded.session_id,
75
+ agent_ref = excluded.agent_ref,
76
+ launched_at = excluded.launched_at`,
77
+ [node.id, input.session, agentRef, input.at]
78
+ );
79
+ });
80
+ }
81
+
82
+ /** The agent ref holding a node, for this session only: another session's
83
+ * worker is not addressable from here. */
84
+ async refFor(node: string, session: string): Promise<string | null> {
85
+ const row = this.#db.get(
86
+ `SELECT w.agent_ref FROM worker w
87
+ JOIN node n ON n.id = w.node_id
88
+ WHERE n.external_id = ? AND w.session_id = ?`,
89
+ [node, session]
90
+ );
91
+ return typeof row?.agent_ref === 'string' ? row.agent_ref : null;
92
+ }
93
+
94
+ /**
95
+ * Hand a node from warm relay to cold recovery: drop the caller's own
96
+ * address and release its claim in one transaction. While either existed
97
+ * the item could not queue; with both gone the scheduler re-serves it as a
98
+ * `resume` pass. Scoped to the owning session — another session's address
99
+ * is not this caller's to revoke.
100
+ */
101
+ async remove(node: string, session: string): Promise<boolean> {
102
+ return this.#db.transaction(() => {
103
+ const removed = this.#db.run(
104
+ `DELETE FROM worker
105
+ WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
106
+ AND session_id = ?`,
107
+ [node, session]
108
+ );
109
+ if (removed === 0) return false;
110
+ this.#db.run(
111
+ `DELETE FROM claim
112
+ WHERE node_id = (SELECT id FROM node WHERE external_id = ?)
113
+ AND session_id = ?`,
114
+ [node, session]
115
+ );
116
+ return true;
117
+ });
118
+ }
119
+ }
120
+
121
+ /* eslint-enable @typescript-eslint/require-await */
@@ -0,0 +1,151 @@
1
+ import {execFile} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+
4
+ import {withDatabase} from '../db/index.mts';
5
+ import {nowIso} from '../db/time.mts';
6
+ import type {Logger} from '../logger/index.mts';
7
+ import {PrStore} from '../stores/index.mts';
8
+ import {findNode} from '../stores/materialize.mts';
9
+
10
+ const run = promisify(execFile);
11
+
12
+ export interface OpenPr {
13
+ readonly number: number;
14
+ readonly headRefName: string;
15
+ }
16
+
17
+ export type PrLister = (repo: string) => Promise<OpenPr[]>;
18
+
19
+ /** Open PRs authored by this identity, via gh. */
20
+ export const githubLister: PrLister = async (repo) => {
21
+ const {stdout} = await run(
22
+ 'gh',
23
+ [
24
+ 'pr',
25
+ 'list',
26
+ '--repo',
27
+ repo,
28
+ '--author',
29
+ '@me',
30
+ '--state',
31
+ 'open',
32
+ '--json',
33
+ 'number,headRefName',
34
+ ],
35
+ {timeout: 30_000, maxBuffer: 8 * 1024 * 1024}
36
+ );
37
+ return JSON.parse(stdout) as OpenPr[];
38
+ };
39
+
40
+ /**
41
+ * Adopt agent-authored PRs the graph does not know.
42
+ *
43
+ * Registered PR items are runtime state: a database rebuild loses them, and a
44
+ * worker crash can leave a PR open that nothing ever registered. An orphaned
45
+ * PR has no watch and no worker, so it goes stale silently. This runs on the
46
+ * tick cadence rather than as a one-off reconciliation: every open PR this
47
+ * identity authored, in every repo the graph already knows, gets a PR item —
48
+ * `origin adopted`, ticket-linked when the branch leads with a ticket id the
49
+ * graph holds — and the existing machinery watches and schedules it like
50
+ * anything else.
51
+ *
52
+ * Repos come from existing PR items, so adoption never reaches into a repo
53
+ * the project has not already touched.
54
+ */
55
+ export async function adoptOrphans(
56
+ env: NodeJS.ProcessEnv,
57
+ opts: {
58
+ list?: PrLister;
59
+ dbPath?: string | undefined;
60
+ log?: Logger | undefined;
61
+ } = {}
62
+ ): Promise<number> {
63
+ const list = opts.list ?? githubLister;
64
+ return withDatabase(opts.dbPath, env, async (db) => {
65
+ const prs = new PrStore(db);
66
+ const repos = db
67
+ .all('SELECT DISTINCT repo FROM pr WHERE repo IS NOT NULL')
68
+ .map((row) => String(row.repo));
69
+ let adopted = 0;
70
+ for (const repo of repos) {
71
+ let open: OpenPr[];
72
+ try {
73
+ open = await list(repo);
74
+ } catch (error) {
75
+ opts.log?.warn('adoption listing failed', {
76
+ repo,
77
+ error: error instanceof Error ? error.message : String(error),
78
+ });
79
+ continue;
80
+ }
81
+ // What "known" means, and why numbers and branches are scoped
82
+ // differently. A number is a PR's permanent identity: it is never reused
83
+ // for a different PR, and re-adopting one can never help — upsert only
84
+ // rewrites the pr row, it does not clear a stale outcome — so every
85
+ // numbered row contributes its number, concluded or not. Re-adopting a
86
+ // reopened-but-still-concluded PR would just churn a phantom adoption
87
+ // each tick without ever making it live again.
88
+ //
89
+ // A branch is not an identity key. Two open PRs can share a head name
90
+ // (same-named branches across forks), so a numbered row's branch must
91
+ // not suppress a distinct fork PR that reuses it — the number already
92
+ // guards that row. Only a row with no number yet contributes its branch:
93
+ // a ticket-worker's registration made before its PR opens, which
94
+ // adoption must not race. And only while that row is live — once it
95
+ // concludes, its branch is free for later work to reuse, and a stale row
96
+ // must not block adopting the new PR that inherits the name.
97
+ const known = new Set<string>();
98
+ for (const row of db.all(
99
+ `SELECT p.branch AS branch, p.pr_number AS pr_number,
100
+ o.node_id AS outcome_node
101
+ FROM pr p
102
+ LEFT JOIN outcome o ON o.node_id = p.node_id
103
+ WHERE p.repo = ?`,
104
+ [repo]
105
+ )) {
106
+ if (typeof row.pr_number === 'number')
107
+ known.add(`#${String(row.pr_number)}`);
108
+ else if (
109
+ row.outcome_node === null &&
110
+ typeof row.branch === 'string' &&
111
+ row.branch !== ''
112
+ )
113
+ known.add(row.branch);
114
+ }
115
+ for (const pr of open) {
116
+ // A listing with no number or head ref is malformed; skip it rather
117
+ // than register a nameless item.
118
+ if (!Number.isInteger(pr.number) || pr.headRefName === '') continue;
119
+ if (known.has(`#${String(pr.number)}`) || known.has(pr.headRefName)) {
120
+ continue;
121
+ }
122
+ // Remember the number so the same PR is not re-adopted; not the head
123
+ // name, since a different fork PR can share it and still deserves a
124
+ // row of its own.
125
+ known.add(`#${String(pr.number)}`);
126
+ // A branch led by a ticket id the graph holds is that ticket's work.
127
+ const match = /^([a-z]+-\d+)/iu.exec(pr.headRefName);
128
+ const ticketId = match?.[1]?.toUpperCase();
129
+ const ticket =
130
+ ticketId !== undefined && findNode(db, ticketId)?.kind === 'ticket'
131
+ ? ticketId
132
+ : null;
133
+ await prs.upsertPr({
134
+ id: `${repo}#${String(pr.number)}`,
135
+ ticket,
136
+ origin: 'adopted',
137
+ repo,
138
+ prNumber: pr.number,
139
+ url: `https://github.com/${repo}/pull/${String(pr.number)}`,
140
+ branch: pr.headRefName,
141
+ title: `Adopted open PR #${String(pr.number)} (${pr.headRefName})`,
142
+ injected: false,
143
+ priority: null,
144
+ updatedAt: nowIso(),
145
+ });
146
+ adopted += 1;
147
+ }
148
+ }
149
+ return adopted;
150
+ });
151
+ }
@@ -0,0 +1,48 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {WatchStore} from '../stores/index.mts';
3
+ import type {Logger} from '../logger/index.mts';
4
+ import {cadenceFor, EXPIRY_SECONDS} from './cadence.mts';
5
+ import type {PrSnapshot, Snapshotter} from './snapshot.mts';
6
+
7
+ /**
8
+ * Hand an item back to the server: record the PR as it stands now and release
9
+ * the worker's claim, in one transaction.
10
+ *
11
+ * The baseline is what the next poll diffs against. A failed read degrades to
12
+ * priming on the first successful poll, which widens the window in which a
13
+ * change goes unreported — taken over failing the handoff.
14
+ */
15
+ export async function armWatch(
16
+ db: Database,
17
+ input: {
18
+ node: string;
19
+ repo: string;
20
+ prNumber: number;
21
+ at: string;
22
+ snapshot: Snapshotter;
23
+ session: string | null;
24
+ releaseClaimFor?: string | null;
25
+ log?: Logger | undefined;
26
+ }
27
+ ): Promise<void> {
28
+ let baseline: PrSnapshot | null = null;
29
+ try {
30
+ baseline = await input.snapshot(input.repo, input.prNumber);
31
+ } catch (error) {
32
+ input.log?.warn('watch armed without a baseline snapshot', {
33
+ node: input.node,
34
+ error: error instanceof Error ? error.message : String(error),
35
+ });
36
+ }
37
+ await new WatchStore(db).set({
38
+ node: input.node,
39
+ intervalSeconds: cadenceFor(baseline),
40
+ at: input.at,
41
+ expiresAt: new Date(
42
+ Date.parse(input.at) + EXPIRY_SECONDS * 1_000
43
+ ).toISOString(),
44
+ snapshot: baseline,
45
+ session: input.session,
46
+ releaseClaimFor: input.releaseClaimFor ?? null,
47
+ });
48
+ }