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.
- package/package.json +1 -1
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon.ts +2 -2
- package/src/diff-flags.ts +111 -6
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +182 -1
- package/src/fleet.ts +13 -20
- package/src/orchestrator-tick.ts +296 -24
- package/src/settlement.ts +35 -5
- package/src/status-render.ts +11 -7
- package/src/store.ts +14 -2
- package/src/to-spec.ts +233 -24
- package/src/types.ts +18 -3
- package/src/worker.ts +149 -35
package/src/failure-class.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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:
|
|
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
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
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
|