omp-conductor 0.20.0 → 0.20.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.
@@ -34,6 +34,14 @@ export interface ClassifyFacts {
34
34
  * reachable. Lets the table tell an infrastructure outage (#177) from a
35
35
  * deterministic test failure by the log's own words. */
36
36
  failingLog?: string;
37
+ /** The PR's changed-file list, derived at the settle call site from the
38
+ * diff the settlement already fetches (#1059). Lets the table tell a
39
+ * Compose dependency-startup failure no PR could have caused from one a
40
+ * PR's own diff did — a PR that touches no container configuration cannot
41
+ * have broken `docker compose up`. Absent means the diff could not be
42
+ * read (or was cut short), which is "could not tell": the conditional
43
+ * signature below then stays silent and the attempt is charged. */
44
+ changedFiles?: string[];
37
45
  }
38
46
 
39
47
  export interface Classification {
@@ -208,6 +216,62 @@ export function infraSignatureVersion(): string {
208
216
  return INFRA_LOG_SIGNATURES.join("|");
209
217
  }
210
218
 
219
+ /**
220
+ * Compose's own sentence for a dependency that failed its healthcheck during
221
+ * `docker compose up`: "dependency failed to start: container
222
+ * chad-postgres-1 is unhealthy" (#1059). Matched with its trailing context,
223
+ * never a bare `is unhealthy` — that phrase is common enough in application
224
+ * logs to be unsafe on its own. Shared with the settle call site so the diff
225
+ * fetch and the classifier can never disagree about which log sentence needs
226
+ * the changed-file list.
227
+ */
228
+ export const COMPOSE_DEPENDENCY_STARTUP_SIGNATURE =
229
+ "dependency failed to start: container ";
230
+
231
+ /**
232
+ * A changed path that configures the containers Compose jobs run, or the
233
+ * workflow that invokes them: a compose file — `docker-compose*.ya?ml`,
234
+ * Compose v2's default `compose.ya?ml`, or any `*.ya?ml` directly inside a
235
+ * `compose/` directory (#1071) — anywhere in the tree, a `Dockerfile*`, or
236
+ * anything under `.github/workflows/`. A PR touching any of these can have
237
+ * broken `docker compose up` itself — that is what turns the signature below
238
+ * from infrastructure into a charged, deterministic failure. Matched
239
+ * case-insensitively: the price of missing a container file is waiving a
240
+ * genuine attempt, while a false positive (a path merely *named* like one)
241
+ * only charges one.
242
+ */
243
+ const CONTAINER_CONFIG_PATH =
244
+ /(?:^|\/)(?:docker-compose[^/]*\.ya?ml|compose\.ya?ml|compose\/[^/]+\.ya?ml|Dockerfile[^/]*)$|(?:^|\/)\.github\/workflows\//i;
245
+
246
+ /**
247
+ * Evidence that a failed check's log names a Compose dependency-startup
248
+ * failure, or `undefined` when the log does not name one or the PR's own diff
249
+ * could have caused it (#1059).
250
+ *
251
+ * Deliberately conditional on the PR's changed-file list, and deliberately
252
+ * left out of {@link INFRA_LOG_SIGNATURES}: a job that runs `docker compose`
253
+ * can be broken by the diff itself — a bad `docker-compose.yml` in a PR
254
+ * produces this exact sentence — so the blanket signature would waive the
255
+ * implementation attempt for a real defect. Only when the diff touches no
256
+ * compose file, no Dockerfile and no workflow is the fault one the PR cannot
257
+ * have introduced, and the recovery (`rerun-checks`) is bounded by the strike
258
+ * cap either way.
259
+ *
260
+ * Fails closed: `undefined` changedFiles is "could not read the diff", never
261
+ * "clean", and stays silent — an unknown or truncated diff must not waive an
262
+ * attempt.
263
+ */
264
+ export function composeDependencyStartup(
265
+ log: string,
266
+ changedFiles: string[] | undefined,
267
+ ): string | undefined {
268
+ if (changedFiles === undefined) return undefined;
269
+ const lower = log.toLowerCase();
270
+ if (!lower.includes(COMPOSE_DEPENDENCY_STARTUP_SIGNATURE)) return undefined;
271
+ if (changedFiles.some((path) => CONTAINER_CONFIG_PATH.test(path))) return undefined;
272
+ return COMPOSE_DEPENDENCY_STARTUP_SIGNATURE.trim();
273
+ }
274
+
211
275
  /** Lowercased check state — `gh pr checks` has emitted both `failure` and
212
276
  * `FAILURE` across versions, and the classifier's callers must agree on one
213
277
  * spelling so log selection and classification see the same set of checks. */
@@ -601,6 +665,69 @@ export function noVerdictExit(run: RunRecord): string | undefined {
601
665
  return `the session ended without delivering a settlement verdict; last words: "${lastWords.slice(0, 80)}"`;
602
666
  }
603
667
 
668
+ /** A reliable PR reference inside a blocker's prose: a `PR #N`/
669
+ * `pull request #N` mention or a GitHub `…/pull/N` URL. Named once so the
670
+ * evidence's "the next act is an observation of this PR" claim and the code
671
+ * that finds the PR stay one pattern (#1068). */
672
+ const PR_REFERENCE_PATTERN =
673
+ /(?:PR|pull request)\s+#?\s*\d+|github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/i;
674
+
675
+ /**
676
+ * The `blockers:` list of a blocked settlement's stored report, in order
677
+ * (#1068). The worker's structured yield renders one item per blocker as
678
+ * ` - <item>` under a `blockers:` heading, and prose settlements that use the
679
+ * same heading parse the same way. `undefined` when the report carries no
680
+ * such section — a report that names no blockers and a missing report read
681
+ * identically, which is honest: the worker did not name a condition to
682
+ * observe.
683
+ */
684
+ function blockedSettlementBlockers(report: string | undefined): string[] | undefined {
685
+ if (report === undefined) return undefined;
686
+ const blockers: string[] = [];
687
+ let inBlockers = false;
688
+ for (const line of report.split("\n")) {
689
+ if (inBlockers) {
690
+ const item = /^ {2,}-\s+(.*)$/.exec(line);
691
+ if (item === null) break;
692
+ blockers.push(item[1]!.trim());
693
+ continue;
694
+ }
695
+ if (/^blockers:\s*$/i.test(line)) inBlockers = true;
696
+ }
697
+ return blockers.length === 0 ? undefined : blockers;
698
+ }
699
+
700
+ /**
701
+ * An explicit question in the blocked row's own words, or `undefined` when the
702
+ * row names none (#1068). The worker's question travels in the pre-existing
703
+ * `lastError` slot, or as a `blockers:` item that ends interrogative; a
704
+ * blocker that names a condition to observe ("PR #1067 checks still
705
+ * pending…") is not a question. Quoted verbatim into the escalation evidence,
706
+ * because the orchestrator answers the worker's own words rather than a
707
+ * paraphrase.
708
+ */
709
+ function blockedQuestion(run: RunRecord): string | undefined {
710
+ if (run.lastError !== undefined && run.lastError.trim() !== "") return run.lastError;
711
+ return blockedSettlementBlockers(run.report)?.find((b) => /[??]\s*$/.test(b));
712
+ }
713
+
714
+ /**
715
+ * The report's first substantive line: skips the `status:`/`pr:`/`branch:`/
716
+ * `head:` headers and blank lines a structured settlement puts on top, and
717
+ * bounds the quote so a prose dump cannot balloon an evidence line. `undefined`
718
+ * when the report is empty or carries nothing but scaffold.
719
+ */
720
+ function firstReportContentLine(report: string | undefined): string | undefined {
721
+ if (report === undefined) return undefined;
722
+ for (const raw of report.split("\n")) {
723
+ const line = raw.trim();
724
+ if (line === "") continue;
725
+ if (/^(?:status|pr|branch|head):\s*\S/.test(line)) continue;
726
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
727
+ }
728
+ return undefined;
729
+ }
730
+
604
731
  export function classifyRun(
605
732
  run: RunRecord,
606
733
  facts: ClassifyFacts,
@@ -744,10 +871,44 @@ export function classifyRun(
744
871
  }
745
872
 
746
873
  if (run.state === "blocked") {
874
+ // `blocked` is overloaded (#1068). A worker that finishes its work and
875
+ // stops because it is waiting on an observable condition — almost always
876
+ // its PR's checks — records that condition as `blockers:` in its
877
+ // settlement report; a worker that stops to ask records an actual
878
+ // question. Only the second shape is a human escalation: the first names
879
+ // the next act (observe, then the sweep settles the row the way it
880
+ // settles `settlement-stuck` when the PR resolves), so escalating it
881
+ // wakes the orchestrator for a question that does not exist and the act
882
+ // that *is* required appears nowhere. The evidence never claims the
883
+ // worker "left no report" — it quotes the question or the first blocker,
884
+ // and when the report genuinely is empty it says which fields are.
885
+ const question = blockedQuestion(run);
886
+ if (question !== undefined) {
887
+ return { cls: "question", recovery: "escalate", evidence: question };
888
+ }
889
+ const blockers = blockedSettlementBlockers(run.report);
890
+ if (blockers !== undefined) {
891
+ // Prefer the blocker that names a PR: the reader's next act is an
892
+ // observation of that PR and a merge, and the evidence must say so.
893
+ const named = blockers.find((b) => PR_REFERENCE_PATTERN.test(b)) ?? blockers[0]!;
894
+ return {
895
+ cls: "awaiting-observation",
896
+ recovery: "observe",
897
+ evidence: `waiting on ${named}`,
898
+ };
899
+ }
900
+ const reportEmpty = run.report === undefined || run.report.trim() === "";
901
+ const firstContent = firstReportContentLine(run.report);
747
902
  return {
748
903
  cls: "question",
749
904
  recovery: "escalate",
750
- evidence: run.lastError ?? "the worker stopped to ask a question and left no report",
905
+ evidence: reportEmpty
906
+ ? `the worker stopped without a run report (run.report is empty${
907
+ run.lastError === undefined || run.lastError.trim() === "" ? "; lastError is empty too" : ""
908
+ })`
909
+ : firstContent === undefined
910
+ ? "the worker stopped without asking a question or naming a blocker (run.report is non-empty but has no readable content)"
911
+ : `the worker stopped without asking a question or naming a blocker — ${firstContent}`,
751
912
  };
752
913
  }
753
914
 
@@ -854,6 +1015,26 @@ export function classifyRun(
854
1015
  evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${signature}"`,
855
1016
  };
856
1017
  }
1018
+ // A Compose dependency-startup failure is infra only when the PR's
1019
+ // diff could not have caused it (#1059): a job that runs `docker
1020
+ // compose` is broken by a PR that breaks its own compose file, and
1021
+ // that attempt is genuinely spent. The settlement hands the
1022
+ // classifier the PR's changed-file list, so "did this diff touch any
1023
+ // container configuration" is a fact, not a guess from the log alone.
1024
+ // Absent list (or a log without the sentence) stays silent and the
1025
+ // row reads `ci-deterministic`.
1026
+ const compose = composeDependencyStartup(facts.failingLog, facts.changedFiles);
1027
+ if (compose !== undefined) {
1028
+ const check = checks.find((c) => normalise(c.state) === "failure");
1029
+ return {
1030
+ cls: "ci-infra",
1031
+ recovery: "rerun-checks",
1032
+ // Both halves, so the evidence says *which* sentence matched and
1033
+ // *why* it was not the diff's fault — the changed-file list
1034
+ // showed no container configuration.
1035
+ evidence: `infrastructure signature in ${check?.name === undefined ? "failed check" : check.name} log: "${compose}" — the PR diff touches no container configuration, so the PR cannot have failed this check`,
1036
+ };
1037
+ }
857
1038
  }
858
1039
  const failing = unresolved.filter((c) => normalise(c.state) === "failure");
859
1040
  if (failing.length > 0) {
package/src/fleet.ts CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  resolveArmState,
104
104
  TICK_CONFIG_FILE,
105
105
  tickConfigMatchesProject,
106
+ writeArmedMarker,
106
107
  type ArmState,
107
108
  type TickConfig,
108
109
  type TickConfigResult,
@@ -308,9 +309,10 @@ export interface ArmMarkersWritten {
308
309
  * A challenge filed and sent, with nothing armed yet.
309
310
  *
310
311
  * Arming used to block here for up to five minutes on an in-session
311
- * acknowledgement. With the console owning the operator DM, the reply lands in
312
- * a session that runs no tick extension, so that wait could never be satisfied
313
- * it is now two mechanical steps, and this is the first one's receipt.
312
+ * acknowledgement. Nothing waits now: the reply is consumed mechanically by
313
+ * the session whose topic the challenge went to (the orchestrator pane,
314
+ * #1061), or by `omp-conductor arm --reply` on a console host whose DM the
315
+ * operator answered in. This is the send half's receipt.
314
316
  */
315
317
  export interface ArmChallengeSent {
316
318
  outcome: "challenge-sent";
@@ -566,7 +568,8 @@ function armReplyCommand(projectName?: string): string {
566
568
  * proof cannot weaken it.
567
569
  *
568
570
  * Like {@link armTicks}, this returns as soon as the challenge is filed and
569
- * sent: `omp-conductor arm --reply` writes the markers.
571
+ * sent: the reply settles it mechanically on the orchestrator pane's own
572
+ * topic, or through `omp-conductor arm --reply` from the console.
570
573
  */
571
574
  export async function armFleet(
572
575
  projectNames: readonly string[],
@@ -709,10 +712,12 @@ export type ArmReplyResult = ArmReplyAccepted | ArmReplyRefused;
709
712
  * The verification half of the ceremony: classify the operator's verbatim
710
713
  * message and, on a match, arm exactly the projects the challenge recorded.
711
714
  *
712
- * This runs in the console session, which is where the operator's reply lands
713
- * now that it owns the Telegram DM. No session waits for anything: the
714
- * challenge is durable state, so the two halves are ordinary commands that can
715
- * run minutes apart in different processes.
715
+ * This is the host CLI half, and it is one of two consumers of the same
716
+ * durable record: the orchestrator session's inbound path settles a reply
717
+ * sent to the project topic mechanically (#1061), while this command remains
718
+ * the console's route for a reply that landed in the operator DM. No session
719
+ * waits for anything: the challenge is durable state, so the halves are
720
+ * ordinary commands that can run minutes apart in different processes.
716
721
  *
717
722
  * The security properties are the send half's, unchanged. Only the project's
718
723
  * own record and the fleet record are read (no other project's ceremony can be
@@ -3552,18 +3557,6 @@ function armClock(ms: number): string {
3552
3557
  return minutes === 0 ? `${seconds}s` : `${minutes}m${String(seconds).padStart(2, "0")}s`;
3553
3558
  }
3554
3559
 
3555
- /**
3556
- * The one armed-marker write both proofs share: same content, same mode, and
3557
- * the same restamp of the pre-per-project shared marker the heartbeat still
3558
- * honours — a project that just armed must not leave the bare marker around to
3559
- * re-arm future fleets through `disarm` (#316).
3560
- */
3561
- function writeArmedMarker(path: string, owner: string, arm: ArmState): void {
3562
- mkdirSync(dirname(path), { recursive: true });
3563
- writeFileSync(path, `armed ${new Date().toISOString()} owner=${owner}\n`, { mode: 0o600 });
3564
- if (arm.legacy === "honoured") rmSync(legacyArmedMarkerPath(), { force: true });
3565
- }
3566
-
3567
3560
  /**
3568
3561
  * The session surface the claim-only verdict judges: the tick-cwd-derived
3569
3562
  * session directory plus, when the live claim's file lives elsewhere in the