omp-conductor 0.2.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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run bookkeeping on bun:sqlite.
|
|
3
|
+
*
|
|
4
|
+
* The tracker's labels remain the source of truth for *what* the fleet is
|
|
5
|
+
* doing; this store exists so the dispatcher can answer cap questions ("am I
|
|
6
|
+
* already at two workers?", "have I spent $25 today?") without a round-trip to
|
|
7
|
+
* GitHub on every tick, and so a restart can reconcile the runs it left
|
|
8
|
+
* mid-flight. Losing the file is survivable — it is a cache with a memory, not
|
|
9
|
+
* a ledger.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { RunRecord, RunState, Store } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* States that consume a worker slot. `pushed-green` counts: the branch is
|
|
20
|
+
* pushed and CI is green but nothing is merged, so the worktree, the branch
|
|
21
|
+
* and the issue claim are all still held.
|
|
22
|
+
*/
|
|
23
|
+
const ACTIVE_STATES: readonly RunState[] = ["claimed", "running", "pushed-green"];
|
|
24
|
+
|
|
25
|
+
const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Allowlist for `updateRun`'s dynamic SET clause. Column names cannot be bound
|
|
29
|
+
* as parameters, so they are matched against this table rather than
|
|
30
|
+
* interpolated from caller input. `id` is deliberately absent: it is the
|
|
31
|
+
* address of the row, not one of its fields.
|
|
32
|
+
*/
|
|
33
|
+
const UPDATABLE_COLUMNS: Record<string, true> = {
|
|
34
|
+
project: true,
|
|
35
|
+
issue: true,
|
|
36
|
+
repo: true,
|
|
37
|
+
branch: true,
|
|
38
|
+
worktree: true,
|
|
39
|
+
state: true,
|
|
40
|
+
attempt: true,
|
|
41
|
+
turns: true,
|
|
42
|
+
spendUsd: true,
|
|
43
|
+
sessionFile: true,
|
|
44
|
+
prUrl: true,
|
|
45
|
+
startedAt: true,
|
|
46
|
+
endedAt: true,
|
|
47
|
+
lastError: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Everything SQLite will accept from us. */
|
|
51
|
+
type SqlValue = string | number | null;
|
|
52
|
+
|
|
53
|
+
/** The `runs` table exactly as SQLite hands it back: optional means NULL. */
|
|
54
|
+
interface RunRow {
|
|
55
|
+
id: string;
|
|
56
|
+
project: string;
|
|
57
|
+
issue: number;
|
|
58
|
+
repo: string;
|
|
59
|
+
branch: string;
|
|
60
|
+
worktree: string;
|
|
61
|
+
state: string;
|
|
62
|
+
attempt: number;
|
|
63
|
+
turns: number;
|
|
64
|
+
spendUsd: number;
|
|
65
|
+
sessionFile: string | null;
|
|
66
|
+
prUrl: string | null;
|
|
67
|
+
startedAt: number;
|
|
68
|
+
endedAt: number | null;
|
|
69
|
+
lastError: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SCHEMA = `
|
|
73
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
project TEXT NOT NULL,
|
|
76
|
+
issue INTEGER NOT NULL,
|
|
77
|
+
repo TEXT NOT NULL,
|
|
78
|
+
branch TEXT NOT NULL,
|
|
79
|
+
worktree TEXT NOT NULL,
|
|
80
|
+
state TEXT NOT NULL,
|
|
81
|
+
attempt INTEGER NOT NULL,
|
|
82
|
+
turns INTEGER NOT NULL,
|
|
83
|
+
spendUsd REAL NOT NULL,
|
|
84
|
+
sessionFile TEXT,
|
|
85
|
+
prUrl TEXT,
|
|
86
|
+
startedAt INTEGER NOT NULL,
|
|
87
|
+
endedAt INTEGER,
|
|
88
|
+
lastError TEXT
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
94
|
+
"key" TEXT PRIMARY KEY,
|
|
95
|
+
at INTEGER NOT NULL
|
|
96
|
+
);
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `undefined` is not a legal binding, and a boolean is only accepted by some
|
|
101
|
+
* bun:sqlite builds, so both are normalised before they reach the driver.
|
|
102
|
+
*/
|
|
103
|
+
function toSql(value: unknown): SqlValue {
|
|
104
|
+
if (value === undefined || value === null) return null;
|
|
105
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
106
|
+
return value as SqlValue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A NULL column becomes an absent property rather than an `undefined` one, so
|
|
111
|
+
* a record read back out of the store deep-equals the one that went in.
|
|
112
|
+
*/
|
|
113
|
+
function toRecord(row: RunRow): RunRecord {
|
|
114
|
+
const record: RunRecord = {
|
|
115
|
+
id: row.id,
|
|
116
|
+
project: row.project,
|
|
117
|
+
issue: row.issue,
|
|
118
|
+
repo: row.repo,
|
|
119
|
+
branch: row.branch,
|
|
120
|
+
worktree: row.worktree,
|
|
121
|
+
state: row.state as RunState,
|
|
122
|
+
attempt: row.attempt,
|
|
123
|
+
turns: row.turns,
|
|
124
|
+
spendUsd: row.spendUsd,
|
|
125
|
+
startedAt: row.startedAt,
|
|
126
|
+
};
|
|
127
|
+
if (row.sessionFile !== null) record.sessionFile = row.sessionFile;
|
|
128
|
+
if (row.prUrl !== null) record.prUrl = row.prUrl;
|
|
129
|
+
if (row.endedAt !== null) record.endedAt = row.endedAt;
|
|
130
|
+
if (row.lastError !== null) record.lastError = row.lastError;
|
|
131
|
+
return record;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Open (creating if needed) the run store at `dbPath`; `:memory:` is honoured
|
|
136
|
+
* for tests. Safe to call on a fresh path — the schema is applied on open, so
|
|
137
|
+
* there is no separate migration step to forget.
|
|
138
|
+
*/
|
|
139
|
+
export function openStore(dbPath: string): Store {
|
|
140
|
+
if (dbPath !== ":memory:" && !dbPath.startsWith("file:")) {
|
|
141
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const db = new Database(dbPath, { create: true });
|
|
145
|
+
|
|
146
|
+
// WAL lets the plugin read status while the daemon is mid-write; the busy
|
|
147
|
+
// timeout covers the single writer lock they still contend for.
|
|
148
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
149
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
150
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
151
|
+
// ponytail: the schema is created if absent and never migrated — a column
|
|
152
|
+
// change means hand-editing or deleting the file. Upgrade path for the first
|
|
153
|
+
// shape change: PRAGMA user_version plus an ordered migration list here.
|
|
154
|
+
db.exec(SCHEMA);
|
|
155
|
+
|
|
156
|
+
const insertRun = db.query<unknown, SqlValue[]>(
|
|
157
|
+
`INSERT INTO runs (
|
|
158
|
+
id, project, issue, repo, branch, worktree, state, attempt, turns,
|
|
159
|
+
spendUsd, sessionFile, prUrl, startedAt, endedAt, lastError
|
|
160
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
161
|
+
);
|
|
162
|
+
const selectRun = db.query<RunRow, [string]>(`SELECT * FROM runs WHERE id = ?`);
|
|
163
|
+
const selectActive = db.query<RunRow, SqlValue[]>(
|
|
164
|
+
`SELECT * FROM runs
|
|
165
|
+
WHERE project = ? AND state IN (${ACTIVE_PLACEHOLDERS})
|
|
166
|
+
ORDER BY startedAt ASC`,
|
|
167
|
+
);
|
|
168
|
+
const countAttempts = db.query<{ n: number }, [string, number]>(
|
|
169
|
+
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND issue = ?`,
|
|
170
|
+
);
|
|
171
|
+
const countStartedSince = db.query<{ n: number }, [string, number]>(
|
|
172
|
+
`SELECT COUNT(*) AS n FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
173
|
+
);
|
|
174
|
+
const sumSpendSince = db.query<{ total: number }, [string, number]>(
|
|
175
|
+
`SELECT COALESCE(SUM(spendUsd), 0) AS total
|
|
176
|
+
FROM runs WHERE project = ? AND startedAt >= ?`,
|
|
177
|
+
);
|
|
178
|
+
const countNotified = db.query<{ n: number }, [string]>(
|
|
179
|
+
`SELECT COUNT(*) AS n FROM notifications WHERE "key" = ?`,
|
|
180
|
+
);
|
|
181
|
+
// First notification wins, so the row records when a human was actually
|
|
182
|
+
// paged rather than when the newest retry re-reported the same event.
|
|
183
|
+
const insertNotified = db.query<unknown, [string, number]>(
|
|
184
|
+
`INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
createRun(r: Omit<RunRecord, "id">): RunRecord {
|
|
189
|
+
const record: RunRecord = { ...r, id: crypto.randomUUID() };
|
|
190
|
+
insertRun.run(
|
|
191
|
+
record.id,
|
|
192
|
+
record.project,
|
|
193
|
+
record.issue,
|
|
194
|
+
record.repo,
|
|
195
|
+
record.branch,
|
|
196
|
+
record.worktree,
|
|
197
|
+
record.state,
|
|
198
|
+
record.attempt,
|
|
199
|
+
record.turns,
|
|
200
|
+
record.spendUsd,
|
|
201
|
+
toSql(record.sessionFile),
|
|
202
|
+
toSql(record.prUrl),
|
|
203
|
+
record.startedAt,
|
|
204
|
+
toSql(record.endedAt),
|
|
205
|
+
toSql(record.lastError),
|
|
206
|
+
);
|
|
207
|
+
return record;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
updateRun(id: string, patch: Partial<RunRecord>): void {
|
|
211
|
+
const assignments: string[] = [];
|
|
212
|
+
const values: SqlValue[] = [];
|
|
213
|
+
for (const [column, value] of Object.entries(patch)) {
|
|
214
|
+
if (!UPDATABLE_COLUMNS[column]) continue;
|
|
215
|
+
assignments.push(`${column} = ?`);
|
|
216
|
+
values.push(toSql(value));
|
|
217
|
+
}
|
|
218
|
+
if (assignments.length === 0) return;
|
|
219
|
+
values.push(id);
|
|
220
|
+
// An unknown id matches no row: zero changes, no error, by design — the
|
|
221
|
+
// dispatcher patches runs a restart may already have reaped.
|
|
222
|
+
db.query<unknown, SqlValue[]>(
|
|
223
|
+
`UPDATE runs SET ${assignments.join(", ")} WHERE id = ?`,
|
|
224
|
+
).run(...values);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
getRun(id: string): RunRecord | undefined {
|
|
228
|
+
const row = selectRun.get(id);
|
|
229
|
+
return row ? toRecord(row) : undefined;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
activeRuns(project: string): RunRecord[] {
|
|
233
|
+
return selectActive.all(project, ...ACTIVE_STATES).map(toRecord);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
attemptsFor(project: string, issue: number): number {
|
|
237
|
+
return countAttempts.get(project, issue)?.n ?? 0;
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
runsStartedSince(project: string, sinceEpochMs: number): number {
|
|
241
|
+
return countStartedSince.get(project, sinceEpochMs)?.n ?? 0;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
spendSince(project: string, sinceEpochMs: number): number {
|
|
245
|
+
return sumSpendSince.get(project, sinceEpochMs)?.total ?? 0;
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
wasNotified(key: string): boolean {
|
|
249
|
+
return (countNotified.get(key)?.n ?? 0) > 0;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
markNotified(key: string): void {
|
|
253
|
+
// ponytail: notifications is append-only and never pruned. It gains one
|
|
254
|
+
// short row per escalation, so it is decades from mattering; prune by
|
|
255
|
+
// `at` if it ever does.
|
|
256
|
+
insertNotified.run(key, Date.now());
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
close(): void {
|
|
260
|
+
db.close(false);
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub adapter for the tracker port.
|
|
3
|
+
*
|
|
4
|
+
* Every operation goes through the already-authenticated `gh` CLI, so the
|
|
5
|
+
* daemon never handles a token itself: credentials stay in the user's keychain
|
|
6
|
+
* or `gh` config and are never passed as argv, written to a file, or logged.
|
|
7
|
+
*
|
|
8
|
+
* ponytail: shelling out to `gh` is the deliberate simplification. The ceiling
|
|
9
|
+
* is per-call cost (one process spawn plus one TLS handshake per operation,
|
|
10
|
+
* ~200-400ms) and failure classification by matching human-readable stderr
|
|
11
|
+
* instead of reading a status code. Upgrade path when either bites: replace the
|
|
12
|
+
* body of `gh()` with `fetch("https://api.github.com/...")` using a token from
|
|
13
|
+
* `gh auth token`; the six Tracker methods above it stay untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
/** The subset of `gh issue list --json` output this adapter reads. Fields the
|
|
19
|
+
* API can return as null are typed as such so the mapping has to handle it. */
|
|
20
|
+
interface GhIssue {
|
|
21
|
+
number: number;
|
|
22
|
+
title: string | null;
|
|
23
|
+
body: string | null;
|
|
24
|
+
labels: { name: string }[] | null;
|
|
25
|
+
url: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Carries the captured stderr so callers can classify a failure without
|
|
30
|
+
* re-running the command or parsing the message text of a plain Error. */
|
|
31
|
+
class GhError extends Error {
|
|
32
|
+
readonly argv: string[];
|
|
33
|
+
readonly code: number;
|
|
34
|
+
readonly stderr: string;
|
|
35
|
+
|
|
36
|
+
constructor(argv: string[], code: number, stderr: string) {
|
|
37
|
+
const detail = stderr.trim() || "(no stderr)";
|
|
38
|
+
super(`\`gh ${argv.join(" ")}\` exited ${code}: ${detail}`);
|
|
39
|
+
this.name = "GhError";
|
|
40
|
+
this.argv = argv;
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.stderr = stderr;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The single execution path for this adapter. `stdin` is written to the child
|
|
48
|
+
* and closed, which is how bodies containing newlines, backticks or a leading
|
|
49
|
+
* `-` reach `gh` without ever being interpolated into argv.
|
|
50
|
+
*/
|
|
51
|
+
async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
52
|
+
const proc = Bun.spawn(["gh", ...argv], {
|
|
53
|
+
// Always a closed stream: commands that do not read stdin see immediate
|
|
54
|
+
// EOF instead of an open pipe nobody ends.
|
|
55
|
+
stdin: new Blob([stdin ?? ""]),
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
61
|
+
new Response(proc.stdout).text(),
|
|
62
|
+
new Response(proc.stderr).text(),
|
|
63
|
+
proc.exited,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const signal = proc.signalCode;
|
|
67
|
+
if (code !== 0 || signal) {
|
|
68
|
+
throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr);
|
|
69
|
+
}
|
|
70
|
+
return stdout;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when a label edit failed only because the requested end state already
|
|
75
|
+
* holds. Adding a label is idempotent server-side, but removing one that is not
|
|
76
|
+
* present is a 404, and a concurrent daemon restart can easily race into both.
|
|
77
|
+
*/
|
|
78
|
+
function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
79
|
+
if (!(err instanceof GhError)) return false;
|
|
80
|
+
const stderr = err.stderr;
|
|
81
|
+
return op === "add"
|
|
82
|
+
? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr)
|
|
83
|
+
: /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue/i.test(
|
|
84
|
+
stderr,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function makeTracker(p: ProjectConfig): Tracker {
|
|
89
|
+
const repo = p.tracker.repo;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async listReady(): Promise<ReadyIssue[]> {
|
|
93
|
+
const raw = await gh([
|
|
94
|
+
"issue",
|
|
95
|
+
"list",
|
|
96
|
+
"--repo",
|
|
97
|
+
repo,
|
|
98
|
+
"--state",
|
|
99
|
+
"open",
|
|
100
|
+
"--label",
|
|
101
|
+
p.queueLabel,
|
|
102
|
+
// ponytail: one page is the cap. A queue deeper than 100 ready issues
|
|
103
|
+
// truncates silently; upgrade path is `--paginate` via the API, but a
|
|
104
|
+
// backlog that size is a staffing problem before it is a paging one.
|
|
105
|
+
"--limit",
|
|
106
|
+
"100",
|
|
107
|
+
"--json",
|
|
108
|
+
"number,title,body,labels,url,updatedAt",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const text = raw.trim();
|
|
112
|
+
// `gh` prints nothing at all in some no-match paths; an empty queue is
|
|
113
|
+
// the normal steady state, not an error.
|
|
114
|
+
if (!text) return [];
|
|
115
|
+
|
|
116
|
+
const issues = JSON.parse(text) as GhIssue[];
|
|
117
|
+
return issues.map((issue) => ({
|
|
118
|
+
number: issue.number,
|
|
119
|
+
title: issue.title ?? "",
|
|
120
|
+
body: issue.body ?? "",
|
|
121
|
+
labels: (issue.labels ?? []).map((label) => label.name),
|
|
122
|
+
url: issue.url,
|
|
123
|
+
updatedAt: issue.updatedAt,
|
|
124
|
+
}));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async addLabel(issue: number, label: string): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await gh(["issue", "edit", String(issue), "--repo", repo, "--add-label", label]);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (!isLabelNoop(err, "add")) throw err;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async removeLabel(issue: number, label: string): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
await gh(["issue", "edit", String(issue), "--repo", repo, "--remove-label", label]);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (!isLabelNoop(err, "remove")) throw err;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async comment(issue: number, body: string): Promise<void> {
|
|
144
|
+
// `--body-file -` reads stdin, so the body is never shell- or argv-
|
|
145
|
+
// mangled and has no length limit worth worrying about.
|
|
146
|
+
await gh(["issue", "comment", String(issue), "--repo", repo, "--body-file", "-"], body);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async close(issue: number): Promise<void> {
|
|
150
|
+
await gh(["issue", "close", String(issue), "--repo", repo]);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async linkParent(child: number, parent: number): Promise<void> {
|
|
154
|
+
// Native sub-issue linkage rather than a body mention: it is what the
|
|
155
|
+
// repo's own epic rollups read, so a human sees the split without us
|
|
156
|
+
// maintaining a second index of it.
|
|
157
|
+
await gh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract for omp-conductor.
|
|
3
|
+
*
|
|
4
|
+
* Every later slice (tracker adapters, the store, the dispatcher loop, the
|
|
5
|
+
* worker driver, the plugin and the CLI) imports from here and nothing else,
|
|
6
|
+
* so this file stays free of imports and runtime code — the single exception
|
|
7
|
+
* is `DEFAULT_CAPS`, which is data, not behaviour.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hard limits enforced in code, never by the model. A worker that is asked to
|
|
12
|
+
* respect a budget will eventually talk itself out of it, so the dispatcher
|
|
13
|
+
* counts turns, wall clock and dollars itself and kills anything over the line.
|
|
14
|
+
*/
|
|
15
|
+
export interface Caps {
|
|
16
|
+
/** Parallel omp sessions. Two, because the homelab only has 3 CI runners and
|
|
17
|
+
* a third worker would starve its own PR checks. */
|
|
18
|
+
maxConcurrentWorkers: number;
|
|
19
|
+
/** Rolling-day spend ceiling; the loop stops claiming work once it is hit. */
|
|
20
|
+
dailySpendUsd: number;
|
|
21
|
+
/** Turn ceiling for one worker — catches loops that are burning tokens
|
|
22
|
+
* without converging. */
|
|
23
|
+
workerMaxTurns: number;
|
|
24
|
+
/** Wall-clock ceiling for one worker (90 min): a session that is merely
|
|
25
|
+
* stuck spends no turns, so turns alone cannot detect it. */
|
|
26
|
+
workerWallClockMs: number;
|
|
27
|
+
/** Retries per issue before it escalates (2): one clean retry recovers from
|
|
28
|
+
* flaky CI, a third almost always means the issue itself is underspecified. */
|
|
29
|
+
maxAttemptsPerIssue: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One module checkout a routed issue can target. A project is usually several
|
|
34
|
+
* repos (module images, shared packages), and the dispatcher must know how to
|
|
35
|
+
* clone and validate each one without asking the model.
|
|
36
|
+
*/
|
|
37
|
+
export interface RepoTarget {
|
|
38
|
+
/** Short routing key that appears in the issue label, e.g. "api". */
|
|
39
|
+
name: string;
|
|
40
|
+
cloneUrl: string;
|
|
41
|
+
/** Branch worktrees are cut from and PRs target. */
|
|
42
|
+
defaultBranch: string;
|
|
43
|
+
/**
|
|
44
|
+
* Exact cheap pre-push commands CI also runs, with the cwd each runs from.
|
|
45
|
+
* Running the real gate locally is what makes an unattended push safe — a
|
|
46
|
+
* subset lets lint errors outside the source dir reach the runners.
|
|
47
|
+
*/
|
|
48
|
+
gates: { cmd: string; cwd: string }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How much the orchestrator says out loud without being asked. Declared as data
|
|
53
|
+
* so the validator, the wizard and the brief all enumerate the same two values:
|
|
54
|
+
* a third scope cannot be added while one of them still knows only two.
|
|
55
|
+
*/
|
|
56
|
+
export const REPORT_SCOPES = ["escalations", "material"] as const;
|
|
57
|
+
|
|
58
|
+
export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a project that never answered the question gets. `material` rather than
|
|
62
|
+
* `escalations` because a config written before this key existed was serviced by
|
|
63
|
+
* a brief that reported material events, and quietly muting an existing fleet is
|
|
64
|
+
* the kind of change nobody notices until the week it mattered.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Everything the dispatcher needs to service one product: where work comes
|
|
70
|
+
* from, where code goes, and what it may spend doing it. Config is per project
|
|
71
|
+
* so unrelated products cannot consume each other's budget.
|
|
72
|
+
*/
|
|
73
|
+
export interface ProjectConfig {
|
|
74
|
+
name: string;
|
|
75
|
+
/** Issue source. Kind is pinned to "github" today but kept explicit so a
|
|
76
|
+
* second tracker is a config change rather than a schema change. */
|
|
77
|
+
tracker: { kind: "github"; repo: string };
|
|
78
|
+
/** The one label that means "a human has signed this off as agent-ready". */
|
|
79
|
+
queueLabel: string;
|
|
80
|
+
/** Labels the dispatcher writes back so the tracker alone shows live state
|
|
81
|
+
* to a human who never opens the daemon's logs. */
|
|
82
|
+
stateLabels: { inProgress: string; blocked: string; failed: string };
|
|
83
|
+
/** Maps a `${labelPrefix}${name}` label on an issue to the checkout it
|
|
84
|
+
* belongs in, so routing is declared by humans, not guessed. */
|
|
85
|
+
routing: { labelPrefix: string; repos: Record<string, RepoTarget> };
|
|
86
|
+
/** Per-project overrides layered on the global defaults. */
|
|
87
|
+
caps: Partial<Caps>;
|
|
88
|
+
/**
|
|
89
|
+
* Model pattern for worker sessions, in omp's model/role syntax. Omitted
|
|
90
|
+
* leaves the harness default in place, which is what a project that never
|
|
91
|
+
* answered the question wants.
|
|
92
|
+
*/
|
|
93
|
+
workerModel?: string;
|
|
94
|
+
/** How a stuck run reaches a human, and what to do when it cannot. */
|
|
95
|
+
escalation: { telegramChatId?: string; fallbackToIssueComment: boolean };
|
|
96
|
+
/**
|
|
97
|
+
* How loud the orchestrator is. Optional on disk — a config written before
|
|
98
|
+
* this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
|
|
99
|
+
* `resolveReportScope` rather than reaching for `.scope` directly.
|
|
100
|
+
*/
|
|
101
|
+
reporting?: { scope: ReportScope };
|
|
102
|
+
/** Parent directory for per-run worktrees. */
|
|
103
|
+
workspaceRoot: string;
|
|
104
|
+
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
105
|
+
mirrorRoot: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The config format this conductor writes. Bumped from 1 when a daily-volume cap
|
|
110
|
+
* was retired: a v1 file may still carry cap keys this version no longer
|
|
111
|
+
* enforces, so it is read leniently and normalised up, while a v2 file is held
|
|
112
|
+
* to the current key set exactly.
|
|
113
|
+
*/
|
|
114
|
+
export const CONFIG_VERSION = 2;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Versions this conductor can read. A v1 file loads, drops the caps that no
|
|
118
|
+
* longer exist, and is rewritten as v2 the next time anything saves.
|
|
119
|
+
*/
|
|
120
|
+
export const READABLE_CONFIG_VERSIONS = [1, CONFIG_VERSION] as const;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* On-disk root config. `version` is present from day one so a format change
|
|
124
|
+
* can be migrated instead of silently misread by an older daemon.
|
|
125
|
+
*/
|
|
126
|
+
export interface ConductorConfig {
|
|
127
|
+
version: typeof CONFIG_VERSION;
|
|
128
|
+
defaults: Caps;
|
|
129
|
+
projects: ProjectConfig[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The tracker-agnostic view of a queued issue: only the fields the dispatcher
|
|
134
|
+
* actually reads, so adapters never have to fabricate provider metadata.
|
|
135
|
+
*/
|
|
136
|
+
export interface ReadyIssue {
|
|
137
|
+
number: number;
|
|
138
|
+
title: string;
|
|
139
|
+
body: string;
|
|
140
|
+
labels: string[];
|
|
141
|
+
url: string;
|
|
142
|
+
/** Used to detect an issue edited mid-run, which invalidates the claim. */
|
|
143
|
+
updatedAt: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Deliberately narrow so a Gitea or local-file tracker can drop in later.
|
|
148
|
+
* Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
|
|
149
|
+
*/
|
|
150
|
+
export interface Tracker {
|
|
151
|
+
listReady(): Promise<ReadyIssue[]>;
|
|
152
|
+
addLabel(issue: number, label: string): Promise<void>;
|
|
153
|
+
removeLabel(issue: number, label: string): Promise<void>;
|
|
154
|
+
comment(issue: number, body: string): Promise<void>;
|
|
155
|
+
close(issue: number): Promise<void>;
|
|
156
|
+
/** Records that a worker split its issue, so follow-up work stays traceable
|
|
157
|
+
* to the request that spawned it. */
|
|
158
|
+
linkParent(child: number, parent: number): Promise<void>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Execution state is separate from the tracker's own labels on purpose: labels
|
|
163
|
+
* are coarse and human-editable, while the loop needs to distinguish "pushed
|
|
164
|
+
* and green, waiting on merge" from "merged" to decide what to do on restart.
|
|
165
|
+
*/
|
|
166
|
+
export type RunState =
|
|
167
|
+
| "claimed"
|
|
168
|
+
| "running"
|
|
169
|
+
| "pushed-green"
|
|
170
|
+
| "merged"
|
|
171
|
+
| "blocked"
|
|
172
|
+
| "failed"
|
|
173
|
+
| "killed";
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One attempt at one issue. Persisted so a daemon restart can reconcile
|
|
177
|
+
* orphaned worktrees and branches instead of leaking them.
|
|
178
|
+
*/
|
|
179
|
+
export interface RunRecord {
|
|
180
|
+
id: string;
|
|
181
|
+
project: string;
|
|
182
|
+
issue: number;
|
|
183
|
+
/** `RepoTarget.name` this attempt was routed to. */
|
|
184
|
+
repo: string;
|
|
185
|
+
branch: string;
|
|
186
|
+
worktree: string;
|
|
187
|
+
state: RunState;
|
|
188
|
+
/** 1-based attempt number, checked against `Caps.maxAttemptsPerIssue`. */
|
|
189
|
+
attempt: number;
|
|
190
|
+
turns: number;
|
|
191
|
+
spendUsd: number;
|
|
192
|
+
/** omp session transcript, so a human can read what the worker actually did. */
|
|
193
|
+
sessionFile?: string;
|
|
194
|
+
prUrl?: string;
|
|
195
|
+
startedAt: number;
|
|
196
|
+
endedAt?: number;
|
|
197
|
+
/** Last failure text, surfaced verbatim in escalations. */
|
|
198
|
+
lastError?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bookkeeping only — GitHub labels remain the source of truth. The store
|
|
203
|
+
* exists to answer cap questions cheaply and to survive a restart; if it is
|
|
204
|
+
* ever lost, the tracker can rebuild the world.
|
|
205
|
+
*/
|
|
206
|
+
export interface Store {
|
|
207
|
+
createRun(r: Omit<RunRecord, "id">): RunRecord;
|
|
208
|
+
updateRun(id: string, patch: Partial<RunRecord>): void;
|
|
209
|
+
getRun(id: string): RunRecord | undefined;
|
|
210
|
+
activeRuns(project: string): RunRecord[];
|
|
211
|
+
attemptsFor(project: string, issue: number): number;
|
|
212
|
+
runsStartedSince(project: string, sinceEpochMs: number): number;
|
|
213
|
+
spendSince(project: string, sinceEpochMs: number): number;
|
|
214
|
+
/** Idempotence guard so a retry loop cannot page a human repeatedly for the
|
|
215
|
+
* same event. */
|
|
216
|
+
wasNotified(key: string): boolean;
|
|
217
|
+
markNotified(key: string): void;
|
|
218
|
+
close(): void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* How loudly to interrupt: tier 1 is "answer when you can" (the run is parked
|
|
223
|
+
* and safe), tier 2 is "the fleet is stopped until you look".
|
|
224
|
+
*/
|
|
225
|
+
export type EscalationTier = 1 | 2;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A message to a human, carrying enough identity to find the run without
|
|
229
|
+
* pasting logs into a chat window.
|
|
230
|
+
*/
|
|
231
|
+
export interface Escalation {
|
|
232
|
+
tier: EscalationTier;
|
|
233
|
+
project: string;
|
|
234
|
+
issue: number;
|
|
235
|
+
summary: string;
|
|
236
|
+
detail?: string;
|
|
237
|
+
runId?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Baseline limits used when a project omits `caps`. Data, not behaviour: kept
|
|
242
|
+
* beside the type so the defaults cannot drift out of shape with it.
|
|
243
|
+
*/
|
|
244
|
+
export const DEFAULT_CAPS: Caps = {
|
|
245
|
+
maxConcurrentWorkers: 2,
|
|
246
|
+
dailySpendUsd: 25,
|
|
247
|
+
workerMaxTurns: 120,
|
|
248
|
+
workerWallClockMs: 90 * 60 * 1000,
|
|
249
|
+
maxAttemptsPerIssue: 2,
|
|
250
|
+
};
|