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.
@@ -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 nine Tracker methods above it stay untouched.
13
+ * `gh auth token`; the eleven Tracker methods above it stay untouched.
14
14
  */
15
15
 
16
- import type { PrState, ProjectConfig, ReadyIssue, Tracker } from "../types.ts";
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 `gh issue view --json parent` payload, or undefined
194
- * when the issue has no parent. Throws on a payload that claims a parent but
195
- * does not carry a usable number admission would otherwise treat garbage as
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 { parent: { number?: unknown } | null };
200
- if (parsed.parent == null) return undefined;
201
- const n = parsed.parent.number;
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 gh([
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 gh(["issue", "edit", String(issue), "--repo", repo, "--add-label", label]);
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 gh(["issue", "edit", String(issue), "--repo", repo, "--remove-label", label]);
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 gh(["issue", "comment", String(issue), "--repo", repo, "--body-file", "-"], body);
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 gh(["issue", "close", String(issue), "--repo", repo]);
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 gh(["issue", "edit", String(parent), "--repo", repo, "--add-sub-issue", String(child)]);
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
- // Native sub-issue parent, not a body scrape. Verified on gh 2.97.0:
282
- // `parent` is null with no link, otherwise `{ number, title, url, ... }`.
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 gh(["issue", "view", String(issue), "--repo", repo, "--json", "parent"]),
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 gh([
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 gh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
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
- /** Retries per issue before it escalates (2): one clean retry recovers from
31
- * flaky CI, a third almost always means the issue itself is underspecified. */
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
  };