omp-conductor 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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.",
@@ -149,6 +149,35 @@ export function neverStarted(
149
149
  return undefined;
150
150
  }
151
151
 
152
+ /**
153
+ * Evidence that the conductor's own git path failed before the worker's first
154
+ * turn, or `undefined` when this row is something else.
155
+ *
156
+ * A mirror ref-lock (#168), a dead mirror, a transiently unreachable host: the
157
+ * worker never touched the issue, so none of this should charge an
158
+ * implementation attempt. `lastError` of this shape is written only by the
159
+ * dispatch catch (`errText(err)` = the stack, which prefixes the thrown
160
+ * "git … exited N: …" message with "Error: ") and by state messages — never by
161
+ * a worker — so it is proof of the daemon's own git rather than a worker's
162
+ * failed push.
163
+ *
164
+ * Exported for the same reason {@link neverStarted} is: the store's one-time
165
+ * repair has to recognise exactly what the classifier does, or history and
166
+ * future disagree about which rows were the daemon's fault.
167
+ */
168
+ export function dispatchInfra(
169
+ run: Pick<RunRecord, "turns" | "lastError" | "prUrl" | "headSha" | "salvageSha">,
170
+ ): string | undefined {
171
+ if (run.turns !== 0) return undefined;
172
+ if (run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined) return undefined;
173
+ if (run.lastError === undefined || !DISPATCH_GIT_ERROR.test(run.lastError)) return undefined;
174
+ return `dispatch failed in the conductor's own git path before turn 1: ${run.lastError.split("\n")[0]}`;
175
+ }
176
+
177
+ // See {@link dispatchInfra}: the "Error: " prefix is what `errText` puts in
178
+ // front of the thrown message, so a bare /^git / would never match a real row.
179
+ const DISPATCH_GIT_ERROR = /^(?:Error: )?git .+ exited \d+/s;
180
+
152
181
  export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classification {
153
182
  const hasArtifacts = run.prUrl !== undefined || run.headSha !== undefined || run.salvageSha !== undefined;
154
183
 
@@ -184,25 +213,13 @@ export function classifyRun(run: RunRecord, facts: ClassifyFacts): Classificatio
184
213
  }
185
214
 
186
215
  // Dispatch infra: the conductor's own git path failed before the worker's
187
- // first turn a mirror ref-lock (#168), a dead mirror, a transiently
188
- // unreachable host. The worker never touched the issue, so this should not
189
- // charge an implementation attempt. `lastError` here is written only by the
190
- // dispatch catch (`errText(err)` = the stack, which prefixes the thrown
191
- // "git exited N: …" message with "Error: ") and by state messages — never
192
- // by a worker so that shape is proof of the daemon's own git, not a
193
- // worker's failed push.
194
- if (
195
- run.state === "failed" &&
196
- run.turns === 0 &&
197
- !hasArtifacts &&
198
- run.lastError !== undefined &&
199
- /^(?:Error: )?git .+ exited \d+/s.test(run.lastError)
200
- ) {
201
- return {
202
- cls: "dispatch-infra",
203
- recovery: "requeue",
204
- evidence: `dispatch failed in the conductor's own git path before turn 1: ${run.lastError.split("\n")[0]}`,
205
- };
216
+ // first turn. See {@link dispatchInfra} the worker never touched the issue,
217
+ // so this must not charge an implementation attempt.
218
+ if (run.state === "failed") {
219
+ const evidence = dispatchInfra(run);
220
+ if (evidence !== undefined) {
221
+ return { cls: "dispatch-infra", recovery: "requeue", evidence };
222
+ }
206
223
  }
207
224
 
208
225
  if (run.state === "blocked") {
package/src/store.ts CHANGED
@@ -15,7 +15,7 @@ import { dirname, join } from "node:path";
15
15
 
16
16
  import { stateDir } from "./config.ts";
17
17
  import { DECISION_TTL_MS, DEFAULT_CAPS } from "./types.ts";
18
- import { neverStarted } from "./failure-class.ts";
18
+ import { dispatchInfra, neverStarted } from "./failure-class.ts";
19
19
  import type {
20
20
  DecisionDraft,
21
21
  FailureClass,
@@ -726,6 +726,48 @@ export function openStore(dbPath: string): Store {
726
726
  for (const row of misread) reclassify.run(row.id);
727
727
  }
728
728
 
729
+ // The same repair, for the case the one above deliberately leaves alone: its
730
+ // comment names it exactly ("a turn-0 row carrying an unrecognised error (a
731
+ // failed mirror fetch, say) is untouched"). #177 gave that fault a name, but
732
+ // only for rows written after it existed. Every earlier one was logged
733
+ // `unknown`, whose recovery is `escalate` — so `runsNeedingClassification`
734
+ // never revisits it and `countFailures` counts it forever. With
735
+ // `maxAttemptsPerIssue` defaulting to 2, a single mirror ref-lock is half an
736
+ // issue's entire budget spent on a fault that was never in its code.
737
+ //
738
+ // Narrow the same way: exactly `unknown`, state `failed`, and the
739
+ // CLASSIFIER's own predicate rather than a second copy of the rule.
740
+ // Deliberately only `failureClass` — not `recoveryAction`/`recoveredAt`.
741
+ // Relieving the budget is the point; re-animating a months-old run into the
742
+ // requeue sweep is not, and clearing the recovery columns would do exactly
743
+ // that. Idempotent for free: these rows then read `dispatch-infra`, which the
744
+ // WHERE no longer matches.
745
+ const mirrorBroke = db
746
+ .query<
747
+ { id: string; lastError: string | null; prUrl: string | null; headSha: string | null; salvageSha: string | null },
748
+ []
749
+ >(
750
+ `SELECT id, lastError, prUrl, headSha, salvageSha FROM runs
751
+ WHERE failureClass = 'unknown' AND turns = 0 AND state = 'failed'`,
752
+ )
753
+ .all()
754
+ .filter(
755
+ (row) =>
756
+ dispatchInfra({
757
+ turns: 0,
758
+ ...(row.lastError === null ? {} : { lastError: row.lastError }),
759
+ ...(row.prUrl === null ? {} : { prUrl: row.prUrl }),
760
+ ...(row.headSha === null ? {} : { headSha: row.headSha }),
761
+ ...(row.salvageSha === null ? {} : { salvageSha: row.salvageSha }),
762
+ }) !== undefined,
763
+ );
764
+ if (mirrorBroke.length > 0) {
765
+ const reclassify = db.query<unknown, [string]>(
766
+ `UPDATE runs SET failureClass = 'dispatch-infra' WHERE id = ?`,
767
+ );
768
+ for (const row of mirrorBroke) reclassify.run(row.id);
769
+ }
770
+
729
771
  const insertRun = db.query<unknown, SqlValue[]>(
730
772
  `INSERT INTO runs (
731
773
  id, project, issue, repo, branch, worktree, state, attempt, turns,