omp-conductor 0.3.18 → 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 +171 -3
- 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. */
|
|
@@ -73,6 +80,23 @@ interface ClosersResponse {
|
|
|
73
80
|
} | null;
|
|
74
81
|
}
|
|
75
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
|
+
|
|
76
100
|
/** Carries the captured stderr so callers can classify a failure without
|
|
77
101
|
* re-running the command or parsing the message text of a plain Error. */
|
|
78
102
|
class GhError extends Error {
|
|
@@ -169,7 +193,98 @@ export function firstOpenCloser(raw: string): string | undefined {
|
|
|
169
193
|
* be answered, confidently, about some other repository's pull request. A
|
|
170
194
|
* settle sweep that acted on that answer would mark the wrong run merged.
|
|
171
195
|
*/
|
|
172
|
-
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
|
+
}
|
|
173
288
|
|
|
174
289
|
/**
|
|
175
290
|
* GitHub's PR state spelling mapped onto {@link PrState}.
|
|
@@ -195,6 +310,18 @@ export function prStateFrom(raw: string): PrState | undefined {
|
|
|
195
310
|
}
|
|
196
311
|
}
|
|
197
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
|
+
|
|
198
325
|
/**
|
|
199
326
|
* Parent number from a raw GraphQL response, or undefined when the issue has
|
|
200
327
|
* no parent. Throws when the issue or claimed parent is malformed — admission
|
|
@@ -329,6 +456,16 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
329
456
|
return firstOpenCloser(raw);
|
|
330
457
|
},
|
|
331
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
|
+
|
|
332
469
|
async prState(url: string): Promise<PrState | undefined> {
|
|
333
470
|
// No `--repo`: a full URL is self-locating, and verified so on gh 2.97.0
|
|
334
471
|
// from a directory that is not a git repository at all — which is exactly
|
|
@@ -347,5 +484,36 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
347
484
|
return undefined;
|
|
348
485
|
}
|
|
349
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
|
+
},
|
|
350
518
|
};
|
|
351
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
|
};
|
package/src/unblock.ts
CHANGED
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
* one event that happens outside every run, so no member fits it — folding it
|
|
23
23
|
* into `merged` or `killed` would make `status` describe a run that never
|
|
24
24
|
* reached either. Eligibility is read off the tracker's labels and never off a
|
|
25
|
-
* run row, so the store has nothing to say here. Leaving
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* exists to stop.
|
|
25
|
+
* run row, so the store has nothing to say here. Leaving history alone keeps
|
|
26
|
+
* both budgets honest: a block consumes an operational continuation, while a
|
|
27
|
+
* real implementation failure consumes the separate failed-attempt budget.
|
|
29
28
|
*/
|
|
30
29
|
|
|
31
30
|
import { LIVE_STATES } from "./store.ts";
|
|
@@ -35,8 +34,10 @@ import type { Caps, ProjectConfig, RunRecord, Store, Tracker } from "./types.ts"
|
|
|
35
34
|
export interface UnblockOutcome {
|
|
36
35
|
/** State labels the tracker was asked to drop. */
|
|
37
36
|
cleared: string[];
|
|
38
|
-
/**
|
|
37
|
+
/** Total run segments, retained for history and sequence numbering. */
|
|
39
38
|
attemptsUsed: number;
|
|
39
|
+
failuresUsed: number;
|
|
40
|
+
continuationsUsed: number;
|
|
40
41
|
/** Newest attempt, when the store has one for this issue at all. */
|
|
41
42
|
latest?: RunRecord;
|
|
42
43
|
}
|
|
@@ -72,6 +73,8 @@ export async function unblockIssue(
|
|
|
72
73
|
return {
|
|
73
74
|
cleared,
|
|
74
75
|
attemptsUsed: store.attemptsFor(project.name, issue),
|
|
76
|
+
failuresUsed: store.failuresFor(project.name, issue),
|
|
77
|
+
continuationsUsed: store.continuationsFor(project.name, issue),
|
|
75
78
|
...(latest === undefined ? {} : { latest }),
|
|
76
79
|
};
|
|
77
80
|
}
|
|
@@ -82,17 +85,21 @@ export async function unblockIssue(
|
|
|
82
85
|
* and a spent attempt budget makes the next tick escalate rather than dispatch.
|
|
83
86
|
* Either promised blindly would send someone away believing work had resumed.
|
|
84
87
|
*/
|
|
85
|
-
export function formatUnblock(
|
|
88
|
+
export function formatUnblock(
|
|
89
|
+
issue: number,
|
|
90
|
+
o: UnblockOutcome,
|
|
91
|
+
project: ProjectConfig,
|
|
92
|
+
caps: Caps,
|
|
93
|
+
): string {
|
|
86
94
|
const latest = o.latest;
|
|
87
95
|
const lines = [`#${issue}: cleared ${o.cleared.join(", ")}`];
|
|
88
96
|
|
|
89
97
|
if (latest === undefined) {
|
|
90
|
-
lines.push("
|
|
98
|
+
lines.push(" runs none recorded — the labels were cleared anyway; eligibility is read off the tracker");
|
|
91
99
|
} else {
|
|
92
|
-
lines.push(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
100
|
+
lines.push(` runs ${o.attemptsUsed}, newest ${latest.state}`);
|
|
101
|
+
lines.push(` failures ${o.failuresUsed} of ${caps.maxAttemptsPerIssue}`);
|
|
102
|
+
lines.push(` continuations ${o.continuationsUsed} of ${caps.maxContinuationsPerIssue}`);
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
if (latest !== undefined && LIVE_STATES.includes(latest.state)) {
|
|
@@ -100,10 +107,15 @@ export function formatUnblock(issue: number, o: UnblockOutcome, project: Project
|
|
|
100
107
|
` in flight attempt ${latest.attempt} is ${latest.state}, so the issue keeps ` +
|
|
101
108
|
`"${project.stateLabels.inProgress}" until it ends — nothing is re-claimed before then`,
|
|
102
109
|
);
|
|
103
|
-
} else if (o.
|
|
110
|
+
} else if (o.failuresUsed >= caps.maxAttemptsPerIssue) {
|
|
111
|
+
lines.push(
|
|
112
|
+
` next tick not eligible: all ${caps.maxAttemptsPerIssue} failed attempts are spent. ` +
|
|
113
|
+
"Rewrite the issue or raise maxAttemptsPerIssue.",
|
|
114
|
+
);
|
|
115
|
+
} else if (o.continuationsUsed > caps.maxContinuationsPerIssue) {
|
|
104
116
|
lines.push(
|
|
105
|
-
` next tick not eligible:
|
|
106
|
-
"
|
|
117
|
+
` next tick not eligible: the ${caps.maxContinuationsPerIssue}-continuation budget was exceeded. ` +
|
|
118
|
+
"Inspect progress or raise maxContinuationsPerIssue.",
|
|
107
119
|
);
|
|
108
120
|
} else {
|
|
109
121
|
lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
|