@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.
- package/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- 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 */
|