@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,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,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';
|