@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,83 @@
1
+ import type {OutcomeKind, PrOrigin, Status, TargetKind} from './status.mts';
2
+
3
+ /** Every entity's `id` is its tracker/forge external id (e.g. `CLC-945`). */
4
+ export interface Project {
5
+ id: string;
6
+ name: string;
7
+ /** The tracker this project came from; null until a write names one. */
8
+ source: string | null;
9
+ }
10
+
11
+ export interface Milestone {
12
+ id: string;
13
+ project: string;
14
+ name: string;
15
+ }
16
+
17
+ export interface Ticket {
18
+ id: string;
19
+ project: string;
20
+ url: string;
21
+ title: string;
22
+ status: Status;
23
+ targetKind: TargetKind;
24
+ requiresHuman: boolean;
25
+ /** Injected at run time; ranks to the top of the frontier. */
26
+ injected: boolean;
27
+ /** Lower is more urgent; `null` sorts last. */
28
+ priority: number | null;
29
+ branchHint: string | null;
30
+ labels: string[];
31
+ updatedAt: string | null;
32
+ }
33
+
34
+ export interface Pr {
35
+ id: string;
36
+ /** The originating ticket, or null for a bare PR / raw prompt. */
37
+ ticket: string | null;
38
+ origin: PrOrigin;
39
+ repo: string | null;
40
+ prNumber: number | null;
41
+ url: string | null;
42
+ branch: string | null;
43
+ title: string;
44
+ injected: boolean;
45
+ priority: number | null;
46
+ updatedAt: string | null;
47
+ }
48
+
49
+ /** `blocker` blocks `blocked` — i.e. `blocked` depends on `blocker`. */
50
+ export interface Edge {
51
+ blocker: string;
52
+ blocked: string;
53
+ }
54
+
55
+ export interface Session {
56
+ id: string;
57
+ host: string | null;
58
+ pid: number | null;
59
+ /** The Claude session this server serves; the caller correlator. */
60
+ claudeSessionId: string | null;
61
+ /** When the session acknowledged the probe; work orders wait on it. */
62
+ ackedAt: string | null;
63
+ startedAt: string;
64
+ heartbeatAt: string;
65
+ }
66
+
67
+ export interface Claim {
68
+ node: string;
69
+ session: string;
70
+ actor: string | null;
71
+ worktree: string | null;
72
+ branch: string | null;
73
+ claimedAt: string;
74
+ }
75
+
76
+ export interface Outcome {
77
+ node: string;
78
+ outcome: OutcomeKind;
79
+ /** Meaningful only for `failed`; null otherwise. */
80
+ retryable: boolean | null;
81
+ detail: string | null;
82
+ recordedAt: string;
83
+ }
@@ -0,0 +1,2 @@
1
+ export * from './placeholders.mts';
2
+ export * from './refresh-service.mts';
@@ -0,0 +1,43 @@
1
+ import type {Database} from '../db/database.mts';
2
+
3
+ /**
4
+ * Every node an edge referenced that nobody has since written, grouped by the
5
+ * tracker that owns it. A placeholder carries no project of its own, so its
6
+ * tracker comes from a ticket on the other end of an edge touching it — the
7
+ * only thing that can have referenced it.
8
+ *
9
+ * One query for the whole graph rather than one per placeholder: `reconcile`
10
+ * runs on every write, and a project scan leaves hundreds of placeholders, each
11
+ * of which would otherwise pay this four-way join.
12
+ *
13
+ * A placeholder that no edge connects to a project with a tracker is absent
14
+ * from the result — `edge add A B` with neither endpoint written yet reaches no
15
+ * project, so there is nobody to ask. Those surface through the anomalies
16
+ * read-model, not from here.
17
+ *
18
+ * A placeholder referenced from tickets on two trackers picks the smallest
19
+ * source, deterministically. Cross-tracker dependencies are illegal anyway;
20
+ * asking the wrong tracker resolves the request `missing`, which the anomalies
21
+ * read-model then reports rather than the loop spinning.
22
+ */
23
+ export function placeholdersBySource(db: Database): Map<string, string[]> {
24
+ const bySource = new Map<string, string[]>();
25
+ const rows = db.all(
26
+ `SELECT n.id AS id, n.external_id AS external_id, MIN(p.source) AS source
27
+ FROM node n
28
+ JOIN edge e ON (e.blocker = n.id OR e.blocked = n.id)
29
+ JOIN ticket t
30
+ ON t.node_id = (CASE WHEN e.blocker = n.id THEN e.blocked ELSE e.blocker END)
31
+ JOIN project p ON p.node_id = t.project_id
32
+ WHERE n.kind = 'unknown' AND p.source IS NOT NULL
33
+ GROUP BY n.id
34
+ ORDER BY n.id`
35
+ );
36
+ for (const row of rows) {
37
+ const source = String(row.source);
38
+ const ids = bySource.get(source);
39
+ if (ids === undefined) bySource.set(source, [String(row.external_id)]);
40
+ else ids.push(String(row.external_id));
41
+ }
42
+ return bySource;
43
+ }
@@ -0,0 +1,203 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {nowIso} from '../db/time.mts';
3
+ import {DataError, ensure, UsageError} from '../errors/index.mts';
4
+ import {
5
+ CursorStore,
6
+ FetchRequestStore,
7
+ findNode,
8
+ RefreshStore,
9
+ } from '../stores/index.mts';
10
+ import type {FetchRequest, RefreshRow, RefreshState} from '../stores/index.mts';
11
+ import {placeholdersBySource} from './placeholders.mts';
12
+
13
+ /**
14
+ * Every decision about what to fetch next and when a refresh is done. All of it
15
+ * derives from the database, so `reconcile` is idempotent and a write command
16
+ * can call it unconditionally without knowing which phase it is in.
17
+ */
18
+ export class RefreshService {
19
+ readonly #db: Database;
20
+ readonly #refreshes: RefreshStore;
21
+ readonly #requests: FetchRequestStore;
22
+ readonly #cursors: CursorStore;
23
+ readonly #now: () => string;
24
+
25
+ constructor(db: Database, now: () => string = nowIso) {
26
+ this.#db = db;
27
+ this.#refreshes = new RefreshStore(db);
28
+ this.#requests = new FetchRequestStore(db);
29
+ this.#cursors = new CursorStore(db);
30
+ this.#now = now;
31
+ }
32
+
33
+ /** Open a refresh, or report that a live one already owns this source. */
34
+ async startScan(input: {
35
+ source: string;
36
+ projects: readonly string[];
37
+ sessionId: string | null;
38
+ rebuild: boolean;
39
+ }): Promise<{resumed: boolean}> {
40
+ const row = await this.#refreshes.get(input.source);
41
+ // A refresh that closed but has not yet had its completion pushed still
42
+ // belongs to whoever is waiting on that event — reopening it here would
43
+ // blank the completion fields and the event would never fire.
44
+ const finishing =
45
+ row !== null &&
46
+ row.completedAt !== null &&
47
+ row.completionEmittedAt === null;
48
+ const busy = row !== null && (row.state !== 'idle' || finishing);
49
+ if (busy && (await this.#refreshes.hasLiveSession(input.source))) {
50
+ // Resuming must put the outstanding instructions back on the wire: the
51
+ // caller is a session that asked again because it is holding nothing, and
52
+ // the drain only pushes rows it has not already marked delivered.
53
+ await this.#requests.redeliver(input.source);
54
+ return {resumed: true};
55
+ }
56
+
57
+ if (input.rebuild) {
58
+ await this.#db.transaction(() => this.#db.run('DELETE FROM node'));
59
+ // The delete is graph-wide, so every source's cursor must reset with it —
60
+ // otherwise another tracker's next delta sync starts past data that no
61
+ // longer exists and never re-fetches it.
62
+ await this.#cursors.clearAllCursors();
63
+ }
64
+
65
+ const at = this.#now();
66
+ await this.#requests.clear(input.source);
67
+ await this.#refreshes.open({
68
+ source: input.source,
69
+ projects: input.projects,
70
+ sessionId: input.sessionId,
71
+ at,
72
+ });
73
+ const cursor = await this.#cursors.getCursor(input.source);
74
+ await this.#requests.enqueueScan({
75
+ source: input.source,
76
+ projects: input.projects,
77
+ cursor,
78
+ at,
79
+ });
80
+ return {resumed: false};
81
+ }
82
+
83
+ /** The agent has written everything its scan found. */
84
+ async completeScan(input: {
85
+ source: string;
86
+ cursor: string | null;
87
+ }): Promise<{state: RefreshState; pending: string[]}> {
88
+ const row = await this.#refreshes.get(input.source);
89
+ ensure(
90
+ row !== null && row.state === 'scanning',
91
+ () =>
92
+ new UsageError(`no scan is in progress for ${input.source}`, {
93
+ hint: 'start one with `dispatch refresh --tracker <id> --project <id>` before reporting it done.',
94
+ })
95
+ );
96
+
97
+ if (input.cursor !== null) {
98
+ await this.#refreshes.setPendingCursor(input.source, input.cursor);
99
+ }
100
+ await this.#requests.resolveScan(input.source);
101
+ await this.#refreshes.setState(input.source, 'resolving');
102
+ await this.reconcile();
103
+
104
+ const after = await this.#refreshes.get(input.source);
105
+ ensure(
106
+ after !== null,
107
+ () =>
108
+ new DataError(`the ${input.source} refresh vanished mid-completion`, {
109
+ hint: 'another process deleted the refresh row; rerun `dispatch refresh --tracker <id> --project <id>`.',
110
+ })
111
+ );
112
+ const pending = (await this.#requests.openTickets())
113
+ .filter((request) => request.source === input.source)
114
+ .map((request) => request.ticket);
115
+ return {state: after.state, pending};
116
+ }
117
+
118
+ /** The tracker has no such ticket; stop asking for it. */
119
+ async markMissing(ticket: string): Promise<void> {
120
+ const request = await this.#requests.openTicketRequest(ticket);
121
+ ensure(
122
+ request !== null,
123
+ () =>
124
+ new UsageError(`nothing asked for ticket ${ticket}`, {
125
+ hint: 'only a ticket the CLI requested can be reported missing — check `dispatch refresh status`.',
126
+ })
127
+ );
128
+ await this.#requests.resolve(request.id, 'missing');
129
+ await this.reconcile();
130
+ }
131
+
132
+ async status(
133
+ source: string
134
+ ): Promise<{refresh: RefreshRow | null; requests: FetchRequest[]}> {
135
+ return {
136
+ refresh: await this.#refreshes.get(source),
137
+ requests: await this.#requests.bySource(source),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Bring every source back in line with the graph. Three passes, in order:
143
+ * satisfy requests whose ticket now exists, chase placeholders nobody is
144
+ * fetching, close whatever has nothing outstanding.
145
+ */
146
+ async reconcile(): Promise<void> {
147
+ const at = this.#now();
148
+
149
+ for (const request of await this.#requests.openTickets()) {
150
+ const node = findNode(this.#db, request.ticket);
151
+ if (node !== null && node.kind !== 'unknown') {
152
+ await this.#requests.resolve(request.id, 'materialized');
153
+ }
154
+ }
155
+
156
+ // A scan writes edges before their endpoints, so under one every reference
157
+ // is briefly dangling; chasing them there would ask for most of the project.
158
+ // The state is read once per source, before any per-placeholder work, so a
159
+ // scan pays nothing for the placeholders it is still filling in.
160
+ for (const [source, externalIds] of placeholdersBySource(this.#db)) {
161
+ const row = await this.#refreshes.get(source);
162
+ if (row?.state === 'scanning') continue;
163
+ // A refresh that closed but still owes its completion push is not fair
164
+ // game to reopen — blanking the completion fields here would leave the
165
+ // session that is waiting on the event waiting forever.
166
+ if (
167
+ row !== null &&
168
+ row.completedAt !== null &&
169
+ row.completionEmittedAt === null
170
+ ) {
171
+ continue;
172
+ }
173
+ // The state advances only once a request exists to justify it.
174
+ // `enqueueTicket` returns null for an id that is already queued or
175
+ // already resolved `missing`, and a `resolving` refresh with nothing
176
+ // outstanding closes on the next pass and owes another completion — for
177
+ // a `missing` tombstone, which is permanent, that repeats on every write.
178
+ let resolving = row !== null && row.state === 'resolving';
179
+ for (const externalId of externalIds) {
180
+ const enqueued = await this.#requests.enqueueTicket({
181
+ source,
182
+ ticket: externalId,
183
+ at,
184
+ });
185
+ if (enqueued === null) continue;
186
+ if (!resolving) {
187
+ await this.#refreshes.openResolving({source, sessionId: null, at});
188
+ resolving = true;
189
+ }
190
+ }
191
+ }
192
+
193
+ for (const row of await this.#refreshes.active()) {
194
+ if (row.state !== 'resolving') continue;
195
+ if ((await this.#requests.openCount(row.source)) > 0) continue;
196
+ if (row.pendingCursor !== null) {
197
+ await this.#cursors.setCursor(row.source, row.pendingCursor);
198
+ }
199
+ await this.#requests.clearExceptMissing(row.source);
200
+ await this.#refreshes.close(row.source, at);
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,18 @@
1
+ # schedule
2
+
3
+ The server's deterministic scheduling half.
4
+
5
+ - `scheduler.mts` — `Scheduler.tick(now)`: heartbeat and sweep, then (only
6
+ once the session is acked) one admission budget — `max-parallel` minus
7
+ live claims — spent on milestone-review orders first, then
8
+ claim-then-emit over the dispatch queue, plus the once-per-episode condition
9
+ orders (`notice` table). Returns orders; never touches the channel, so it
10
+ tests without one.
11
+ - `caps.mts` — `RepoAdmission`: the per-repo caps on open PRs and in-flight
12
+ builds, which bound resources a PR holds while no agent does. Unlike the
13
+ budget, a cap refuses one queue entry and the pass continues — a later entry
14
+ for another repo may still fit.
15
+ - `tick.mts` — `runServerTick`: opens the DB, runs the scheduler, pushes the
16
+ orders, and keeps the probe/ack handshake alive on a capped backoff.
17
+ - `correlate.mts` — `resolveSession`: an explicit registry id, else the one
18
+ live server carrying the caller's Claude session id.
@@ -0,0 +1,113 @@
1
+ import type {RepoPrLoad} from '../graph/index.mts';
2
+ import {limitFor} from '../model/index.mts';
3
+ import type {RepoCap, RepoCapPolicy} from '../model/index.mts';
4
+
5
+ /** A cap at its limit, and how much work is waiting behind it. */
6
+ export interface CapHold {
7
+ repo: string;
8
+ cap: RepoCap;
9
+ limit: number;
10
+ observed: number;
11
+ /** Queue entries this cap refused over the pass. */
12
+ waiting: number;
13
+ }
14
+
15
+ /** What the caps need to know about one queue entry. */
16
+ export interface PrItemRef {
17
+ id: string;
18
+ repo: string | null;
19
+ prNumber: number | null;
20
+ }
21
+
22
+ /**
23
+ * The per-repo half of admission, run over one pass of the dispatch queue.
24
+ *
25
+ * Counts are node sets rather than integers so a reservation is idempotent:
26
+ * dispatching a PR that is already building must not spend a second build
27
+ * slot, and only an item with no PR number yet adds to the open-PR count.
28
+ * Reserving at all is what stops one pass overshooting a cap — every
29
+ * `dispatch_pr` is expected to produce a build, and each one that opens a PR
30
+ * to produce an open PR, neither of which the pass's own load reading can
31
+ * show. Past this pass the claim each order takes carries the same weight,
32
+ * so the next tick counts it without help from here.
33
+ *
34
+ * An item with no repo is admitted: there is nothing to attribute it to.
35
+ */
36
+ export class RepoAdmission {
37
+ readonly #policy: RepoCapPolicy;
38
+ readonly #open = new Map<string, Set<string>>();
39
+ readonly #building = new Map<string, Set<string>>();
40
+ readonly #holds = new Map<string, CapHold>();
41
+
42
+ constructor(policy: RepoCapPolicy, load: readonly RepoPrLoad[]) {
43
+ this.#policy = policy;
44
+ for (const entry of load) {
45
+ if (entry.open) RepoAdmission.#add(this.#open, entry.repo, entry.node);
46
+ if (entry.building)
47
+ RepoAdmission.#add(this.#building, entry.repo, entry.node);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * The cap refusing this item, or null when both admit it. A refusal is
53
+ * recorded, so `holds` reports what the pass left waiting.
54
+ *
55
+ * The open-PR cap gates only an item that would open a *new* PR. An item
56
+ * whose PR already exists is counted against that cap already, so working
57
+ * it adds nothing to the pool — and gating it would deadlock the cap,
58
+ * because nothing could finish to free a slot. The build cap gates every
59
+ * item; it cannot deadlock, since builds drain without an agent.
60
+ */
61
+ admit(item: PrItemRef): CapHold | null {
62
+ if (item.repo === null) return null;
63
+ if (item.prNumber === null) {
64
+ const hold = this.#atLimit(item.repo, 'open-prs');
65
+ if (hold !== null) return this.#record(hold);
66
+ }
67
+ const hold = this.#atLimit(item.repo, 'in-flight-builds');
68
+ return hold === null ? null : this.#record(hold);
69
+ }
70
+
71
+ /** Spend this item's slots for the rest of the pass. */
72
+ reserve(item: PrItemRef): void {
73
+ if (item.repo === null) return;
74
+ if (item.prNumber === null)
75
+ RepoAdmission.#add(this.#open, item.repo, item.id);
76
+ RepoAdmission.#add(this.#building, item.repo, item.id);
77
+ }
78
+
79
+ /** Every cap that refused something, repo then cap. */
80
+ holds(): CapHold[] {
81
+ return [...this.#holds.values()].sort(
82
+ (a, b) => a.repo.localeCompare(b.repo) || a.cap.localeCompare(b.cap)
83
+ );
84
+ }
85
+
86
+ #atLimit(repo: string, cap: RepoCap): CapHold | null {
87
+ const observed = (cap === 'open-prs' ? this.#open : this.#building).get(
88
+ repo
89
+ )?.size;
90
+ const limit = limitFor(this.#policy, repo, cap);
91
+ return (observed ?? 0) >= limit
92
+ ? {repo, cap, limit, observed: observed ?? 0, waiting: 0}
93
+ : null;
94
+ }
95
+
96
+ #record(hold: CapHold): CapHold {
97
+ const key = `${hold.repo} ${hold.cap}`;
98
+ const held = this.#holds.get(key) ?? hold;
99
+ held.waiting += 1;
100
+ this.#holds.set(key, held);
101
+ return held;
102
+ }
103
+
104
+ static #add(
105
+ counts: Map<string, Set<string>>,
106
+ repo: string,
107
+ node: string
108
+ ): void {
109
+ const set = counts.get(repo) ?? new Set<string>();
110
+ set.add(node);
111
+ counts.set(repo, set);
112
+ }
113
+ }
@@ -0,0 +1,69 @@
1
+ import type {Database} from '../db/database.mts';
2
+ import {nowIso} from '../db/time.mts';
3
+ import {DataError, ensure} from '../errors/index.mts';
4
+ import {DEFAULT_STALE_AFTER_SECONDS} from '../graph/index.mts';
5
+ import {withLiveProcesses} from '../liveness/index.mts';
6
+ import {SessionStore} from '../stores/index.mts';
7
+
8
+ /**
9
+ * Correlate a caller to its server without demanding one exists.
10
+ *
11
+ * `null` means no live server rides this caller's session — an operator at a
12
+ * terminal, or a session whose server died. A command that only wanted to
13
+ * release its own claim can proceed: there is no claim of its own to release.
14
+ *
15
+ * Ambiguity is emphatically not that case. More than one live server carries
16
+ * this session id, so which one's claim a release should target is unknowable,
17
+ * and guessing would either strand capacity or revoke a running worker's grant.
18
+ * That throws, naming `--session` as the way out.
19
+ */
20
+ export async function correlateSession(
21
+ db: Database,
22
+ env: NodeJS.ProcessEnv,
23
+ explicit: string | undefined
24
+ ): Promise<string | null> {
25
+ if (explicit !== undefined) return explicit;
26
+ const caller = env.CLAUDE_CODE_SESSION_ID ?? null;
27
+ if (caller === null) return null;
28
+ const live = await withLiveProcesses(
29
+ await new SessionStore(db).liveForCaller(
30
+ caller,
31
+ nowIso(),
32
+ DEFAULT_STALE_AFTER_SECONDS
33
+ )
34
+ );
35
+ ensure(
36
+ live.length <= 1,
37
+ () =>
38
+ new DataError(
39
+ `${String(live.length)} live servers carry this session id: ${live
40
+ .map((session) => session.id)
41
+ .join(', ')}`,
42
+ {
43
+ hint: 'pass --session with the registry id from the probe event; correlation cannot choose between them.',
44
+ }
45
+ )
46
+ );
47
+ return live[0]?.id ?? null;
48
+ }
49
+
50
+ /**
51
+ * The session a caller's writes must ride. Claims recorded under it cascade
52
+ * away if that server dies. Unlike `correlateSession`, having no server at all
53
+ * is a failure — the caller needs a session to write under.
54
+ */
55
+ export async function resolveSession(
56
+ db: Database,
57
+ env: NodeJS.ProcessEnv,
58
+ explicit: string | undefined
59
+ ): Promise<string> {
60
+ const session = await correlateSession(db, env, explicit);
61
+ ensure(
62
+ session !== null,
63
+ () =>
64
+ new DataError('no live server correlates to this session', {
65
+ hint: 'pass --session with the registry id from the probe event, or start the dispatch MCP server.',
66
+ })
67
+ );
68
+ return session;
69
+ }
@@ -0,0 +1,7 @@
1
+ export {RepoAdmission} from './caps.mts';
2
+ export type {CapHold, PrItemRef} from './caps.mts';
3
+ export {DEFAULT_MAX_PARALLEL, Scheduler} from './scheduler.mts';
4
+ export type {TickResult, WorkOrder} from './scheduler.mts';
5
+ export {createTickState, runServerTick} from './tick.mts';
6
+ export type {TickState} from './tick.mts';
7
+ export {correlateSession, resolveSession} from './correlate.mts';