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.
@@ -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. */
@@ -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
- /** 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
  };
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 the history alone is
26
- * also what keeps `maxAttemptsPerIssue` honest: an answered block still spent a
27
- * worker's whole budget, and the same question answered twice is a loop the cap
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
- /** Attempts this issue has already spent. Unchanged by the unblock. */
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(issue: number, o: UnblockOutcome, project: ProjectConfig, caps: Caps): string {
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(" attempts none recorded — the labels were cleared anyway; eligibility is read off the tracker");
98
+ lines.push(" runs none recorded — the labels were cleared anyway; eligibility is read off the tracker");
91
99
  } else {
92
- lines.push(
93
- ` attempts ${o.attemptsUsed} of ${caps.maxAttemptsPerIssue} used, newest ${latest.state} — ` +
94
- "unchanged, an answered block still spent a worker",
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.attemptsUsed >= caps.maxAttemptsPerIssue) {
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: all ${caps.maxAttemptsPerIssue} attempts are spent, so the next tick escalates ` +
106
- "instead of re-claiming. Raise maxAttemptsPerIssue with /conductor setup, or rewrite the issue.",
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}"`);