omp-conductor 0.3.17 → 0.3.19
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/README.md +344 -179
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +136 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +211 -22
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +327 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
package/src/tracker/github.ts
CHANGED
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
* ~200-400ms) and failure classification by matching human-readable stderr
|
|
11
11
|
* instead of reading a status code. Upgrade path when either bites: replace the
|
|
12
12
|
* body of `gh()` with `fetch("https://api.github.com/...")` using a token from
|
|
13
|
-
* `gh auth token`; the
|
|
13
|
+
* `gh auth token`; the eleven Tracker methods above it stay untouched.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
IssueState,
|
|
18
|
+
PrState,
|
|
19
|
+
PrVerification,
|
|
20
|
+
ProjectConfig,
|
|
21
|
+
ReadyIssue,
|
|
22
|
+
Tracker,
|
|
23
|
+
} from "../types.ts";
|
|
17
24
|
|
|
18
25
|
/** The subset of `gh issue list --json` output this adapter reads. Fields the
|
|
19
26
|
* API can return as null are typed as such so the mapping has to handle it. */
|
|
@@ -53,6 +60,12 @@ const CLOSERS_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
|
|
|
53
60
|
}
|
|
54
61
|
}`;
|
|
55
62
|
|
|
63
|
+
const PARENT_QUERY = `query($owner:String!,$repo:String!,$n:Int!){
|
|
64
|
+
repository(owner:$owner,name:$repo){
|
|
65
|
+
issue(number:$n){ parent{ number } }
|
|
66
|
+
}
|
|
67
|
+
}`;
|
|
68
|
+
|
|
56
69
|
/** The `gh api graphql` envelope for {@link CLOSERS_QUERY}. Every level is
|
|
57
70
|
* nullable: a deleted or wrong-numbered issue answers `null`, not an error. */
|
|
58
71
|
interface ClosersResponse {
|
|
@@ -67,6 +80,23 @@ interface ClosersResponse {
|
|
|
67
80
|
} | null;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
interface GhCheck {
|
|
84
|
+
__typename?: string;
|
|
85
|
+
name?: string;
|
|
86
|
+
context?: string;
|
|
87
|
+
status?: string;
|
|
88
|
+
conclusion?: string;
|
|
89
|
+
state?: string;
|
|
90
|
+
detailsUrl?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface GhPrVerification {
|
|
94
|
+
state?: string;
|
|
95
|
+
isDraft?: boolean;
|
|
96
|
+
headRefOid?: string;
|
|
97
|
+
statusCheckRollup?: GhCheck[] | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
/** Carries the captured stderr so callers can classify a failure without
|
|
71
101
|
* re-running the command or parsing the message text of a plain Error. */
|
|
72
102
|
class GhError extends Error {
|
|
@@ -163,7 +193,98 @@ export function firstOpenCloser(raw: string): string | undefined {
|
|
|
163
193
|
* be answered, confidently, about some other repository's pull request. A
|
|
164
194
|
* settle sweep that acted on that answer would mark the wrong run merged.
|
|
165
195
|
*/
|
|
166
|
-
const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d
|
|
196
|
+
const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/;
|
|
197
|
+
|
|
198
|
+
type CheckVerdict = PrVerification["status"];
|
|
199
|
+
|
|
200
|
+
function checkName(check: GhCheck): string {
|
|
201
|
+
return check.name ?? check.context ?? "(unnamed check)";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checkVerdict(check: GhCheck): CheckVerdict {
|
|
205
|
+
if (check.__typename === "StatusContext" || check.state !== undefined) {
|
|
206
|
+
if (check.state === "SUCCESS") return "green";
|
|
207
|
+
if (check.state === "PENDING" || check.state === "EXPECTED") return "pending";
|
|
208
|
+
return "failed";
|
|
209
|
+
}
|
|
210
|
+
if (check.status !== "COMPLETED") return "pending";
|
|
211
|
+
if (check.conclusion === "SUCCESS" || check.conclusion === "SKIPPED") return "green";
|
|
212
|
+
return "failed";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
|
|
217
|
+
* payload against the worker-observed head. A missing rollup is pending rather
|
|
218
|
+
* than green because GitHub may not have created the checks yet.
|
|
219
|
+
*/
|
|
220
|
+
export function prVerificationFrom(raw: string, expectedHead: string): PrVerification {
|
|
221
|
+
const pr = JSON.parse(raw) as GhPrVerification;
|
|
222
|
+
if (pr.state !== "OPEN") {
|
|
223
|
+
return { status: "failed", reason: `PR is ${pr.state ?? "unknown"}, expected OPEN` };
|
|
224
|
+
}
|
|
225
|
+
if (pr.isDraft !== false) {
|
|
226
|
+
return { status: "failed", reason: "PR is draft or draft state is unknown" };
|
|
227
|
+
}
|
|
228
|
+
if (pr.headRefOid?.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
229
|
+
return {
|
|
230
|
+
status: "failed",
|
|
231
|
+
reason: `PR head changed: expected ${expectedHead}, found ${pr.headRefOid ?? "unknown"}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const checks = pr.statusCheckRollup ?? [];
|
|
236
|
+
if (checks.length === 0) {
|
|
237
|
+
return { status: "pending", reason: "GitHub has not reported any checks yet" };
|
|
238
|
+
}
|
|
239
|
+
const failed = checks.filter((check) => checkVerdict(check) === "failed");
|
|
240
|
+
if (failed.length > 0) {
|
|
241
|
+
return {
|
|
242
|
+
status: "failed",
|
|
243
|
+
reason: `Checks failed: ${failed.map((check) => `${checkName(check)} (${check.conclusion ?? check.state ?? "unknown"})`).join(", ")}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const pending = checks.filter((check) => checkVerdict(check) === "pending");
|
|
247
|
+
if (pending.length > 0) {
|
|
248
|
+
return {
|
|
249
|
+
status: "pending",
|
|
250
|
+
reason: `Checks pending: ${pending.map(checkName).join(", ")}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { status: "green", reason: `${checks.length} checks succeeded or were skipped` };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function failedCheck(raw: string): GhCheck | undefined {
|
|
257
|
+
const checks = (JSON.parse(raw) as GhPrVerification).statusCheckRollup ?? [];
|
|
258
|
+
return checks.find((check) => checkVerdict(check) === "failed");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function runLogArgs(detailsUrl: string): string[] | undefined {
|
|
262
|
+
const match =
|
|
263
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/.exec(
|
|
264
|
+
detailsUrl,
|
|
265
|
+
);
|
|
266
|
+
if (match?.[1] === undefined || match[2] === undefined) return undefined;
|
|
267
|
+
return [
|
|
268
|
+
"run",
|
|
269
|
+
"view",
|
|
270
|
+
match[2],
|
|
271
|
+
"--repo",
|
|
272
|
+
match[1],
|
|
273
|
+
...(match[3] === undefined ? [] : ["--job", match[3]]),
|
|
274
|
+
"--log-failed",
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function conciseLog(raw: string): string | undefined {
|
|
279
|
+
const lines = raw
|
|
280
|
+
.replaceAll(/\u001b\[[0-9;]*m/g, "")
|
|
281
|
+
.split("\n")
|
|
282
|
+
.map((line) => line.trimEnd())
|
|
283
|
+
.filter((line) => line.trim() !== "")
|
|
284
|
+
.slice(-8)
|
|
285
|
+
.map((line) => line.slice(0, 240));
|
|
286
|
+
return lines.length === 0 ? undefined : lines.join("\n");
|
|
287
|
+
}
|
|
167
288
|
|
|
168
289
|
/**
|
|
169
290
|
* GitHub's PR state spelling mapped onto {@link PrState}.
|
|
@@ -189,28 +310,43 @@ export function prStateFrom(raw: string): PrState | undefined {
|
|
|
189
310
|
}
|
|
190
311
|
}
|
|
191
312
|
|
|
313
|
+
/** GitHub's issue state spelling, kept fail-closed for cleanup decisions. */
|
|
314
|
+
export function issueStateFrom(raw: string): IssueState | undefined {
|
|
315
|
+
switch (raw.trim()) {
|
|
316
|
+
case "OPEN":
|
|
317
|
+
return "open";
|
|
318
|
+
case "CLOSED":
|
|
319
|
+
return "closed";
|
|
320
|
+
default:
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
192
325
|
/**
|
|
193
|
-
* Parent number from a
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* "no parent" and skip the epic soft-cap.
|
|
326
|
+
* Parent number from a raw GraphQL response, or undefined when the issue has
|
|
327
|
+
* no parent. Throws when the issue or claimed parent is malformed — admission
|
|
328
|
+
* must not turn an unknown relationship into permission to run siblings.
|
|
197
329
|
*/
|
|
198
330
|
export function parentNumberFrom(raw: string): number | undefined {
|
|
199
|
-
const parsed = JSON.parse(raw) as {
|
|
200
|
-
|
|
201
|
-
|
|
331
|
+
const parsed = JSON.parse(raw) as {
|
|
332
|
+
data?: { repository?: { issue?: { parent?: { number?: unknown } | null } | null } | null };
|
|
333
|
+
};
|
|
334
|
+
const issue = parsed.data?.repository?.issue;
|
|
335
|
+
if (issue == null) throw new Error("unexpected missing issue in parent response");
|
|
336
|
+
if (issue.parent == null) return undefined;
|
|
337
|
+
const n = issue.parent.number;
|
|
202
338
|
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
203
339
|
throw new Error(`unexpected parent number ${JSON.stringify(n)}`);
|
|
204
340
|
}
|
|
205
341
|
return n;
|
|
206
342
|
}
|
|
207
343
|
|
|
208
|
-
export function makeTracker(p: ProjectConfig): Tracker {
|
|
344
|
+
export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
209
345
|
const repo = p.tracker.repo;
|
|
210
346
|
|
|
211
347
|
return {
|
|
212
348
|
async listReady(): Promise<ReadyIssue[]> {
|
|
213
|
-
const raw = await
|
|
349
|
+
const raw = await runGh([
|
|
214
350
|
"issue",
|
|
215
351
|
"list",
|
|
216
352
|
"--repo",
|
|
@@ -246,7 +382,7 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
246
382
|
|
|
247
383
|
async addLabel(issue: number, label: string): Promise<void> {
|
|
248
384
|
try {
|
|
249
|
-
await
|
|
385
|
+
await runGh(["issue", "edit", String(issue), "--repo", repo, "--add-label", label]);
|
|
250
386
|
} catch (err) {
|
|
251
387
|
if (!isLabelNoop(err, "add")) throw err;
|
|
252
388
|
}
|
|
@@ -254,7 +390,7 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
254
390
|
|
|
255
391
|
async removeLabel(issue: number, label: string): Promise<void> {
|
|
256
392
|
try {
|
|
257
|
-
await
|
|
393
|
+
await runGh(["issue", "edit", String(issue), "--repo", repo, "--remove-label", label]);
|
|
258
394
|
} catch (err) {
|
|
259
395
|
if (!isLabelNoop(err, "remove")) throw err;
|
|
260
396
|
}
|
|
@@ -263,25 +399,37 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
263
399
|
async comment(issue: number, body: string): Promise<void> {
|
|
264
400
|
// `--body-file -` reads stdin, so the body is never shell- or argv-
|
|
265
401
|
// mangled and has no length limit worth worrying about.
|
|
266
|
-
await
|
|
402
|
+
await runGh(["issue", "comment", String(issue), "--repo", repo, "--body-file", "-"], body);
|
|
267
403
|
},
|
|
268
404
|
|
|
269
405
|
async close(issue: number): Promise<void> {
|
|
270
|
-
await
|
|
406
|
+
await runGh(["issue", "close", String(issue), "--repo", repo]);
|
|
271
407
|
},
|
|
272
408
|
|
|
273
409
|
async linkParent(child: number, parent: number): Promise<void> {
|
|
274
410
|
// Native sub-issue linkage rather than a body mention: it is what the
|
|
275
411
|
// repo's own epic rollups read, so a human sees the split without us
|
|
276
412
|
// maintaining a second index of it.
|
|
277
|
-
await
|
|
413
|
+
await runGh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
|
|
278
414
|
},
|
|
279
415
|
|
|
280
416
|
async parentOf(issue: number): Promise<number | undefined> {
|
|
281
|
-
//
|
|
282
|
-
//
|
|
417
|
+
// gh 2.86 cannot expose `parent` through `issue view --json`; its raw
|
|
418
|
+
// GraphQL command can, and is already the adapter's path for PR closers.
|
|
419
|
+
const [owner = "", name = ""] = repo.split("/");
|
|
283
420
|
return parentNumberFrom(
|
|
284
|
-
await
|
|
421
|
+
await runGh([
|
|
422
|
+
"api",
|
|
423
|
+
"graphql",
|
|
424
|
+
"-f",
|
|
425
|
+
`query=${PARENT_QUERY}`,
|
|
426
|
+
"-F",
|
|
427
|
+
`owner=${owner}`,
|
|
428
|
+
"-F",
|
|
429
|
+
`repo=${name}`,
|
|
430
|
+
"-F",
|
|
431
|
+
`n=${issue}`,
|
|
432
|
+
]),
|
|
285
433
|
);
|
|
286
434
|
},
|
|
287
435
|
|
|
@@ -290,7 +438,7 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
290
438
|
// that spelling, so an empty half means a hand-edited config: `gh` then
|
|
291
439
|
// errors and the caller holds the candidate rather than guessing.
|
|
292
440
|
const [owner = "", name = ""] = repo.split("/");
|
|
293
|
-
const raw = await
|
|
441
|
+
const raw = await runGh([
|
|
294
442
|
"api",
|
|
295
443
|
"graphql",
|
|
296
444
|
"-f",
|
|
@@ -308,6 +456,16 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
308
456
|
return firstOpenCloser(raw);
|
|
309
457
|
},
|
|
310
458
|
|
|
459
|
+
async issueState(issue: number): Promise<IssueState | undefined> {
|
|
460
|
+
try {
|
|
461
|
+
return issueStateFrom(
|
|
462
|
+
await runGh(["issue", "view", String(issue), "--repo", repo, "--json", "state", "--jq", ".state"]),
|
|
463
|
+
);
|
|
464
|
+
} catch {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
|
|
311
469
|
async prState(url: string): Promise<PrState | undefined> {
|
|
312
470
|
// No `--repo`: a full URL is self-locating, and verified so on gh 2.97.0
|
|
313
471
|
// from a directory that is not a git repository at all — which is exactly
|
|
@@ -316,7 +474,7 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
316
474
|
// what the URL already says.
|
|
317
475
|
if (!PR_URL.test(url)) return undefined;
|
|
318
476
|
try {
|
|
319
|
-
return prStateFrom(await
|
|
477
|
+
return prStateFrom(await runGh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
|
|
320
478
|
} catch {
|
|
321
479
|
// Never throws, per the port's contract. A deleted PR, a revoked token
|
|
322
480
|
// and a flaky network all mean "could not tell", and the caller's whole
|
|
@@ -326,5 +484,36 @@ export function makeTracker(p: ProjectConfig): Tracker {
|
|
|
326
484
|
return undefined;
|
|
327
485
|
}
|
|
328
486
|
},
|
|
487
|
+
|
|
488
|
+
async verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined> {
|
|
489
|
+
if (!PR_URL.test(url)) return undefined;
|
|
490
|
+
try {
|
|
491
|
+
const raw = await runGh([
|
|
492
|
+
"pr",
|
|
493
|
+
"view",
|
|
494
|
+
url,
|
|
495
|
+
"--json",
|
|
496
|
+
"state,isDraft,headRefOid,statusCheckRollup",
|
|
497
|
+
]);
|
|
498
|
+
const verification = prVerificationFrom(raw, expectedHead);
|
|
499
|
+
if (verification.status !== "failed") return verification;
|
|
500
|
+
|
|
501
|
+
const detailsUrl = failedCheck(raw)?.detailsUrl;
|
|
502
|
+
const args = detailsUrl === undefined ? undefined : runLogArgs(detailsUrl);
|
|
503
|
+
if (args === undefined) return verification;
|
|
504
|
+
try {
|
|
505
|
+
const digest = conciseLog(await runGh(args));
|
|
506
|
+
return digest === undefined
|
|
507
|
+
? verification
|
|
508
|
+
: { ...verification, reason: `${verification.reason}\n${digest}` };
|
|
509
|
+
} catch {
|
|
510
|
+
return verification;
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
// Lookup or payload failure is not permission to accept success. The
|
|
514
|
+
// daemon records a pending row and asks again on a later tick.
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
},
|
|
329
518
|
};
|
|
330
519
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Read one property off an unvalidated transcript entry. */
|
|
2
|
+
function prop(source: unknown, key: string): unknown {
|
|
3
|
+
if (source === null || typeof source !== "object") return undefined;
|
|
4
|
+
return Reflect.get(source, key);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One transcript line rendered for somebody watching, or `undefined` for the
|
|
9
|
+
* lines not worth a row: thinking blocks, tool results, session metadata, and
|
|
10
|
+
* anything this parser does not recognise.
|
|
11
|
+
*
|
|
12
|
+
* Defensive throughout. The transcript is written by the harness, not by this
|
|
13
|
+
* package, so its shape is a peer dependency's business and can gain entry
|
|
14
|
+
* types without warning.
|
|
15
|
+
*/
|
|
16
|
+
export function formatTranscriptLine(line: string): string | undefined {
|
|
17
|
+
let entry: unknown;
|
|
18
|
+
try {
|
|
19
|
+
entry = JSON.parse(line);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (prop(entry, "type") !== "message") return undefined;
|
|
24
|
+
const message = prop(entry, "message");
|
|
25
|
+
if (prop(message, "role") !== "assistant") return undefined;
|
|
26
|
+
|
|
27
|
+
const content = prop(message, "content");
|
|
28
|
+
if (typeof content === "string") {
|
|
29
|
+
return content.trim() === "" ? undefined : `assistant: ${content.trim()}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const block of blocks) {
|
|
35
|
+
const type = prop(block, "type");
|
|
36
|
+
if (type === "text") {
|
|
37
|
+
const text = prop(block, "text");
|
|
38
|
+
if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
|
|
39
|
+
} else if (type === "toolCall") {
|
|
40
|
+
const name = prop(block, "name");
|
|
41
|
+
if (typeof name === "string" && name !== "") out.push(`tool: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out.length === 0 ? undefined : out.join("\n");
|
|
45
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -27,9 +27,12 @@ export interface Caps {
|
|
|
27
27
|
/** Wall-clock ceiling for one worker (90 min): a session that is merely
|
|
28
28
|
* stuck spends no turns, so turns alone cannot detect it. */
|
|
29
29
|
workerWallClockMs: number;
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
30
|
+
/** Failed implementation/CI attempts allowed before escalation. Operational
|
|
31
|
+
* continuations do not consume this budget. */
|
|
32
32
|
maxAttemptsPerIssue: number;
|
|
33
|
+
/** Cap-kill, daemon-orphan and answered-block continuations allowed before a
|
|
34
|
+
* crash/resume loop escalates independently of implementation failures. */
|
|
35
|
+
maxContinuationsPerIssue: number;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -93,6 +96,18 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
|
93
96
|
*/
|
|
94
97
|
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Mechanical permission for release-shaped tool calls. `authority.release`
|
|
101
|
+
* says who owns the decision; this key is the enforcement gate that decides
|
|
102
|
+
* whether an autonomous session may invoke the tools at all.
|
|
103
|
+
*/
|
|
104
|
+
export const RELEASE_POLICIES = ["none", "operator-brief"] as const;
|
|
105
|
+
|
|
106
|
+
export type ReleasePolicy = (typeof RELEASE_POLICIES)[number];
|
|
107
|
+
|
|
108
|
+
/** Safe for old and partial configs: release/deploy tools stay closed. */
|
|
109
|
+
export const DEFAULT_RELEASE_POLICY: ReleasePolicy = "none";
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* Who holds an authority the daemon itself never exercises. Declared as data
|
|
98
113
|
* for the same reason as {@link REPORT_SCOPES}: the validator, the wizard and
|
|
@@ -158,6 +173,12 @@ export interface ProjectConfig {
|
|
|
158
173
|
* them is holding the merge button.
|
|
159
174
|
*/
|
|
160
175
|
authority: { merge: AuthorityHolder; release: AuthorityHolder };
|
|
176
|
+
/**
|
|
177
|
+
* Tool-call enforcement for releases and deploys. Optional only for configs
|
|
178
|
+
* written before the tripwire existed; omission resolves fail-closed to
|
|
179
|
+
* {@link DEFAULT_RELEASE_POLICY}.
|
|
180
|
+
*/
|
|
181
|
+
releasePolicy?: ReleasePolicy;
|
|
161
182
|
/**
|
|
162
183
|
* How loud the orchestrator is. Optional on disk — a config written before
|
|
163
184
|
* this key existed loads as {@link DEFAULT_REPORT_SCOPE} — so read it through
|
|
@@ -217,6 +238,15 @@ export interface ReadyIssue {
|
|
|
217
238
|
*/
|
|
218
239
|
export type PrState = "merged" | "closed" | "open";
|
|
219
240
|
|
|
241
|
+
/** Result of independently checking a worker's claimed green pull request. */
|
|
242
|
+
export interface PrVerification {
|
|
243
|
+
status: "green" | "pending" | "failed";
|
|
244
|
+
reason: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Tracker lifecycle state for an issue. Undefined means the adapter could not tell. */
|
|
248
|
+
export type IssueState = "open" | "closed";
|
|
249
|
+
|
|
220
250
|
/**
|
|
221
251
|
* Deliberately narrow so a Gitea or local-file tracker can drop in later.
|
|
222
252
|
* Nothing here is GitHub-shaped; the GitHub adapter owns `gh` entirely.
|
|
@@ -252,6 +282,11 @@ export interface Tracker {
|
|
|
252
282
|
* only party that remembers across all of those.
|
|
253
283
|
*/
|
|
254
284
|
openCloserFor(issue: number): Promise<string | undefined>;
|
|
285
|
+
/**
|
|
286
|
+
* Whether an issue is still open, or undefined when tracker/network state is
|
|
287
|
+
* ambiguous. Cleanup must never interpret undefined as permission to delete.
|
|
288
|
+
*/
|
|
289
|
+
issueState(issue: number): Promise<IssueState | undefined>;
|
|
255
290
|
/**
|
|
256
291
|
* The state of one specific pull request, or undefined when this adapter
|
|
257
292
|
* could not tell — a network failure, a deleted PR, a URL it cannot parse.
|
|
@@ -265,6 +300,11 @@ export interface Tracker {
|
|
|
265
300
|
* is how a PR a human rejected gets recorded as merged.
|
|
266
301
|
*/
|
|
267
302
|
prState(url: string): Promise<PrState | undefined>;
|
|
303
|
+
/**
|
|
304
|
+
* Verify that a worker's pull request is open, ready, still at the reported
|
|
305
|
+
* head, and has a non-empty terminal-success check rollup.
|
|
306
|
+
*/
|
|
307
|
+
verifyPr(url: string, expectedHead: string): Promise<PrVerification | undefined>;
|
|
268
308
|
}
|
|
269
309
|
|
|
270
310
|
/**
|
|
@@ -276,6 +316,8 @@ export type RunState =
|
|
|
276
316
|
| "claimed"
|
|
277
317
|
| "running"
|
|
278
318
|
| "pushed-green"
|
|
319
|
+
/** Worker finished, but GitHub has not yet produced a terminal check verdict. */
|
|
320
|
+
| "pushed-pending"
|
|
279
321
|
/** Its PR landed. Written by the tick's settle sweep, never by a worker: only
|
|
280
322
|
* the tracker knows, and it knows minutes to days after the run ended. */
|
|
281
323
|
| "merged"
|
|
@@ -301,16 +343,93 @@ export interface RunRecord {
|
|
|
301
343
|
/** 1-based attempt number, checked against `Caps.maxAttemptsPerIssue`. */
|
|
302
344
|
attempt: number;
|
|
303
345
|
turns: number;
|
|
346
|
+
/** Effective turn ceiling for this run; operators may only raise it. */
|
|
347
|
+
maxTurns: number;
|
|
304
348
|
spendUsd: number;
|
|
305
349
|
/** omp session transcript, so a human can read what the worker actually did. */
|
|
306
350
|
sessionFile?: string;
|
|
307
351
|
prUrl?: string;
|
|
352
|
+
/** Pull request head the worker observed after its deterministic CI watcher exited. */
|
|
353
|
+
headSha?: string;
|
|
308
354
|
startedAt: number;
|
|
309
355
|
endedAt?: number;
|
|
310
356
|
/** Last failure text, surfaced verbatim in escalations. */
|
|
311
357
|
lastError?: string;
|
|
312
358
|
}
|
|
313
359
|
|
|
360
|
+
export type AdmissionHoldReason =
|
|
361
|
+
| "capacity"
|
|
362
|
+
| "issue-active"
|
|
363
|
+
| "failed-attempts"
|
|
364
|
+
| "continuations"
|
|
365
|
+
| "parent-lookup-error"
|
|
366
|
+
| "sibling-active"
|
|
367
|
+
| "open-pr-lookup-error"
|
|
368
|
+
| "open-pr"
|
|
369
|
+
| "daily-spend-cap"
|
|
370
|
+
| "unroutable:no-repo-label"
|
|
371
|
+
| "unroutable:multiple-repo-labels"
|
|
372
|
+
| "unroutable:unknown-repo";
|
|
373
|
+
|
|
374
|
+
/** One bounded reason group from the latest admission pass. */
|
|
375
|
+
export interface AdmissionHoldSummary {
|
|
376
|
+
reason: AdmissionHoldReason;
|
|
377
|
+
count: number;
|
|
378
|
+
/** Queue-order sample, capped before persistence and rendering. */
|
|
379
|
+
issues: number[];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Persisted outcome of the latest completed dispatch tick. */
|
|
383
|
+
export interface DispatchSummary {
|
|
384
|
+
completedAt: number;
|
|
385
|
+
ready: number;
|
|
386
|
+
routed: number;
|
|
387
|
+
admitted: number;
|
|
388
|
+
/** True only for system/API failures, never ordinary policy holds. */
|
|
389
|
+
degraded: boolean;
|
|
390
|
+
holds: AdmissionHoldSummary[];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Admission holds that indicate repairable friction rather than normal flow control. */
|
|
394
|
+
export type FrictionAdmissionReason =
|
|
395
|
+
| "failed-attempts"
|
|
396
|
+
| "continuations"
|
|
397
|
+
| "parent-lookup-error"
|
|
398
|
+
| "open-pr-lookup-error"
|
|
399
|
+
| "unroutable:no-repo-label"
|
|
400
|
+
| "unroutable:multiple-repo-labels"
|
|
401
|
+
| "unroutable:unknown-repo";
|
|
402
|
+
|
|
403
|
+
/** A mechanically observed or explicitly classified source of repeated friction. */
|
|
404
|
+
export type FrictionKind =
|
|
405
|
+
| `admission:${FrictionAdmissionReason}`
|
|
406
|
+
| "feedback:escalation-should-digest"
|
|
407
|
+
| "feedback:report-noise"
|
|
408
|
+
| "feedback:report-surprise";
|
|
409
|
+
|
|
410
|
+
/** One observation persisted into the daily friction rollup. */
|
|
411
|
+
export interface FrictionObservation {
|
|
412
|
+
kind: FrictionKind;
|
|
413
|
+
/** Number of affected candidates/events represented by this observation. */
|
|
414
|
+
occurrences: number;
|
|
415
|
+
issues?: number[];
|
|
416
|
+
issue?: number;
|
|
417
|
+
/** Bounded human-readable evidence, never an unbounded error or transcript. */
|
|
418
|
+
sample?: string;
|
|
419
|
+
at: number;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A repeated signal eligible for the orchestrator's Learning loop. */
|
|
423
|
+
export interface FrictionSignal {
|
|
424
|
+
kind: FrictionKind;
|
|
425
|
+
observations: number;
|
|
426
|
+
occurrences: number;
|
|
427
|
+
issues: number[];
|
|
428
|
+
samples: string[];
|
|
429
|
+
latestAt: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
314
433
|
/**
|
|
315
434
|
* Bookkeeping only — GitHub labels remain the source of truth. The store
|
|
316
435
|
* exists to answer cap questions cheaply and to survive a restart; if it is
|
|
@@ -324,7 +443,17 @@ export interface Store {
|
|
|
324
443
|
activeRuns(project: string): RunRecord[];
|
|
325
444
|
/** Runs backed by a worker process — what capacity counts. Subset of {@link Store.activeRuns}. */
|
|
326
445
|
liveRuns(project: string): RunRecord[];
|
|
446
|
+
/** Failed/killed/orphaned rows whose retained tree has not been reaped. */
|
|
447
|
+
retainedRuns(project: string): RunRecord[];
|
|
448
|
+
/** Newest attempt per issue for the live board. Non-merged work remains
|
|
449
|
+
* visible; merged rows are bounded by the supplied recent-history cutoff. */
|
|
450
|
+
recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
|
|
451
|
+
/** Total run segments, used only for the monotonically increasing run number. */
|
|
327
452
|
attemptsFor(project: string, issue: number): number;
|
|
453
|
+
/** Terminal implementation failures that consume `maxAttemptsPerIssue`. */
|
|
454
|
+
failuresFor(project: string, issue: number): number;
|
|
455
|
+
/** Operational stops that require a bounded continuation resume. */
|
|
456
|
+
continuationsFor(project: string, issue: number): number;
|
|
328
457
|
/** Newest attempt for one issue, whatever state it reached. `omp-conductor
|
|
329
458
|
* tail` resolves an issue number to a transcript through this; the number is
|
|
330
459
|
* what an operator has, the run id is not. */
|
|
@@ -334,6 +463,19 @@ export interface Store {
|
|
|
334
463
|
/** Idempotence guard so a retry loop cannot page a human repeatedly for the
|
|
335
464
|
* same event. */
|
|
336
465
|
wasNotified(key: string): boolean;
|
|
466
|
+
recordDispatch(project: string, summary: DispatchSummary): void;
|
|
467
|
+
latestDispatch(project: string): DispatchSummary | undefined;
|
|
468
|
+
/** Add one bounded observation to the per-day friction rollup. */
|
|
469
|
+
recordFriction(project: string, observation: FrictionObservation): void;
|
|
470
|
+
/** Repeated signals not surfaced within the supplied cooldown window. */
|
|
471
|
+
pendingFriction(
|
|
472
|
+
project: string,
|
|
473
|
+
sinceEpochMs: number,
|
|
474
|
+
minimumObservations: number,
|
|
475
|
+
surfacedBeforeEpochMs: number,
|
|
476
|
+
): FrictionSignal[];
|
|
477
|
+
/** Start the cooldown only after a tick carrying these signals was sent. */
|
|
478
|
+
markFrictionSurfaced(project: string, kinds: readonly FrictionKind[], at: number): void;
|
|
337
479
|
markNotified(key: string): void;
|
|
338
480
|
close(): void;
|
|
339
481
|
}
|
|
@@ -367,4 +509,5 @@ export const DEFAULT_CAPS: Caps = {
|
|
|
367
509
|
workerMaxTurns: 120,
|
|
368
510
|
workerWallClockMs: 90 * 60 * 1000,
|
|
369
511
|
maxAttemptsPerIssue: 2,
|
|
512
|
+
maxContinuationsPerIssue: 2,
|
|
370
513
|
};
|