@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,410 @@
1
+ import type {SqlValue} from '../db/database.mts';
2
+ import {nowIso} from '../db/time.mts';
3
+ import type {DeriveOptions} from './types.mts';
4
+
5
+ /** A session heartbeat older than this makes its claims stale. */
6
+ export const DEFAULT_STALE_AFTER_SECONDS = 300;
7
+
8
+ /**
9
+ * The graph reasoning, as one CTE pipeline over the stores' tables. Every
10
+ * query shares this prefix, so blocking, ranking, gating, and admission always
11
+ * agree.
12
+ *
13
+ * Parameters, in order: now (RFC 3339), session staleness (seconds), and an
14
+ * optional project filter (external id, or NULL for all).
15
+ *
16
+ * The pieces:
17
+ *
18
+ * - `membership` / `seq_edge` / `blocking_edge` — edge semantics by endpoint
19
+ * kind: ticket → milestone is membership, milestone → milestone is
20
+ * sequencing, everything else (including an edge touching a placeholder)
21
+ * is plain blocking, the safe default for an endpoint nobody has fetched.
22
+ * - `anc` — the transitive blocker closure. Recursion continues only through
23
+ * an *unresolved* ticket or PR: a `verified`/`canceled` ticket does not
24
+ * block, and cancellation releases downstream work. A placeholder has no
25
+ * status, so it reads as unresolved and holds its dependents. Milestone
26
+ * ancestors are kept but filtered by openness in `blocker_view` — an open
27
+ * milestone does not block, a closed one does. A milestone's own readiness
28
+ * (`dep_blocked`) counts only non-milestone ancestors, so openness never
29
+ * depends on itself.
30
+ * - `open_dep` — every item held by something unfinished, whether a blocker or
31
+ * a closed milestone gate. One definition serving two readers: it is what
32
+ * makes an item read `blocked`, and what `queued` consults before handing
33
+ * out a pass that launches implementation work.
34
+ * - `descendant`/`fanout` — transitive descendant counts, resolved or not: how
35
+ * much work an item gates, a ranking signal rather than a blocking one.
36
+ * - `live_claim` — a claim is live while its session's heartbeat is fresh;
37
+ * workers hold no heartbeat of their own, so a dead server's claims go
38
+ * stale together.
39
+ * - `review_valid` — a recorded review counts only while it covers exactly
40
+ * the current member set and no member moved after it was recorded.
41
+ * - `milestone_state` / `milestone_open` / `gate` — a milestone is ready when
42
+ * it has members, all are resolved, and none carries an unresolved
43
+ * dependency; open once its review is also valid. A member of a milestone is
44
+ * gated while any sequencing ancestor of that milestone is not open.
45
+ * Readiness never looks at sequencing, which keeps milestone gating acyclic.
46
+ * - `item` — the dispatchable universe: every ticket, plus every PR item. A
47
+ * bare PR (no `ticket_id`) is prompt work; a ticket-attached one is a unit
48
+ * of implementation its ticket-worker registered, inheriting the ticket's
49
+ * project. A PR item has no status; its lifecycle is its outcome row.
50
+ * - `classified` — the derived classification, highest precedence first:
51
+ * resolved → in-flight (started status, or a live claim) → dormant
52
+ * (backlog) → blocked → human-blocked → available. A PR item whose outcome
53
+ * is `human-blocked` classifies human-blocked directly: it has no status to
54
+ * park, and the outcome is its worker saying "waiting on an operator".
55
+ * - A live `worker` row is an address: the launching session can still wake
56
+ * that agent by relaying the event. While one exists the item never queues
57
+ * as `resume` — warm relay and cold re-dispatch are competing recoveries,
58
+ * and racing them puts two agents on one node. `dispatch worker rm` is the
59
+ * explicit handover from the first to the second.
60
+ * - A `watch` row is a worker's PR wait handed to the server: the item reads
61
+ * as in-flight while it exists, is never queued while `watching`, and once
62
+ * the server fires it (the PR changed in a way the worker would act on)
63
+ * falls through to the `resume` rule. A `human-blocked` PR keeps its watch,
64
+ * so the same row is also how a parked item hears its answer.
65
+ * - `queued` — what the scheduler may hand out, and as which pass. An
66
+ * `available` item with no outcome row is dispatchable as-is; a started item
67
+ * with no live claim and no outcome is a crashed run — its claim is stale or
68
+ * was already swept away with its session — re-served as `resume`; a
69
+ * recorded outcome re-admits the item for exactly one follow-up
70
+ * pass — an `adopted` PR is an existing PR, so `resume` (re-derive from the
71
+ * forge), never `available` (implement from a title); `verify` for a
72
+ * delivered ticket (a bare PR is done at delivered),
73
+ * `finalize` for a decomposed parent whose subtasks all resolved, `retry`
74
+ * for a retryable failure, and `resume` for a `human-blocked` item the
75
+ * world has moved on since. That last one reads a different signal per
76
+ * kind, because a park is answered wherever the item lives: a ticket by a
77
+ * tracker update postdating the report — the ticket now reads available, so
78
+ * the human unparked it, and the timestamp guard keeps a stale local row
79
+ * (ingest lagging the worker's own park transition) from passing for a
80
+ * response — and a PR by its watch firing on an observation, which is the
81
+ * forge saying someone who is not this agent touched it. Expiry alone does
82
+ * not qualify: it fires with no events, and a deadline is not an answer.
83
+ * Revive defers to a live worker like every other resume: a park that was
84
+ * already revived once leaves its outcome standing until the next report,
85
+ * so without that guard a yield from the resumed worker would read as a
86
+ * second park and race a warm relay. The branch is deliberately ahead of
87
+ * the human-blocked guard; behind it, it is unreachable for a PR item,
88
+ * whose classification is derived from that very outcome. Nothing
89
+ * human-owned, still parked, resolved, or held by a live claim is ever
90
+ * handed out.
91
+ *
92
+ * Every pass that puts an agent on implementation — `resume`, `finalize`,
93
+ * `retry` — additionally requires `dependency_open = 0`. The classification
94
+ * cannot carry that on its own: a started status, a live claim, or a watch
95
+ * all outrank `blocked`, so an item can be genuinely in flight *and*
96
+ * genuinely held, and only admission sees both. `verify` is exempt — it
97
+ * confirms work that already landed, which an open blocker does not
98
+ * invalidate.
99
+ */
100
+ export const PREFIX = `
101
+ WITH RECURSIVE
102
+ p(now_ts, stale_s, project_filter) AS (SELECT ?, ?, ?),
103
+ mile(id) AS (SELECT node_id FROM milestone),
104
+ resolved_leaf(id) AS (
105
+ SELECT node_id FROM ticket WHERE status IN ('verified','canceled')
106
+ UNION
107
+ SELECT o.node_id FROM outcome o
108
+ JOIN pr ON pr.node_id = o.node_id
109
+ WHERE o.outcome IN ('delivered','verified','canceled')
110
+ ),
111
+ membership(member_id, milestone_id) AS (
112
+ SELECT e.blocker, e.blocked
113
+ FROM edge e
114
+ JOIN node nb ON nb.id = e.blocker
115
+ JOIN node nd ON nd.id = e.blocked
116
+ WHERE nb.kind = 'ticket' AND nd.kind = 'milestone'
117
+ ),
118
+ seq_edge(blocker, blocked) AS (
119
+ SELECT e.blocker, e.blocked
120
+ FROM edge e
121
+ JOIN node nb ON nb.id = e.blocker
122
+ JOIN node nd ON nd.id = e.blocked
123
+ WHERE nb.kind = 'milestone' AND nd.kind = 'milestone'
124
+ ),
125
+ blocking_edge(blocker, blocked) AS (
126
+ SELECT e.blocker, e.blocked
127
+ FROM edge e
128
+ JOIN node nb ON nb.id = e.blocker
129
+ JOIN node nd ON nd.id = e.blocked
130
+ WHERE NOT (nb.kind = 'ticket' AND nd.kind = 'milestone')
131
+ AND NOT (nb.kind = 'milestone' AND nd.kind = 'milestone')
132
+ ),
133
+ anc(target, id) AS (
134
+ SELECT blocked, blocker FROM blocking_edge
135
+ UNION
136
+ SELECT a.target, be.blocker
137
+ FROM anc a
138
+ JOIN blocking_edge be ON be.blocked = a.id
139
+ WHERE a.id NOT IN (SELECT id FROM resolved_leaf)
140
+ ),
141
+ unresolved_anc(target, id) AS (
142
+ SELECT target, id FROM anc WHERE id NOT IN (SELECT id FROM resolved_leaf)
143
+ ),
144
+ descendant(target, id) AS (
145
+ SELECT blocker, blocked FROM blocking_edge
146
+ UNION
147
+ SELECT d.target, be.blocked
148
+ FROM descendant d
149
+ JOIN blocking_edge be ON be.blocker = d.id
150
+ ),
151
+ fanout(id, n) AS (SELECT target, COUNT(*) FROM descendant GROUP BY target),
152
+ live_session(id) AS (
153
+ SELECT s.id FROM session s, p
154
+ WHERE unixepoch(p.now_ts) - unixepoch(s.heartbeat_at) <= p.stale_s
155
+ ),
156
+ live_claim(node_id) AS (
157
+ SELECT c.node_id FROM claim c JOIN live_session ls ON ls.id = c.session_id
158
+ ),
159
+ member(milestone_id, node_id, status, updated_at, external_id) AS (
160
+ SELECT mb.milestone_id, mb.member_id, t.status, t.updated_at, n.external_id
161
+ FROM membership mb
162
+ JOIN ticket t ON t.node_id = mb.member_id
163
+ JOIN node n ON n.id = mb.member_id
164
+ ),
165
+ review_valid(milestone_id) AS (
166
+ SELECT r.milestone_id
167
+ FROM review r
168
+ WHERE NOT EXISTS (
169
+ SELECT 1 FROM member m
170
+ WHERE m.milestone_id = r.milestone_id
171
+ AND m.external_id NOT IN (
172
+ SELECT member_external_id FROM review_member rm
173
+ WHERE rm.milestone_id = r.milestone_id
174
+ )
175
+ )
176
+ AND NOT EXISTS (
177
+ SELECT 1 FROM review_member rm
178
+ WHERE rm.milestone_id = r.milestone_id
179
+ AND rm.member_external_id NOT IN (
180
+ SELECT m.external_id FROM member m
181
+ WHERE m.milestone_id = r.milestone_id
182
+ )
183
+ )
184
+ AND NOT EXISTS (
185
+ SELECT 1 FROM member m
186
+ WHERE m.milestone_id = r.milestone_id
187
+ AND m.updated_at IS NOT NULL
188
+ AND unixepoch(m.updated_at) > unixepoch(r.recorded_at)
189
+ )
190
+ ),
191
+ milestone_state(id, member_count, open_count, dep_blocked, review_recorded) AS (
192
+ SELECT
193
+ mi.node_id,
194
+ COUNT(mem.node_id),
195
+ COALESCE(SUM(CASE WHEN mem.status NOT IN ('verified','canceled') THEN 1 ELSE 0 END), 0),
196
+ COALESCE(SUM(CASE WHEN EXISTS (
197
+ SELECT 1 FROM unresolved_anc ua
198
+ WHERE ua.target = mem.node_id AND ua.id NOT IN (SELECT id FROM mile)
199
+ ) THEN 1 ELSE 0 END), 0),
200
+ EXISTS (SELECT 1 FROM review_valid rv WHERE rv.milestone_id = mi.node_id)
201
+ FROM milestone mi
202
+ LEFT JOIN member mem ON mem.milestone_id = mi.node_id
203
+ GROUP BY mi.node_id
204
+ ),
205
+ milestone_open(id) AS (
206
+ SELECT id FROM milestone_state
207
+ WHERE member_count > 0 AND open_count = 0 AND dep_blocked = 0 AND review_recorded
208
+ ),
209
+ blocker_view(target, id) AS (
210
+ SELECT ua.target, ua.id FROM unresolved_anc ua
211
+ WHERE ua.id NOT IN (SELECT id FROM mile)
212
+ UNION
213
+ SELECT ua.target, ua.id FROM unresolved_anc ua
214
+ WHERE ua.id IN (SELECT id FROM mile)
215
+ AND ua.id NOT IN (SELECT id FROM milestone_open)
216
+ ),
217
+ m_anc(target, id) AS (
218
+ SELECT blocked, blocker FROM seq_edge
219
+ UNION
220
+ SELECT a.target, se.blocker
221
+ FROM m_anc a
222
+ JOIN seq_edge se ON se.blocked = a.id
223
+ ),
224
+ gate(item_id, milestone_id) AS (
225
+ SELECT mb.member_id, a.id
226
+ FROM membership mb
227
+ JOIN m_anc a ON a.target = mb.milestone_id
228
+ JOIN milestone_state ms ON ms.id = a.id
229
+ WHERE ms.member_count > 0 AND a.id NOT IN (SELECT id FROM milestone_open)
230
+ ),
231
+ open_dep(id) AS (
232
+ SELECT target FROM blocker_view
233
+ UNION
234
+ SELECT item_id FROM gate
235
+ ),
236
+ item AS (
237
+ SELECT
238
+ t.node_id,
239
+ n.external_id AS id,
240
+ 'ticket' AS kind,
241
+ np.external_id AS project,
242
+ NULL AS ticket,
243
+ t.url,
244
+ t.title,
245
+ t.status,
246
+ t.target_kind,
247
+ t.requires_human,
248
+ t.injected,
249
+ t.priority,
250
+ t.branch_hint,
251
+ t.labels,
252
+ t.updated_at,
253
+ NULL AS origin,
254
+ NULL AS repo,
255
+ NULL AS pr_number
256
+ FROM ticket t
257
+ JOIN node n ON n.id = t.node_id
258
+ JOIN node np ON np.id = t.project_id
259
+ UNION ALL
260
+ SELECT
261
+ pr.node_id,
262
+ n.external_id,
263
+ 'pr',
264
+ np.external_id,
265
+ nt.external_id,
266
+ pr.url,
267
+ pr.title,
268
+ NULL,
269
+ 'pr',
270
+ 0,
271
+ pr.injected,
272
+ pr.priority,
273
+ pr.branch,
274
+ '[]',
275
+ pr.updated_at,
276
+ pr.origin,
277
+ pr.repo,
278
+ pr.pr_number
279
+ FROM pr
280
+ JOIN node n ON n.id = pr.node_id
281
+ LEFT JOIN node nt ON nt.id = pr.ticket_id
282
+ LEFT JOIN ticket t2 ON t2.node_id = pr.ticket_id
283
+ LEFT JOIN node np ON np.id = t2.project_id
284
+ ),
285
+ classified AS (
286
+ SELECT
287
+ i.*,
288
+ c.session_id AS claim_session,
289
+ c.actor AS claim_actor,
290
+ c.worktree AS claim_worktree,
291
+ c.branch AS claim_branch,
292
+ c.claimed_at AS claim_claimed_at,
293
+ CASE
294
+ WHEN c.node_id IS NULL THEN NULL
295
+ WHEN lc.node_id IS NOT NULL THEN 1
296
+ ELSE 0
297
+ END AS claim_live,
298
+ o.outcome AS outcome,
299
+ o.retryable AS outcome_retryable,
300
+ o.detail AS outcome_detail,
301
+ o.recorded_at AS outcome_recorded_at,
302
+ (SELECT group_concat(nx.external_id, ',' ORDER BY nx.external_id)
303
+ FROM blocker_view bv JOIN node nx ON nx.id = bv.id
304
+ WHERE bv.target = i.node_id) AS blocked_by,
305
+ (SELECT group_concat(ng.external_id, ',' ORDER BY ng.external_id)
306
+ FROM gate g JOIN node ng ON ng.id = g.milestone_id
307
+ WHERE g.item_id = i.node_id) AS gated_by,
308
+ (SELECT group_concat(nm.external_id, ',' ORDER BY nm.external_id)
309
+ FROM membership mb JOIN node nm ON nm.id = mb.milestone_id
310
+ WHERE mb.member_id = i.node_id) AS milestones,
311
+ COALESCE(f.n, 0) AS fanout,
312
+ w.state AS watch_state,
313
+ EXISTS (SELECT 1 FROM pr_event pe WHERE pe.node_id = i.node_id)
314
+ AS events_observed,
315
+ lw.node_id IS NOT NULL AS worker_live,
316
+ i.node_id IN (SELECT id FROM open_dep) AS dependency_open,
317
+ CASE
318
+ WHEN i.kind = 'pr' AND o.outcome IN ('delivered', 'verified') THEN 'verified'
319
+ WHEN i.kind = 'pr' AND o.outcome = 'canceled' THEN 'canceled'
320
+ WHEN i.kind = 'pr' AND o.outcome = 'human-blocked' THEN 'human-blocked'
321
+ WHEN i.status = 'verified' THEN 'verified'
322
+ WHEN i.status = 'canceled' THEN 'canceled'
323
+ WHEN i.status IN ('in-progress','in-review','finished','delivered') THEN 'in-flight'
324
+ WHEN lc.node_id IS NOT NULL THEN 'in-flight'
325
+ WHEN w.node_id IS NOT NULL THEN 'in-flight'
326
+ WHEN i.status = 'backlog' THEN 'dormant'
327
+ WHEN i.node_id IN (SELECT id FROM open_dep) THEN 'blocked'
328
+ WHEN i.requires_human = 1
329
+ OR i.target_kind = 'human-only'
330
+ OR i.status IN ('paused', 'awaiting-external') THEN 'human-blocked'
331
+ WHEN i.kind = 'pr' OR i.status = 'available' THEN 'available'
332
+ ELSE 'dormant'
333
+ END AS classification
334
+ FROM item i
335
+ LEFT JOIN claim c ON c.node_id = i.node_id
336
+ LEFT JOIN live_claim lc ON lc.node_id = i.node_id
337
+ LEFT JOIN fanout f ON f.id = i.node_id
338
+ LEFT JOIN outcome o ON o.node_id = i.node_id
339
+ LEFT JOIN watch w ON w.node_id = i.node_id
340
+ LEFT JOIN (
341
+ SELECT wk.node_id FROM worker wk
342
+ JOIN live_session ls ON ls.id = wk.session_id
343
+ ) lw ON lw.node_id = i.node_id
344
+ ),
345
+ queued AS (
346
+ SELECT *,
347
+ CASE
348
+ WHEN watch_state = 'watching' THEN NULL
349
+ WHEN requires_human = 1
350
+ OR classification IN ('verified', 'canceled', 'dormant')
351
+ THEN NULL
352
+ WHEN outcome = 'human-blocked' AND (claim_live IS NULL OR claim_live = 0)
353
+ AND worker_live = 0
354
+ AND (
355
+ (kind = 'pr' AND watch_state = 'fired' AND events_observed)
356
+ OR (kind = 'ticket' AND classification = 'available'
357
+ AND updated_at IS NOT NULL
358
+ AND unixepoch(updated_at) > unixepoch(outcome_recorded_at))
359
+ ) THEN 'resume'
360
+ WHEN classification = 'human-blocked' THEN NULL
361
+ WHEN outcome IS NULL AND classification = 'available'
362
+ AND origin = 'adopted' THEN 'resume'
363
+ WHEN outcome IS NULL AND classification = 'available' THEN 'available'
364
+ WHEN outcome IS NULL AND (claim_live IS NULL OR claim_live = 0)
365
+ AND classification = 'in-flight'
366
+ AND worker_live = 0 AND dependency_open = 0 THEN 'resume'
367
+ WHEN outcome IS NULL OR claim_live = 1 THEN NULL
368
+ WHEN outcome = 'delivered' AND kind = 'ticket' THEN 'verify'
369
+ WHEN outcome = 'decomposed' AND dependency_open = 0 THEN 'finalize'
370
+ WHEN outcome = 'failed' AND outcome_retryable = 1
371
+ AND dependency_open = 0 THEN 'retry'
372
+ END AS pass
373
+ FROM classified
374
+ )
375
+ `;
376
+
377
+ export const PROJECT_FILTER =
378
+ '((SELECT project_filter FROM p) IS NULL OR project = (SELECT project_filter FROM p))';
379
+
380
+ /**
381
+ * The frontier's order is total and deterministic, so two runs over one graph
382
+ * always agree: injected work first, then priority (lower is more urgent,
383
+ * absent sorts last), then descendant fan-out (more downstream work unblocked
384
+ * first — the critical-path signal), then id. Milestone order is deliberately
385
+ * absent: sequencing is enforced by the gate, not the ranking.
386
+ */
387
+ export const RANK_ORDER =
388
+ 'ORDER BY injected DESC, (priority IS NULL) ASC, priority ASC, fanout DESC, id ASC';
389
+
390
+ /**
391
+ * The dispatch queue's order: injected work first (a runtime-injected ticket
392
+ * or bare PR goes to the head without preempting anything in flight), then the
393
+ * follow-up passes on work already invested in (resume, finalize, verify,
394
+ * retry), then the ranked available frontier.
395
+ */
396
+ export const QUEUE_ORDER = `ORDER BY
397
+ (pass = 'available' AND injected = 1) DESC,
398
+ CASE pass
399
+ WHEN 'resume' THEN 0 WHEN 'finalize' THEN 1 WHEN 'verify' THEN 2
400
+ WHEN 'retry' THEN 3 ELSE 4
401
+ END ASC,
402
+ (priority IS NULL) ASC, priority ASC, fanout DESC, id ASC`;
403
+
404
+ export function pipelineParams(options: DeriveOptions): SqlValue[] {
405
+ return [
406
+ options.now ?? nowIso(),
407
+ options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS,
408
+ options.project ?? null,
409
+ ];
410
+ }
@@ -0,0 +1,207 @@
1
+ /* eslint-disable @typescript-eslint/require-await --
2
+ * `node:sqlite` is synchronous; the async facade keeps call sites stable. */
3
+ import type {Database} from '../db/database.mts';
4
+ import {nowIso} from '../db/time.mts';
5
+ import {
6
+ DEFAULT_STALE_AFTER_SECONDS,
7
+ PREFIX,
8
+ PROJECT_FILTER,
9
+ QUEUE_ORDER,
10
+ RANK_ORDER,
11
+ pipelineParams,
12
+ } from './pipeline.mts';
13
+ import {integer, splitList, text, toClaim, toClassified} from './rows.mts';
14
+ import type {
15
+ ClassifiedItem,
16
+ DeriveOptions,
17
+ MilestoneState,
18
+ Pass,
19
+ QueueEntry,
20
+ } from './types.mts';
21
+
22
+ /** Every work item, classified, ordered by external id. */
23
+ export async function classifiedItems(
24
+ db: Database,
25
+ options: DeriveOptions = {}
26
+ ): Promise<ClassifiedItem[]> {
27
+ return db
28
+ .all(
29
+ `${PREFIX} SELECT * FROM classified WHERE ${PROJECT_FILTER} ORDER BY id`,
30
+ pipelineParams(options)
31
+ )
32
+ .map(toClassified);
33
+ }
34
+
35
+ /**
36
+ * The ranked available frontier, most urgent first. An item carrying an
37
+ * outcome row is excluded — its next dispatch is a pass, not fresh work.
38
+ */
39
+ export async function frontier(
40
+ db: Database,
41
+ options: DeriveOptions = {}
42
+ ): Promise<ClassifiedItem[]> {
43
+ return db
44
+ .all(
45
+ `${PREFIX} SELECT * FROM queued WHERE pass = 'available' AND ${PROJECT_FILTER} ${RANK_ORDER}`,
46
+ pipelineParams(options)
47
+ )
48
+ .map(toClassified);
49
+ }
50
+
51
+ /**
52
+ * Everything the scheduler may hand out right now, in dispatch order. The one
53
+ * read the server's fill step runs, so pick and admit always agree.
54
+ */
55
+ export async function dispatchQueue(
56
+ db: Database,
57
+ options: DeriveOptions = {}
58
+ ): Promise<QueueEntry[]> {
59
+ return db
60
+ .all(
61
+ `${PREFIX} SELECT * FROM queued WHERE pass IS NOT NULL AND ${PROJECT_FILTER} ${QUEUE_ORDER}`,
62
+ pipelineParams(options)
63
+ )
64
+ .map((row) => ({
65
+ entry: toClassified(row),
66
+ pass: row.pass === 'available' ? null : (text(row.pass) as Pass),
67
+ }));
68
+ }
69
+
70
+ /** What one PR item costs the repo it belongs to, right now. */
71
+ export interface RepoPrLoad {
72
+ node: string;
73
+ repo: string;
74
+ /** A PR is on the forge and still open, or a live worker is about to open one. */
75
+ open: boolean;
76
+ /** Checks are running, or a live worker is about to start some. */
77
+ building: boolean;
78
+ }
79
+
80
+ /**
81
+ * Per-repo load for the admission caps, read from the graph and the snapshots
82
+ * the watch poll already stored — no fetch of its own.
83
+ *
84
+ * A terminal outcome settles the item whatever the forge last said, so it
85
+ * counts for nothing. Without one, an item that names a PR number is open
86
+ * until a snapshot says otherwise: the poll has to have run at least once for
87
+ * a closed PR to stop counting, and the cap should hold rather than let go on
88
+ * an item nobody has looked at yet.
89
+ *
90
+ * A live claim counts as both. The work it names is a dispatched pr-worker,
91
+ * which will open a PR (if the item has none yet) and push to it — neither
92
+ * visible in a snapshot until it happens, so a cap reading only the forge
93
+ * would admit a second item on every tick until the first one's PR appeared.
94
+ * The claim is shared state, so this is also what keeps a second server on the
95
+ * same database from re-spending a slot this one just spent.
96
+ *
97
+ * `building` is deliberately not scoped to open PRs: a merged PR's trailing
98
+ * jobs hold CI concurrency exactly like any other, and its item stops counting
99
+ * as soon as its worker reports.
100
+ */
101
+ export async function repoPrLoad(
102
+ db: Database,
103
+ options: DeriveOptions = {}
104
+ ): Promise<RepoPrLoad[]> {
105
+ return db
106
+ .all(
107
+ `SELECT n.external_id AS node, pr.repo, pr.pr_number, o.outcome,
108
+ json_extract(w.snapshot, '$.state') AS pr_state,
109
+ json_extract(w.snapshot, '$.rollup') AS rollup,
110
+ lc.node_id IS NOT NULL AS claimed
111
+ FROM pr
112
+ JOIN node n ON n.id = pr.node_id
113
+ LEFT JOIN watch w ON w.node_id = pr.node_id
114
+ LEFT JOIN outcome o ON o.node_id = pr.node_id
115
+ LEFT JOIN (
116
+ SELECT c.node_id FROM claim c
117
+ JOIN session s ON s.id = c.session_id
118
+ WHERE unixepoch(?) - unixepoch(s.heartbeat_at) <= ?
119
+ ) lc ON lc.node_id = pr.node_id
120
+ WHERE pr.repo IS NOT NULL
121
+ ORDER BY n.external_id`,
122
+ [
123
+ options.now ?? nowIso(),
124
+ options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS,
125
+ ]
126
+ )
127
+ .map((row) => {
128
+ const settled = RESOLVED_OUTCOMES.has(text(row.outcome) ?? '');
129
+ const claimed = !settled && integer(row.claimed) === 1;
130
+ const state = text(row.pr_state);
131
+ const unopened = row.pr_number === null || row.pr_number === undefined;
132
+ return {
133
+ node: text(row.node) ?? '',
134
+ repo: text(row.repo) ?? '',
135
+ open: unopened
136
+ ? claimed
137
+ : !settled && (state === null || state === 'OPEN'),
138
+ building: claimed || (!settled && text(row.rollup) === 'PENDING'),
139
+ };
140
+ });
141
+ }
142
+
143
+ /** Outcomes that end a PR item's life, whatever the forge last reported. */
144
+ const RESOLVED_OUTCOMES: ReadonlySet<string> = new Set([
145
+ 'delivered',
146
+ 'verified',
147
+ 'canceled',
148
+ ]);
149
+
150
+ /** Every milestone's derived state, ordered by project then id. */
151
+ export async function milestoneStates(
152
+ db: Database,
153
+ options: DeriveOptions = {}
154
+ ): Promise<MilestoneState[]> {
155
+ const rows = db.all(
156
+ `${PREFIX}
157
+ SELECT
158
+ n.external_id AS id,
159
+ np.external_id AS project,
160
+ mi.name,
161
+ ms.member_count,
162
+ ms.open_count,
163
+ ms.dep_blocked,
164
+ ms.review_recorded,
165
+ ms.id IN (SELECT id FROM milestone_open) AS open,
166
+ c.session_id AS claim_session,
167
+ c.actor AS claim_actor,
168
+ c.worktree AS claim_worktree,
169
+ c.branch AS claim_branch,
170
+ c.claimed_at AS claim_claimed_at,
171
+ CASE
172
+ WHEN c.node_id IS NULL THEN NULL
173
+ WHEN lc.node_id IS NOT NULL THEN 1
174
+ ELSE 0
175
+ END AS claim_live,
176
+ (SELECT group_concat(mem.external_id, ',' ORDER BY mem.external_id)
177
+ FROM member mem WHERE mem.milestone_id = ms.id) AS members
178
+ FROM milestone_state ms
179
+ JOIN milestone mi ON mi.node_id = ms.id
180
+ JOIN node n ON n.id = ms.id
181
+ JOIN node np ON np.id = mi.project_id
182
+ LEFT JOIN claim c ON c.node_id = ms.id
183
+ LEFT JOIN live_claim lc ON lc.node_id = ms.id
184
+ WHERE ((SELECT project_filter FROM p) IS NULL
185
+ OR np.external_id = (SELECT project_filter FROM p))
186
+ ORDER BY project, id`,
187
+ pipelineParams(options)
188
+ );
189
+
190
+ return rows.map((row) => ({
191
+ id: text(row.id) ?? '',
192
+ project: text(row.project) ?? '',
193
+ name: text(row.name) ?? '',
194
+ members: splitList(row.members),
195
+ memberCount: integer(row.member_count),
196
+ openCount: integer(row.open_count),
197
+ readyForReview:
198
+ integer(row.member_count) > 0 &&
199
+ integer(row.open_count) === 0 &&
200
+ integer(row.dep_blocked) === 0,
201
+ reviewRecorded: integer(row.review_recorded) === 1,
202
+ open: integer(row.open) === 1,
203
+ claim: toClaim(row),
204
+ }));
205
+ }
206
+
207
+ /* eslint-enable @typescript-eslint/require-await */