omp-conductor 0.4.3 → 0.4.4

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 CHANGED
@@ -1382,7 +1382,7 @@ persists the class on the row, and performs the one recovery that class names.
1382
1382
  | `orphan-dirty` | orphaned with a failed salvage and no operator ack | hold — recorded only; the tree is the only copy | none |
1383
1383
  | `orphan-clean` | orphaned with nothing uncommitted | requeue | continuation |
1384
1384
  | `turn-cap-progress` | at the turn ceiling **with** a PR, head or salvage commit | continue from the branch | continuation |
1385
- | `turn-cap-spinning` | at the ceiling with no PR and no commits | escalate with the last tool calls the transcript recorded | none |
1385
+ | `turn-cap-spinning` | at the ceiling with no PR and no commits | escalate with the last tool calls the transcript recorded — and the completion path deliberately does **not** requeue it | none |
1386
1386
  | `admin-kill` | killed *below* its own ceiling — a restart or a drain | requeue | none |
1387
1387
  | `ci-infra` | PR open, every unresolved check cancelled / timed out / stale | re-run the failed jobs | none |
1388
1388
  | `ci-deterministic` | PR open, a check genuinely reports `FAILURE` | escalate with the failing check names and links | failed attempt |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/daemon.ts CHANGED
@@ -903,6 +903,38 @@ export async function collectSettlementFlags(
903
903
  * One attempt at one issue, from claim to terminal state. Everything is inside
904
904
  * a single try/catch so that a bad issue costs its own run and nothing else.
905
905
  */
906
+ /**
907
+ * Whether a turns-cap kill is handed straight back to the queue.
908
+ *
909
+ * A turns cap consumes the independent continuation budget rather than an
910
+ * implementation-failure attempt, so a run that ran out of turns mid-work is
911
+ * requeued and the next tick reattaches its branch (#50).
912
+ *
913
+ * The artifact test is what keeps that from becoming a loop. A run that reached
914
+ * the ceiling with no PR, no observed head and no salvage commit produced
915
+ * nothing to continue *from*, and requeueing it spends another continuation on
916
+ * the same spin — the seven rows on this fleet that burned $109 producing no
917
+ * merged PR. That shape is `turn-cap-spinning`, which #132 says must escalate
918
+ * with evidence instead; requeueing it here would settle the question before the
919
+ * classifier ever saw the row, and the queue label would already be back on.
920
+ *
921
+ * Pure so the rule is testable without a worker session — the same split as
922
+ * {@link settlementFor}.
923
+ */
924
+ export function shouldContinueAfterTurnsCap(f: {
925
+ killedBy?: string;
926
+ prUrl?: string;
927
+ headSha?: string;
928
+ salvageSha?: string;
929
+ continuation: number;
930
+ maxContinuations: number;
931
+ }): boolean {
932
+ if (f.killedBy !== "turns") return false;
933
+ const hasArtifacts = f.prUrl !== undefined || f.headSha !== undefined || f.salvageSha !== undefined;
934
+ if (!hasArtifacts) return false;
935
+ return hasContinuationBudget(f.continuation, f.maxContinuations);
936
+ }
937
+
906
938
  async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
907
939
  const { project, caps, tracker, store } = d;
908
940
  const issue = r.issue.number;
@@ -1299,13 +1331,17 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
1299
1331
  detail: [`${r.issue.title}`, r.issue.url, "", ...salvaged, "", result.report].join("\n"),
1300
1332
  });
1301
1333
  } else if (state === "failed" || state === "killed") {
1302
- // A turns cap consumes the independent continuation budget, not an
1303
- // implementation-failure attempt. The row is already `killed`, so this
1304
- // count includes the segment that just ended.
1334
+ // The row is already `killed`, so this count includes the segment that
1335
+ // just ended.
1305
1336
  const continuation = store.continuationsFor(project.name, issue);
1306
- const continueTurns =
1307
- result.killedBy === "turns" &&
1308
- hasContinuationBudget(continuation, caps.maxContinuationsPerIssue);
1337
+ const continueTurns = shouldContinueAfterTurnsCap({
1338
+ killedBy: result.killedBy,
1339
+ prUrl: result.prUrl,
1340
+ headSha: result.headSha,
1341
+ salvageSha: settlement?.patch?.salvageSha,
1342
+ continuation,
1343
+ maxContinuations: caps.maxContinuationsPerIssue,
1344
+ });
1309
1345
 
1310
1346
  if (continueTurns) {
1311
1347
  await tracker.removeLabel(issue, inProgress);
@@ -2891,8 +2927,13 @@ export async function classifyAndRecover(d: Deps): Promise<void> {
2891
2927
  // base does move under it.
2892
2928
  if (run.state === "pushed-green" && cls === "unknown") continue;
2893
2929
 
2930
+ const retry = run.failureClass !== undefined;
2894
2931
  store.updateRun(run.id, { failureClass: cls, recoveryAction: recovery });
2895
- log(`#${run.issue} classified ${cls} → ${recovery}: ${evidence}`);
2932
+ log(
2933
+ retry
2934
+ ? `#${run.issue} retrying ${recovery} for ${cls}: ${evidence}`
2935
+ : `#${run.issue} classified ${cls} → ${recovery}: ${evidence}`,
2936
+ );
2896
2937
  await recoverRun(d, run, cls, recovery, evidence);
2897
2938
  }
2898
2939
  }
package/src/store.ts CHANGED
@@ -735,10 +735,20 @@ export function openStore(dbPath: string): Store {
735
735
  // Newest first, and bounded: every row this returns costs `gh` calls to gather
736
736
  // facts for, so a fleet with a long unclassified history classifies over
737
737
  // several ticks rather than spending one tick's budget on all of it.
738
+ //
739
+ // Classified-but-unrecovered rows are included, not just unclassified ones: a
740
+ // recovery whose tracker write failed has to come back, or the log line saying
741
+ // "retrying next tick" is a lie and the row is stranded with a class and no
742
+ // action. `hold` is excluded because it is *recorded only* by design — its
743
+ // `recoveredAt` stays NULL forever, and re-offering it would spin the sweep.
738
744
  const selectUnclassified = db.query<RunRow, [string, number]>(
739
745
  `SELECT * FROM runs
740
- WHERE project = ? AND failureClass IS NULL
746
+ WHERE project = ?
741
747
  AND state IN ('blocked', 'killed', 'failed', 'orphaned', 'pushed-green')
748
+ AND (
749
+ failureClass IS NULL
750
+ OR (recoveredAt IS NULL AND recoveryAction IN ('settle', 'continue', 'requeue', 'rerun-checks'))
751
+ )
742
752
  ORDER BY startedAt DESC, rowid DESC
743
753
  LIMIT ?`,
744
754
  );
@@ -113,18 +113,29 @@ interface GhPrVerification {
113
113
 
114
114
  /** Carries the captured stderr so callers can classify a failure without
115
115
  * re-running the command or parsing the message text of a plain Error. */
116
- class GhError extends Error {
116
+ export class GhError extends Error {
117
117
  readonly argv: string[];
118
118
  readonly code: number;
119
119
  readonly stderr: string;
120
-
121
- constructor(argv: string[], code: number, stderr: string) {
120
+ /**
121
+ * Whatever the command printed before it failed.
122
+ *
123
+ * Carried because a nonzero exit is not always a failure to *read*: `gh pr
124
+ * checks` exits 1 when a check failed and 8 when one is pending, having
125
+ * already written the complete JSON payload asked for. Discarding stdout here
126
+ * made both CI failure classes unreachable on exactly the pull requests they
127
+ * exist to classify — a red PR answered "no checks" (#132 follow-up).
128
+ */
129
+ readonly stdout: string;
130
+
131
+ constructor(argv: string[], code: number, stderr: string, stdout = "") {
122
132
  const detail = stderr.trim() || "(no stderr)";
123
133
  super(`\`gh ${argv.join(" ")}\` exited ${code}: ${detail}`);
124
134
  this.name = "GhError";
125
135
  this.argv = argv;
126
136
  this.code = code;
127
137
  this.stderr = stderr;
138
+ this.stdout = stdout;
128
139
  }
129
140
  }
130
141
 
@@ -155,7 +166,7 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
155
166
 
156
167
  const signal = proc.signalCode;
157
168
  if (code !== 0 || signal) {
158
- throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr);
169
+ throw new GhError(argv, code, signal ? `${stderr}\nterminated by ${signal}` : stderr, stdout);
159
170
  }
160
171
  return stdout;
161
172
  }
@@ -671,11 +682,17 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
671
682
  if (!PR_URL.test(prUrl)) return [];
672
683
  try {
673
684
  return checkConclusionsFrom(await runGh(["pr", "checks", prUrl, "--json", "name,state,link"]));
674
- } catch {
675
- // `gh pr checks` exits non-zero when checks are failing *and* when it
676
- // could not read them at all, so an exception here says nothing about the
677
- // PR. Empty means "could not tell", and the classifier reads it that way.
678
- return [];
685
+ } catch (err) {
686
+ // A nonzero exit is the *documented* answer for a PR with a failing or
687
+ // pending check `gh pr checks` exits 1 and 8 respectively, having
688
+ // already printed the whole payload. Reading only the throw is what made
689
+ // `ci-infra` and `ci-deterministic` unreachable on live data: every red
690
+ // PR answered "no checks", so it classified `unknown` and escalated.
691
+ //
692
+ // So the payload wins when there is one, and only a genuinely unreadable
693
+ // answer falls through to "could not tell".
694
+ const stdout = err instanceof GhError ? err.stdout : "";
695
+ return checkConclusionsFrom(stdout);
679
696
  }
680
697
  },
681
698
 
package/src/types.ts CHANGED
@@ -1299,10 +1299,14 @@ export interface Store {
1299
1299
  /** Everything an operator still has to care about: pending, sending, failed. */
1300
1300
  openReports(project: string): ReportRecord[];
1301
1301
  /**
1302
- * Terminal rows the classification sweep has not looked at yet, newest first
1303
- * and bounded (#132). Bounded because each row costs tracker calls to gather
1304
- * facts for: a fleet with a long unclassified history works through it over
1305
- * several ticks rather than spending one tick on all of it.
1302
+ * Terminal rows the classification sweep still owes something to, newest first
1303
+ * and bounded (#132): never classified, or classified with a retryable recovery
1304
+ * that has not run. The second half is what makes "retrying next tick" true
1305
+ * when a tracker write fails mid-recovery.
1306
+ *
1307
+ * Bounded because each row costs tracker calls to gather facts for: a fleet
1308
+ * with a long unclassified history works through it over several ticks rather
1309
+ * than spending one tick on all of it.
1306
1310
  */
1307
1311
  runsNeedingClassification(project: string, limit?: number): RunRecord[];
1308
1312
  /** Unrecovered rows per class, for `status`. Empty when nothing is carrying one. */