omp-conductor 0.5.6 → 0.7.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.
package/src/store.ts CHANGED
@@ -61,7 +61,7 @@ export const LIVE_STATES: readonly RunState[] = ["claimed", "running"];
61
61
  * worktrees removed, so they must not consume slots, while their live PRs must
62
62
  * still block duplicate attempts.
63
63
  */
64
- const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-pending", "pushed-green"];
64
+ export const ACTIVE_STATES: readonly RunState[] = [...LIVE_STATES, "pushed-pending", "pushed-green"];
65
65
 
66
66
  const LIVE_PLACEHOLDERS = LIVE_STATES.map(() => "?").join(", ");
67
67
  const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
@@ -773,12 +773,19 @@ export function openStore(dbPath: string): Store {
773
773
  const countFailures = db.query<{ n: number }, [string, number]>(
774
774
  `SELECT COUNT(*) AS n FROM runs
775
775
  WHERE project = ? AND issue = ? AND state = 'failed'
776
- AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure'))`,
776
+ AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
777
777
  );
778
778
  const countContinuations = db.query<{ n: number }, [string, number]>(
779
779
  `SELECT COUNT(*) AS n FROM runs
780
780
  WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
781
- AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure'))`,
781
+ AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
782
+ );
783
+ // How many times one issue reached a given class. Recovery uses it to bound a
784
+ // retry loop whose cause is persistent (e.g. a mirror that will not refresh):
785
+ // after `DISPATCH_INFRA_MAX_STRIKES` the daemon escalates instead of looping.
786
+ const countByClass = db.query<{ n: number }, [string, number, FailureClass]>(
787
+ `SELECT COUNT(*) AS n FROM runs
788
+ WHERE project = ? AND issue = ? AND failureClass = ?`,
782
789
  );
783
790
  // Newest first, and bounded: every row this returns costs `gh` calls to gather
784
791
  // facts for, so a fleet with a long unclassified history classifies over
@@ -1142,6 +1149,10 @@ export function openStore(dbPath: string): Store {
1142
1149
  return countContinuations.get(project, issue)?.n ?? 0;
1143
1150
  },
1144
1151
 
1152
+ classCountFor(project: string, issue: number, cls: FailureClass): number {
1153
+ return countByClass.get(project, issue, cls)?.n ?? 0;
1154
+ },
1155
+
1145
1156
  latestRun(project: string, issue: number): RunRecord | undefined {
1146
1157
  const row = selectLatestRun.get(project, issue);
1147
1158
  return row ? toRecord(row) : undefined;
@@ -695,6 +695,22 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
695
695
  }
696
696
  },
697
697
 
698
+ async checkLog(detailsUrl: string): Promise<string | undefined> {
699
+ const args = runLogArgs(detailsUrl);
700
+ if (args === undefined) return undefined;
701
+ try {
702
+ const raw = await runGh(args);
703
+ const lines = raw.replaceAll(/\u001b\[[0-9;]*m/g, "").split("\n");
704
+ return lines.slice(-400).join("\n");
705
+ } catch {
706
+ // Best-effort, never throws: a revoked token, a deleted run or a flaky
707
+ // network must not error the classifier out of gathering facts. The
708
+ // caller treats `undefined` as "could not read the log" and classifies
709
+ // conservatively (#177).
710
+ return undefined;
711
+ }
712
+ },
713
+
698
714
  async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
699
715
  if (!PR_URL.test(prUrl)) return "unknown";
700
716
  try {
package/src/types.ts CHANGED
@@ -766,6 +766,12 @@ export interface Tracker {
766
766
  * port: nothing downstream may read it as "no checks failed".
767
767
  */
768
768
  checkConclusions(prUrl: string): Promise<{ name: string; state: string; link?: string }[]>;
769
+ /** The tail of a failed workflow run's log, ANSI stripped, or `undefined`
770
+ * when it cannot be fetched. Best-effort, never throws: the classifier uses
771
+ * it to tell an infrastructure outage from a deterministic test failure
772
+ * (#177), and "could not read the log" must fall through to the existing
773
+ * conservative classification rather than error out of fact-gathering. */
774
+ checkLog(detailsUrl: string): Promise<string | undefined>;
769
775
  /** Whether the PR can merge into its base. `unknown` on any doubt, so a
770
776
  * mergeability nobody could read never becomes a conflict recovery. */
771
777
  mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown">;
@@ -803,6 +809,7 @@ export const FAILURE_CLASSES = [
803
809
  "admin-kill",
804
810
  "ci-infra",
805
811
  "ci-deterministic",
812
+ "dispatch-infra",
806
813
  "merge-conflict",
807
814
  "question",
808
815
  "orphan-clean",
@@ -1155,6 +1162,10 @@ export interface Store {
1155
1162
  failuresFor(project: string, issue: number): number;
1156
1163
  /** Operational stops that require a bounded continuation resume. */
1157
1164
  continuationsFor(project: string, issue: number): number;
1165
+ /** How many times one issue has reached a given failure class. A recovery
1166
+ * that keeps landing on the same class (e.g. a persistently broken mirror)
1167
+ * uses this to escalate instead of retrying forever. */
1168
+ classCountFor(project: string, issue: number, cls: FailureClass): number;
1158
1169
  /** Newest attempt for one issue, whatever state it reached. `omp-conductor
1159
1170
  * tail` resolves an issue number to a transcript through this; the number is
1160
1171
  * what an operator has, the run id is not. */
@@ -1321,6 +1332,7 @@ export const VERB_NAMES = [
1321
1332
  "conductor_push",
1322
1333
  "conductor_pr_create",
1323
1334
  "conductor_pr_update_branch",
1335
+ "conductor_pr_update",
1324
1336
  "conductor_pr_merge",
1325
1337
  "conductor_label",
1326
1338
  "conductor_release",
package/src/unblock.ts CHANGED
@@ -40,6 +40,10 @@ export interface UnblockOutcome {
40
40
  continuationsUsed: number;
41
41
  /** Newest attempt, when the store has one for this issue at all. */
42
42
  latest?: RunRecord;
43
+ /** Whether any run for the issue is still in an active state — the answer
44
+ * to "will the dispatcher hold this issue as issue-active?" that #178
45
+ * found this verb guessing at. */
46
+ active: boolean;
43
47
  /** Set when nothing was cleared because the newest attempt's work exists
44
48
  * only in its worktree. Carries the salvage failure verbatim. */
45
49
  refused?: string;
@@ -92,10 +96,12 @@ export async function unblockIssue(
92
96
  // for clearing in-progress, so the row that carries it decides the set.
93
97
  const latest = store.latestRun(project.name, issue);
94
98
  const terminal = latest !== undefined && !LIVE_STATES.includes(latest.state);
99
+ const active = store.activeRuns(project.name).some((r) => r.issue === issue);
95
100
  const counts = {
96
101
  attemptsUsed: store.attemptsFor(project.name, issue),
97
102
  failuresUsed: store.failuresFor(project.name, issue),
98
103
  continuationsUsed: store.continuationsFor(project.name, issue),
104
+ active,
99
105
  };
100
106
 
101
107
  // The one case where this verb refuses. Clearing the labels here re-queues an
@@ -205,8 +211,31 @@ export function formatUnblock(
205
211
  `run row to prove the worker is gone, "${project.stateLabels.inProgress}" was left in place, and ` +
206
212
  "on its own it keeps the issue ineligible",
207
213
  );
214
+ } else if (latest.state === "pushed-green" || latest.state === "pushed-pending") {
215
+ // #178: an unblocked issue whose newest run pushed a green PR is not "free
216
+ // for a fresh attempt" — the dispatcher continues the pushed run's branch,
217
+ // and only once the PR resolves does the issue settle (#175). Say that
218
+ // instead of promising a re-claim the next tick withholds.
219
+ lines.push(
220
+ latest.prUrl === undefined
221
+ ? ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
222
+ `(newest run is ${latest.state} with no recorded PR; the dispatcher still applies its open-PR check at claim time)`
223
+ : ` next tick eligible as a continuation of ${latest.prUrl} — the pushed run stays active until ` +
224
+ "that PR resolves; dispatch continues on its branch",
225
+ );
226
+ } else if (o.active) {
227
+ // A run other than the newest is still active (e.g. a live sibling under a
228
+ // terminal newest row), so the dispatcher will hold the issue as
229
+ // issue-active whatever the labels now say. #178's misleading case.
230
+ lines.push(
231
+ ` in flight a run for this issue is still active, so the dispatcher holds the issue until it settles — ` +
232
+ `nothing is re-claimed before then ("${project.stateLabels.inProgress}" stays unless already released)`,
233
+ );
208
234
  } else {
209
- lines.push(` next tick eligible again, as long as the issue still carries "${project.queueLabel}"`);
235
+ lines.push(
236
+ ` next tick eligible again, as long as the issue still carries "${project.queueLabel}" ` +
237
+ "(the dispatcher still applies its open-PR check at claim time)",
238
+ );
210
239
  }
211
240
 
212
241
  return lines.join("\n");
@@ -97,6 +97,14 @@ export function githubVerbActions(project: ProjectConfig, run: CommandRunner = s
97
97
 
98
98
  updatePrBranch: (prUrl) => gh(["pr", "update-branch", prUrl]),
99
99
 
100
+ updatePr: async (prUrl, fields) => {
101
+ const argv = ["pr", "edit", prUrl];
102
+ if (fields.title !== undefined) argv.push("--title", fields.title);
103
+ if (fields.body !== undefined) argv.push("--body", fields.body);
104
+ const outcome = await gh(argv);
105
+ return outcome.ok ? { ok: true } : { ok: false, stderr: outcome.stderr };
106
+ },
107
+
100
108
  // `--match-head-commit` is the server-side half of the exact-head rule: the
101
109
  // daemon already re-read the head and refused a stale one, and this makes
102
110
  // GitHub refuse too if the branch moved in the milliseconds between. Belt
@@ -145,6 +145,35 @@ export const VERB_SPECS: Readonly<Record<VerbName, VerbSpec>> = {
145
145
  },
146
146
  roleRefusalText: (role) => `conductor_pr_update_branch is not open to a ${role} session.`,
147
147
  },
148
+ conductor_pr_update: {
149
+ name: "conductor_pr_update",
150
+ mutating: true,
151
+ allowedRoles: ["worker", "orchestrator"],
152
+ description:
153
+ "Update this run's pull request title and/or body. Never touches base, head or state — " +
154
+ "a field you may not change is a field this verb refuses rather than renames. A worker " +
155
+ "may only name its own run's PR.",
156
+ args: {
157
+ prUrl: {
158
+ type: "string",
159
+ required: false,
160
+ description:
161
+ "Full pull request URL. Required on an orchestrator channel; a run channel infers its own PR.",
162
+ },
163
+ title: {
164
+ type: "string",
165
+ required: false,
166
+ description: "New pull request title. At least one of title/body must be a non-empty string.",
167
+ },
168
+ body: {
169
+ type: "string",
170
+ required: false,
171
+ description: "New pull request body. At least one of title/body must be a non-empty string.",
172
+ },
173
+ rationale: RATIONALE_ARG,
174
+ },
175
+ roleRefusalText: (role) => `conductor_pr_update is not open to a ${role} session.`,
176
+ },
148
177
  conductor_pr_merge: {
149
178
  name: "conductor_pr_merge",
150
179
  mutating: true,
@@ -145,6 +145,10 @@ export interface VerbActions {
145
145
  opts: { title: string; body: string; base: string },
146
146
  ): Promise<{ ok: true; url: string } | { ok: false; stderr: string }>;
147
147
  updatePrBranch(prUrl: string): Promise<ActionOutcome>;
148
+ updatePr(
149
+ prUrl: string,
150
+ fields: { title?: string; body?: string },
151
+ ): Promise<{ ok: true } | { ok: false; stderr: string }>;
148
152
  mergePr(prUrl: string, headSha: string): Promise<ActionOutcome>;
149
153
  setLabel(issue: number, label: string, action: "add" | "remove"): Promise<ActionOutcome>;
150
154
  release(execution: ReleaseExecution): Promise<ActionOutcome>;
@@ -167,6 +171,14 @@ export interface VerbDeps {
167
171
  * sentinel alongside disarming ticks, so one read covers both.
168
172
  */
169
173
  fleetStop: () => string | undefined;
174
+ /**
175
+ * The epoch-ms timestamp the current pause began, or `undefined` when not
176
+ * paused (or when the sentinel does not parse). Lets the fleet-stop gate tell
177
+ * a mutating call from a run admitted *before* the pause — that run may
178
+ * finish — apart from one admitted after, which must be refused. Parallels
179
+ * {@link VerbDeps.fleetStop} by living behind the same per-call read.
180
+ */
181
+ pausedAt: () => number | undefined;
170
182
  log: (message: string) => void;
171
183
  now: () => number;
172
184
  }
@@ -387,17 +399,11 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
387
399
  );
388
400
  }
389
401
 
390
- // 3. Stop always wins, and is re-read here rather than remembered. Reads are
391
- // exempt: a run finishing its report during a `hold` still has to be able
392
- // to say what it saw, and refusing that would only make it guess.
393
- const stopped = spec.mutating ? deps.fleetStop() : undefined;
394
- if (stopped !== undefined) {
395
- return refuse("fleet-paused", `refused: ${stopped}. Mutating verbs are refused while the fleet is stopped.`);
396
- }
397
-
398
- // 4. The run's own row, re-read. A worker whose run was killed, orphaned or
402
+ // 3. The run's own row, re-read. A worker whose run was killed, orphaned or
399
403
  // settled between its last turn and this call is no longer holding
400
- // anything, and a push from it would land work nothing is tracking.
404
+ // anything, and a push from it would land work nothing is tracking. Read
405
+ // *before* the stop gate below, because the stop gate needs this row to
406
+ // tell an in-flight run (which may finish) from a post-pause call.
401
407
  let run: RunRecord | undefined;
402
408
  if (channel.kind === "run") {
403
409
  run = deps.store.getRun(channel.runId);
@@ -409,6 +415,22 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
409
415
  }
410
416
  }
411
417
 
418
+ // 4. Stop always wins, and is re-read here rather than remembered. Reads are
419
+ // exempt: a run finishing its report during a `hold` still has to be able
420
+ // to say what it saw, and refusing that would only make it guess. A run
421
+ // admitted *before* the pause may finish its in-flight mutations — pause
422
+ // stops new claims, not the work already running (#174) — so only a call
423
+ // whose run started at or after the pause (or an orchestrator's call, with
424
+ // no run row at all) is refused.
425
+ const stopped = spec.mutating ? deps.fleetStop() : undefined;
426
+ if (stopped !== undefined) {
427
+ const at = deps.pausedAt();
428
+ const admittedBeforePause = run !== undefined && at !== undefined && run.startedAt < at;
429
+ if (!admittedBeforePause) {
430
+ return refuse("fleet-paused", `refused: ${stopped}. The pause stops new claims and new mutations; a run admitted before the pause may finish.`);
431
+ }
432
+ }
433
+
412
434
  switch (verb) {
413
435
  case "conductor_push":
414
436
  return pushVerb(deps, channel, args, refuse, allow);
@@ -416,6 +438,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
416
438
  return prCreateVerb(deps, project, channel, args, refuse, allow);
417
439
  case "conductor_pr_update_branch":
418
440
  return prUpdateBranchVerb(deps, project, channel, args, refuse, allow);
441
+ case "conductor_pr_update":
442
+ return prUpdateVerb(deps, project, channel, args, refuse, allow);
419
443
  case "conductor_pr_merge":
420
444
  return prMergeVerb(deps, project, channel, args, refuse, allow);
421
445
  case "conductor_label":
@@ -530,6 +554,18 @@ async function prCreateVerb(
530
554
  );
531
555
  }
532
556
  if (closer !== undefined) {
557
+ // Adoption (#176): a continuation run is never bound to its issue's existing
558
+ // PR — the old row owns the prUrl, and this new row's prUrl stayed NULL — so
559
+ // when the open PR was opened from *this* run's branch, it is the same work.
560
+ // Bind it to this run instead of refusing: pr_status then answers from the
561
+ // live PR, and a merge can act on it. Branch mismatch (or an empty headRefName,
562
+ // which is never identity) keeps the recorded refusal.
563
+ if (closer.headRefName !== "" && closer.headRefName === run.branch) {
564
+ deps.store.updateRun(run.runId, { prUrl: closer.url });
565
+ return allow(
566
+ `adopted ${closer.url} — already open for ${run.branch} and closes #${run.issue}; title/body arguments were not applied`,
567
+ );
568
+ }
533
569
  return refuse("open-pr-exists", `refused: ${closer.url} is already open and already closes #${run.issue}.`);
534
570
  }
535
571
 
@@ -925,6 +961,73 @@ async function prStatusVerb(
925
961
  return allow(`${prUrl} at ${headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
926
962
  }
927
963
 
964
+ /**
965
+ * Update one pull request's title and/or body — nothing else.
966
+ *
967
+ * Same resolution as {@link prStatusVerb}: a worker edits its own run's PR (and
968
+ * only that one), an orchestrator names one this project's runs opened.
969
+ * `conductor_pr_update` exists so the floor's own edits go through the same
970
+ * checks and the same ledger as every other mutation, instead of a raw
971
+ * `gh pr edit` from a session that already disclosed it would do exactly that
972
+ * (#176).
973
+ */
974
+ async function prUpdateVerb(
975
+ deps: VerbDeps,
976
+ project: ProjectConfig,
977
+ channel: VerbChannel,
978
+ args: Record<string, unknown>,
979
+ refuse: Refuse,
980
+ allow: Allow,
981
+ ): Promise<Verdict> {
982
+ const asked = args["prUrl"];
983
+ let prUrl: string;
984
+ let issue: number | undefined;
985
+
986
+ if (channel.kind === "run") {
987
+ const own = deps.store.getRun(channel.runId);
988
+ if (own?.prUrl === undefined) {
989
+ return refuse("pr-missing", "refused: this run has no pull request yet. Open one with conductor_pr_create.");
990
+ }
991
+ if (typeof asked === "string" && asked !== own.prUrl) {
992
+ return refuse(
993
+ "pr-not-this-run",
994
+ `refused: this socket belongs to the run for #${channel.issue}, whose pull request is ${own.prUrl}. ` +
995
+ "A worker edits only its own run's PR.",
996
+ );
997
+ }
998
+ prUrl = own.prUrl;
999
+ issue = channel.issue;
1000
+ } else {
1001
+ if (typeof asked !== "string") {
1002
+ return refuse("malformed-argument", "refused: conductor_pr_update needs prUrl when it has no run to infer one from.");
1003
+ }
1004
+ const target = runForPr(deps, project.name, asked);
1005
+ if (target === undefined) {
1006
+ return refuse("pr-not-this-run", `refused: ${asked} is not a pull request any run in ${project.name} opened.`);
1007
+ }
1008
+ prUrl = asked;
1009
+ issue = target.issue;
1010
+ }
1011
+
1012
+ const title = args["title"];
1013
+ const body = args["body"];
1014
+ if (!(typeof title === "string" && title !== "") && !(typeof body === "string" && body !== "")) {
1015
+ return refuse(
1016
+ "malformed-argument",
1017
+ "refused: conductor_pr_update changes nothing unless at least one of title or body is a non-empty string.",
1018
+ );
1019
+ }
1020
+
1021
+ const outcome = await deps.actions.updatePr(prUrl, {
1022
+ ...(typeof title === "string" && title !== "" ? { title } : {}),
1023
+ ...(typeof body === "string" && body !== "" ? { body } : {}),
1024
+ });
1025
+ if (!outcome.ok) {
1026
+ return refuse("action-failed", `refused: gh could not update the pull request:\n${outcome.stderr}`, issue);
1027
+ }
1028
+ return allow(`updated ${prUrl}.`, undefined, issue);
1029
+ }
1030
+
928
1031
  // ------------------------------------------------------------------ the listener
929
1032
 
930
1033
  export interface VerbListener {
package/src/worktree.ts CHANGED
@@ -108,6 +108,29 @@ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
108
108
  return code === 0;
109
109
  }
110
110
 
111
+ /** Fences the block below so it can be found, replaced, and never duplicated. */
112
+ const REF_LOCK_SIGNATURES = /cannot lock ref|unable to update local ref/i;
113
+
114
+ /**
115
+ * Refreshes a bare mirror's refs from its remote. Two dispatch loops can race
116
+ * on git's *packed-refs* / ref locks for the same repo and one lands a transient
117
+ * `cannot lock ref` / `unable to update local ref` — that is not corruption, but
118
+ * an {@link ensureMirror} that throws on it aborts the whole dispatch at turn 0
119
+ * and chars a failed attempt (#168). Retry once after a short backoff; a second
120
+ * failure (or any non-ref-lock failure) propagates unchanged, so a genuinely
121
+ * broken mirror still surfaces loudly.
122
+ */
123
+ async function refreshMirror(mirrorPath: string): Promise<string> {
124
+ try {
125
+ return await git(["remote", "update", "--prune"], mirrorPath);
126
+ } catch (err) {
127
+ const message = err instanceof Error ? err.message : String(err);
128
+ if (!REF_LOCK_SIGNATURES.test(message)) throw err;
129
+ await new Promise<void>((r) => setTimeout(r, 500));
130
+ return git(["remote", "update", "--prune"], mirrorPath);
131
+ }
132
+ }
133
+
111
134
  /** Fences the block below so it can be found, replaced, and never duplicated. */
112
135
  const EXCLUDE_BEGIN = "# >>> omp-conductor (managed; edit outside this block)";
113
136
  const EXCLUDE_END = "# <<< omp-conductor";
@@ -262,7 +285,7 @@ export async function ensureMirror(
262
285
 
263
286
  if (existsSync(mirrorPath)) {
264
287
  await configureMirror(mirrorPath);
265
- await git(["remote", "update", "--prune"], mirrorPath);
288
+ await refreshMirror(mirrorPath);
266
289
  return mirrorPath;
267
290
  }
268
291