omp-conductor 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,7 +103,7 @@ import {
103
103
  type AskResult,
104
104
  } from "./ask.ts";
105
105
  import { deliverOperatorMessage } from "./reports.ts";
106
- import type { ProjectConfig, RecoveryAction, RunRecord } from "./types.ts";
106
+ import type { FailureClass, ProjectConfig, RecoveryAction, RunRecord } from "./types.ts";
107
107
  import { dbPath, openStore } from "./store.ts";
108
108
  import { digestDue, localDayKey } from "./digest-schedule.ts";
109
109
  import { heldNoticeId } from "./notices.ts";
@@ -499,11 +499,47 @@ const AUTONOMOUS_RECOVERY_ACTIONS: Record<RecoveryAction, boolean> = {
499
499
  none: false,
500
500
  };
501
501
 
502
- /** Bounded name-and-count summary shared by the two recovered-row groups. */
503
- function recoveredSummary(group: readonly RunRecord[]): string {
502
+ /** The failure-class family that names a turns/wall-clock cap kill. Only these
503
+ * rows carry an ordinal: a second cap kill is a decomposition verdict rather
504
+ * than a retry candidate, so the prompt must say which attempt it was without
505
+ * the orchestrator re-querying the runs table (#711). */
506
+ const CAP_KILL_CLASSES: Record<string, true> = {
507
+ "turn-cap-progress": true,
508
+ "turn-cap-spinning": true,
509
+ "wall-clock-cap-progress": true,
510
+ "wall-clock-cap-spinning": true,
511
+ };
512
+
513
+ /** Issue-history lookups answering the two questions about a recovered row:
514
+ * has the issue moved on since that row, and how many attempts has it spent.
515
+ * Lazy callbacks so the tick can hand the store straight through and the
516
+ * digest only pays for the rows it actually reports.
517
+ */
518
+ export interface RecoveryDigestContext {
519
+ /** Every attempt of the issue, oldest first — exactly what
520
+ * `Store.runsForIssue` returns. The recovered row itself is among the
521
+ * answers, so "has the issue moved on" is "is the last element this row". */
522
+ runsForIssue(issue: number): readonly RunRecord[];
523
+ }
524
+
525
+ /** Bounded name-and-count summary shared by the two recovered-row groups. A
526
+ * cap-kill row also names which attempt it was of the issue's total, taken
527
+ * from the issue's own attempt history — never from its position in this
528
+ * digest's list (#711).
529
+ */
530
+ function recoveredSummary(
531
+ group: readonly RunRecord[],
532
+ runsForIssue: (issue: number) => readonly RunRecord[],
533
+ ): string {
504
534
  const named = group
505
535
  .slice(0, 5)
506
- .map((r) => `${r.failureClass ?? "unknown"} #${r.issue}`)
536
+ .map((r) => {
537
+ const label = `${r.failureClass ?? "unknown"} #${r.issue}`;
538
+ if (r.failureClass === undefined || CAP_KILL_CLASSES[r.failureClass] !== true) return label;
539
+ const attempts = runsForIssue(r.issue).length;
540
+ if (attempts === 0) return label;
541
+ return `${label} (attempt ${r.attempt} of ${attempts})`;
542
+ })
507
543
  .join(", ");
508
544
  const rest = group.length > 5 ? `, +${group.length - 5} more` : "";
509
545
  return `${group.length} (${named}${rest})`;
@@ -515,12 +551,42 @@ function recoveredSummary(group: readonly RunRecord[]): string {
515
551
  * The orchestrator used to write this paragraph by re-deriving it from the run
516
552
  * rows every tick. Undefined when nothing was recovered: a line reading "0" is
517
553
  * one nobody reads on the day it says 4.
554
+ *
555
+ * With {@link RecoveryDigestContext} supplied, a recovered row whose issue has
556
+ * since gained a newer run — live, pushed-green, or settled to any terminal
557
+ * state — is history, not news, and is dropped before rendering, so a finished
558
+ * recovery is never re-reported as this tick's own (#711).
518
559
  */
519
- export function recoveryDigestLine(recovered: readonly RunRecord[]): string | undefined {
560
+ export function recoveryDigestLine(
561
+ recovered: readonly RunRecord[],
562
+ ctx?: RecoveryDigestContext,
563
+ ): string | undefined {
520
564
  if (recovered.length === 0) return undefined;
565
+ // Memoized per issue: a window can hold two recovered attempts of one issue,
566
+ // and each row must not re-query the store for the same history.
567
+ const runsByIssue = new Map<number, readonly RunRecord[]>();
568
+ const runsForIssue = (issue: number): readonly RunRecord[] => {
569
+ let runs = runsByIssue.get(issue);
570
+ if (runs === undefined) {
571
+ runs = ctx?.runsForIssue(issue) ?? [];
572
+ runsByIssue.set(issue, runs);
573
+ }
574
+ return runs;
575
+ };
576
+ const fresh =
577
+ ctx === undefined
578
+ ? recovered
579
+ : recovered.filter((r) => {
580
+ const runs = runsForIssue(r.issue);
581
+ // The recovered row is still its issue's newest attempt. Any newer
582
+ // run means the issue moved on after this row recovered.
583
+ const latest = runs[runs.length - 1];
584
+ return latest === undefined || latest.id === r.id;
585
+ });
586
+ if (fresh.length === 0) return undefined;
521
587
  const handled: RunRecord[] = [];
522
588
  const triage: RunRecord[] = [];
523
- for (const r of recovered) {
589
+ for (const r of fresh) {
524
590
  if (r.recoveryAction !== undefined && AUTONOMOUS_RECOVERY_ACTIONS[r.recoveryAction]) {
525
591
  handled.push(r);
526
592
  } else {
@@ -529,10 +595,10 @@ export function recoveryDigestLine(recovered: readonly RunRecord[]): string | un
529
595
  }
530
596
  const lines: string[] = [];
531
597
  if (handled.length > 0) {
532
- lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled)} — already handled, do not re-triage these.`);
598
+ lines.push(`Auto-recovered since last tick: ${recoveredSummary(handled, runsForIssue)} — already handled, do not re-triage these.`);
533
599
  }
534
600
  if (triage.length > 0) {
535
- lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage)} — inspect these runs.`);
601
+ lines.push(`Recovered but needs Duty 1 triage: ${recoveredSummary(triage, runsForIssue)} — inspect these runs.`);
536
602
  }
537
603
  return lines.join("\n");
538
604
  }
@@ -560,10 +626,26 @@ export function queueDigestLine(
560
626
  const unroutable = summary.holds
561
627
  .filter((h) => h.reason.startsWith("unroutable:"))
562
628
  .reduce((n, h) => n + h.count, 0);
563
- if (unroutable === 0) {
564
- return `Queue: ${summary.ready} ready, 0 spare ${claimed} in flight. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
629
+ // A residual lifecycle label — a settled run whose state label was never
630
+ // cleared is neither spare nor in flight, and `claimed` counts only
631
+ // live/settling ownership, so name it as Duty 1 reconciliation work rather
632
+ // than folding it into the grooming instruction (#611).
633
+ const staleLifecycle = summary.holds
634
+ .filter((h) => h.reason === "stale-lifecycle")
635
+ .reduce((n, h) => n + h.count, 0);
636
+ let line = `Queue: ${summary.ready} ready, 0 spare — ${claimed} in flight`;
637
+ if (unroutable > 0) {
638
+ line += `, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label)`;
639
+ }
640
+ if (staleLifecycle > 0) {
641
+ line += `, ${staleLifecycle} with a residual lifecycle label (Duty 1: newest run terminal — reconcile the stale agent:in-progress/blocked/failed state)`;
565
642
  }
566
- return `Queue: ${summary.ready} ready, 0 spare ${claimed} in flight, ${unroutable} unroutable (each unroutable issue needs exactly one "${labelPrefix}<repo>" label).`;
643
+ if (unroutable === 0 && staleLifecycle === 0) {
644
+ line += `. Spare depth is what dispatch can actually claim: groom the backlog (Duty 2) before the live runs settle.`;
645
+ } else {
646
+ line += ".";
647
+ }
648
+ return line;
567
649
  }
568
650
  if (summary.routed >= groomBelow) return undefined;
569
651
  let line = `Queue: running low — ${summary.routed} routable candidate(s), below the grooming trigger of ${groomBelow}.`;
@@ -2497,8 +2579,18 @@ async function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session:
2497
2579
  // What the daemon already fixed, so the session stops re-deriving that
2498
2580
  // paragraph on every tick (#132). Two intervals wide rather than one: a
2499
2581
  // tick that ran long must not drop the window it was meant to report.
2582
+ // The issue history answers which of those rows are still news: a
2583
+ // recovery whose issue has since moved on is dropped inside the digest
2584
+ // (#711). The project name and store are captured consts so the lookup
2585
+ // callback keeps the flow narrowing the direct calls in this block rely
2586
+ // on.
2587
+ const projectName = scope.projectName;
2588
+ const store = frictionStore;
2500
2589
  const recovered = recoveryDigestLine(
2501
- frictionStore.recoveredSince(scope.projectName, now - 2 * config.intervalSeconds * 1_000),
2590
+ store.recoveredSince(projectName, now - 2 * config.intervalSeconds * 1_000),
2591
+ {
2592
+ runsForIssue: (issue) => store.runsForIssue(projectName, issue),
2593
+ },
2502
2594
  );
2503
2595
  if (recovered !== undefined) content = `${content}\n${recovered}`;
2504
2596
  // The dispatch pass already persists the numbers that answer "is the
package/src/routing.ts CHANGED
@@ -39,7 +39,10 @@ export type Unroutable = {
39
39
  const MAX_BRANCH_LEN = 60;
40
40
 
41
41
  /**
42
- * True only when a human has queued the issue and no run already owns it.
42
+ * True only when a human has queued the issue, no run already owns it, and
43
+ * the operator has not parked it. The park label beats the queue label: an
44
+ * issue carrying both is not eligible, because the queue listing was fetched
45
+ * before the park landed and must not turn into a claim (#734).
43
46
  *
44
47
  * The state labels are the interlock against double-dispatch across daemon
45
48
  * restarts: the tracker, not the local store, is the source of truth for
@@ -51,8 +54,13 @@ export function isEligible(issue: ReadyIssue, p: ProjectConfig): boolean {
51
54
  // sides here, which is the only place labels are matched.
52
55
  const labels = new Set(issue.labels);
53
56
  if (!labels.has(p.queueLabel)) return false;
54
- const { inProgress, blocked, failed } = p.stateLabels;
55
- return !labels.has(inProgress) && !labels.has(blocked) && !labels.has(failed);
57
+ const { inProgress, blocked, failed, backlog } = p.stateLabels;
58
+ return (
59
+ !labels.has(inProgress) &&
60
+ !labels.has(blocked) &&
61
+ !labels.has(failed) &&
62
+ !labels.has(backlog)
63
+ );
56
64
  }
57
65
 
58
66
  /**
@@ -55,6 +55,19 @@ export interface SessionHostSpec {
55
55
  * Carried as a plain scalar like the other fields; absent, nothing is staged
56
56
  * on the far side either. */
57
57
  ompSettingsFile?: string;
58
+ /**
59
+ * The structured-settlement contract (#540): a JSON Schema the session's
60
+ * `yield` tool validates its `data` payload against. Carried as plain JSON
61
+ * through the spec, like everything else; absent, the far side passes nothing
62
+ * to the harness.
63
+ */
64
+ outputSchema?: unknown;
65
+ /** Enforcement policy for {@link outputSchema} — the worker contract is
66
+ * `"permissive"`: a schema violation settles with whatever the worker
67
+ * produced, never a lost report. */
68
+ outputSchemaMode?: "permissive" | "strict";
69
+ /** Force the hidden `yield` tool into this session's toolset (#540). */
70
+ requireYieldTool?: boolean;
58
71
  }
59
72
 
60
73
  /** Parent → child. */
@@ -192,6 +205,9 @@ export async function runSessionHost(
192
205
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
193
206
  ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
194
207
  ...(spec.ompSettingsFile === undefined ? {} : { ompSettingsFile: spec.ompSettingsFile }),
208
+ ...(spec.outputSchema === undefined ? {} : { outputSchema: spec.outputSchema }),
209
+ ...(spec.outputSchemaMode === undefined ? {} : { outputSchemaMode: spec.outputSchemaMode }),
210
+ ...(spec.requireYieldTool === undefined ? {} : { requireYieldTool: spec.requireYieldTool }),
195
211
  // The release audit lives in the daemon's state directory, which this
196
212
  // process may not be able to write and must not be trusted to. It
197
213
  // becomes a message; the parent performs the durable write.