omp-conductor 0.10.0 → 0.13.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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The ordered-migration-chain guard (#227).
3
+ *
4
+ * A project that declares `routing.repos.<repo>.migrations.dir` gets its
5
+ * merges checked against one invariant: after the merge, the base branch's
6
+ * `revision`/`down_revision` graph has exactly one head. The check runs on the
7
+ * base tip at merge time, against the PR's diff — a stale parent, a reused
8
+ * revision id, or a deleted migration is refused before the merge, because a
9
+ * wrong merge here is silent production schema drift.
10
+ *
11
+ * Alembic-convention only (`revision = "<id>"` / `down_revision = <expr>` in
12
+ * `*.py`); a repo without the config key is never touched. Pure by
13
+ * construction: every fact arrives as strings, and the module reads no files.
14
+ */
15
+
16
+ import type { PrDiffFile } from "./types.ts";
17
+
18
+ /** One migration file's identity, as the chain check reasons about it. */
19
+ export interface ChainEntry {
20
+ /** Repo-relative path of the file that declares the revision. */
21
+ path: string;
22
+ /** The revision id this file declares. */
23
+ id: string;
24
+ /** Parent revision ids; empty for a root. */
25
+ parents: string[];
26
+ }
27
+
28
+ const REVISION_LINE = /^\s*revision(?::[^=]*)?\s*=\s*["']([^"']+)["']/m;
29
+ const DOWN_REVISION_LINE = /^\s*down_revision(?::[^=]*)?\s*=\s*(.*)$/m;
30
+ const QUOTED = /["']([^"']+)["']/g;
31
+
32
+ /**
33
+ * Parse one migration file's source into its chain identity, or `undefined`
34
+ * when it declares no `revision` (not a chain file; skip it).
35
+ *
36
+ * `down_revision` may be `"abc"`, `None`, or a tuple `('a', 'b')` (a merge
37
+ * migration) — the parents are every quoted string on the right-hand side, so
38
+ * `None`/empty reads as a root without special-casing syntax.
39
+ */
40
+ export function parseChainSource(path: string, source: string): ChainEntry | undefined {
41
+ const id = REVISION_LINE.exec(source)?.[1];
42
+ if (id === undefined) return undefined;
43
+ const parents: string[] = [];
44
+ const rhs = DOWN_REVISION_LINE.exec(source)?.[1] ?? "";
45
+ for (const m of rhs.matchAll(QUOTED)) {
46
+ const parent = m[1];
47
+ if (parent !== undefined && !parents.includes(parent)) parents.push(parent);
48
+ }
49
+ return { path, id, parents };
50
+ }
51
+
52
+ /** Post-image source of one diff file: the added lines, rejoined as a file. */
53
+ function postImageSource(file: PrDiffFile): string {
54
+ const hunks = file.hunks ?? "";
55
+ const lines: string[] = [];
56
+ for (const line of hunks.split("\n")) {
57
+ if (line.startsWith("+")) lines.push(line.slice(1));
58
+ }
59
+ return lines.join("\n");
60
+ }
61
+
62
+ /** Whether one diff entry lives inside the chain directory. */
63
+ function inChainDir(dir: string, path: string): boolean {
64
+ return path.startsWith(`${dir}/`) && path.endsWith(".py");
65
+ }
66
+
67
+ /**
68
+ * What the PR does to the chain: entries added, entries changed (post-image
69
+ * used — a modified file's base entry stands when its added lines declare no
70
+ * revision), and chain files deleted.
71
+ */
72
+ export function chainEntriesFromDiff(
73
+ files: readonly PrDiffFile[],
74
+ dir: string,
75
+ ): { added: ChainEntry[]; changed: ChainEntry[]; deleted: string[] } {
76
+ const added: ChainEntry[] = [];
77
+ const changed: ChainEntry[] = [];
78
+ const deleted: string[] = [];
79
+ for (const file of files) {
80
+ if (!inChainDir(dir, file.path)) continue;
81
+ if (file.status === "removed") {
82
+ deleted.push(file.path);
83
+ continue;
84
+ }
85
+ const entry = parseChainSource(file.path, postImageSource(file));
86
+ if (entry === undefined) continue;
87
+ if (file.status === "added") added.push(entry);
88
+ else changed.push(entry); // modified, or renamed into the chain directory
89
+ }
90
+ return { added, changed, deleted };
91
+ }
92
+
93
+ export interface ChainViolationInput {
94
+ base: ChainEntry[];
95
+ added: ChainEntry[];
96
+ changed: ChainEntry[];
97
+ deleted: string[];
98
+ }
99
+
100
+ /**
101
+ * Refuse a merge that would corrupt the ordered chain, or return [] to pass.
102
+ *
103
+ * A combined graph that no PR touches is never judged: a broken base is not
104
+ * this PR's problem.
105
+ */
106
+ export function chainViolations(input: ChainViolationInput): string[] {
107
+ const touched = input.added.length + input.changed.length + input.deleted.length;
108
+ if (touched === 0) return [];
109
+
110
+ const violations: string[] = [];
111
+
112
+ // 1. The one deletion nothing can justify: a published migration may not
113
+ // disappear from the base branch.
114
+ for (const path of input.deleted) {
115
+ if (input.base.some((entry) => entry.path === path)) {
116
+ violations.push(`deletes ${path}, which exists on the base branch — a published migration may never be deleted`);
117
+ }
118
+ }
119
+
120
+ // 2. Reusing an id that already exists on the base at a DIFFERENT path makes
121
+ // two files claim one revision; the graph stops being an order.
122
+ const baseByPath = new Map(input.base.map((entry) => [entry.path, entry]));
123
+ for (const entry of [...input.added, ...input.changed]) {
124
+ const owner = input.base.find((b) => b.id === entry.id);
125
+ if (owner !== undefined && owner.path !== entry.path) {
126
+ violations.push(`reuses revision id ${entry.id}, already declared by ${owner.path} on the base branch`);
127
+ }
128
+ }
129
+
130
+ // 3. Head coherence: the combined graph — base, minus deleted files, minus
131
+ // changed files' base entries (replaced by their post-image), plus added
132
+ // entries — must have exactly one head. Two heads means a stale parent or
133
+ // a fork nobody repaired; one head after a repair merge is the goal. A
134
+ // duplicate id inside the PR's own entries is incoherent too.
135
+ const combined = new Map(baseByPath);
136
+ for (const path of input.deleted) combined.delete(path);
137
+ for (const entry of input.changed) combined.delete(entry.path);
138
+ for (const entry of [...input.changed, ...input.added]) combined.set(entry.path, entry);
139
+
140
+ const ids = [...combined.values()].map((entry) => entry.id);
141
+ const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
142
+ if (duplicateIds.length > 0) {
143
+ violations.push(
144
+ `the migration graph would declare duplicate revision id${duplicateIds.length > 1 ? "s" : ""} ${[...new Set(duplicateIds)].join(", ")} after merge`,
145
+ );
146
+ }
147
+
148
+ const referenced = new Set([...combined.values()].flatMap((entry) => entry.parents));
149
+ const heads = ids.filter((id, index) => ids.indexOf(id) === index && !referenced.has(id));
150
+ if (heads.length !== 1) {
151
+ violations.push(
152
+ `the migration graph would have ${heads.length} heads after merge (${heads.join(", ") || "none"}); parent(s) must be the current tip`,
153
+ );
154
+ }
155
+
156
+ return violations;
157
+ }
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  startHerdrFleet,
37
37
  } from "./fleet.ts";
38
38
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
39
+ import { readBaseChain } from "./gitops.ts";
39
40
  import {
40
41
  clearRecord,
41
42
  DEFAULT_PORT,
@@ -48,6 +49,7 @@ import {
48
49
  } from "./lifecycle.ts";
49
50
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
50
51
  import { digestDedupeKey } from "./reports.ts";
52
+ import { digestDue } from "./digest-schedule.ts";
51
53
  import {
52
54
  briefPathForProject,
53
55
  policyPathForProject,
@@ -61,7 +63,7 @@ import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
61
63
  import { makeTracker } from "./tracker/github.ts";
62
64
  import { githubVerbActions } from "./verbs/actions.ts";
63
65
  import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
64
- import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
66
+ import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
65
67
  import type { ProjectConfig, ReportKind } from "./types.ts";
66
68
  import { formatUnblock, unblockIssue } from "./unblock.ts";
67
69
  import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
@@ -102,6 +104,9 @@ usage:
102
104
  omp-conductor release-pane [--project NAME]
103
105
  omp-conductor tail <issue> [--project NAME]
104
106
  omp-conductor extend <issue> --turns N [--project NAME]
107
+ omp-conductor worker pause <issue> [--project NAME]
108
+ omp-conductor worker resume <issue> [--project NAME]
109
+ omp-conductor worker stop <issue> --reason TEXT [--project NAME]
105
110
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
106
111
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
107
112
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -168,8 +173,12 @@ usage:
168
173
  the daemon rather than terminals, so this is the only way to watch
169
174
  one live. Runs until Ctrl-C, or until the run has finished and its
170
175
  transcript has stopped growing.
171
- extend monotonically raise a live run's turn ceiling without restarting its
172
- session. Refuses settled runs and values at or below its current cap.
176
+ extend raise a live run's turn ceiling, or set a bounded one-shot ceiling
177
+ after a failed, killed, orphaned or blocked run. Refuses values outside
178
+ configured bounds.
179
+ worker cooperatively pause one live worker at harness idle, then resume the
180
+ same session with a continuation prompt. Its wall clock is frozen
181
+ while parked. Distinct from fleet-level pause/resume.
173
182
  unblock clear <issue>'s blocked and failed labels so the next tick can claim
174
183
  it again — the supported way back for an escalation you answered,
175
184
  and why the brief's "never hand-edit a state label" rule can stay
@@ -605,14 +614,11 @@ try {
605
614
  }
606
615
 
607
616
  /**
608
- * The action ledger (#126): every conductor-verb call, what it asked for,
609
- * and how the daemon decided it.
610
- *
611
- * Its own command as well as a block in `status`, because the two questions
612
- * are different sizes. `status` answers "is anything being refused right
613
- * now"; this answers "what did run 3 actually try to do", which is the
614
- * question an escalation about a run asks, and it needs the whole record
615
- * rather than the newest five lines of every run at once.
617
+ * The action ledger: mediated conductor verbs plus durable per-issue budget
618
+ * changes. Its own command as well as a block in `status`, because the two
619
+ * questions are different sizes. `status` answers "what is pending or being
620
+ * refused now"; this answers "what did run 3 actually try, and what budget
621
+ * did the operator assign", which needs history rather than only live state.
616
622
  */
617
623
  case "ledger": {
618
624
  const cfg = loadConfig();
@@ -634,18 +640,38 @@ try {
634
640
  ...(issue === undefined ? {} : { issue }),
635
641
  limit,
636
642
  });
637
- if (entries.length === 0) {
643
+ const overrides = store.turnOverrideLedger(p.name, {
644
+ ...(issue === undefined ? {} : { issue }),
645
+ limit,
646
+ });
647
+ if (entries.length === 0 && overrides.length === 0) {
638
648
  process.stdout.write(
639
- `no conductor-verb calls recorded for ${p.name}` +
649
+ `no conductor actions recorded for ${p.name}` +
640
650
  `${issue === undefined ? "" : ` on #${String(issue)}`}\n`,
641
651
  );
642
652
  break;
643
653
  }
644
- const refused = entries.filter((e) => e.decision === "refused").length;
645
- process.stdout.write(
646
- `${p.name} ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
647
- `${entries.flatMap(formatVerbLedgerEntry).join("\n")}\n`,
648
- );
654
+ const blocks: string[] = [];
655
+ if (entries.length > 0) {
656
+ const refused = entries.filter((e) => e.decision === "refused").length;
657
+ blocks.push(
658
+ `${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)\n` +
659
+ entries.flatMap(formatVerbLedgerEntry).join("\n"),
660
+ );
661
+ }
662
+ if (overrides.length > 0) {
663
+ blocks.push(
664
+ `${p.name} — ${overrides.length} turn override(s) (newest first)\n` +
665
+ overrides
666
+ .map(
667
+ (entry) =>
668
+ ` ${new Date(entry.setAt).toISOString()} #${entry.issue} ` +
669
+ `${entry.maxTurns} turns`,
670
+ )
671
+ .join("\n"),
672
+ );
673
+ }
674
+ process.stdout.write(`${blocks.join("\n\n")}\n`);
649
675
  } finally {
650
676
  store.close();
651
677
  }
@@ -767,14 +793,30 @@ try {
767
793
  );
768
794
  const payload = (await response.json()) as {
769
795
  error?: unknown;
796
+ kind?: unknown;
770
797
  runId?: unknown;
771
798
  maxTurns?: unknown;
799
+ issue?: unknown;
800
+ nextAttemptMaxTurns?: unknown;
801
+ baseMaxTurns?: unknown;
772
802
  };
773
803
  if (!response.ok) {
774
804
  throw new Error(
775
805
  typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
776
806
  );
777
807
  }
808
+ if (
809
+ payload.kind === "next-attempt" &&
810
+ payload.issue === issue &&
811
+ typeof payload.nextAttemptMaxTurns === "number" &&
812
+ typeof payload.baseMaxTurns === "number"
813
+ ) {
814
+ process.stdout.write(
815
+ `#${issue} next attempt turn ceiling set to ${payload.nextAttemptMaxTurns} ` +
816
+ `(project base ${payload.baseMaxTurns})\n`,
817
+ );
818
+ break;
819
+ }
778
820
  if (typeof payload.runId !== "string" || typeof payload.maxTurns !== "number") {
779
821
  throw new Error("daemon returned an invalid turn-extension response");
780
822
  }
@@ -784,6 +826,96 @@ try {
784
826
  break;
785
827
  }
786
828
 
829
+ case "worker": {
830
+ const sub = argv[1];
831
+ if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
832
+ process.stderr.write(
833
+ "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
834
+ );
835
+ process.exit(2);
836
+ }
837
+ const issue = issueArg("worker", argv[2]);
838
+ const rawReason = sub === "stop" ? flag(argv, "reason") : undefined;
839
+ const reason = rawReason?.trim().replace(/\s+/g, " ");
840
+ if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
841
+ process.stderr.write(
842
+ "omp-conductor: worker stop needs --reason with 1-500 characters\n",
843
+ );
844
+ process.exit(2);
845
+ }
846
+ const project = findProject(loadConfig(), flag(argv, "project"));
847
+ const daemon = livingDaemon();
848
+ if (daemon === undefined) throw new Error("daemon is not running");
849
+ if (daemon.project !== undefined && daemon.project !== project.name) {
850
+ throw new Error(
851
+ `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
852
+ );
853
+ }
854
+ const response = await fetch(
855
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
856
+ {
857
+ method: "PUT",
858
+ headers: { "content-type": "application/json" },
859
+ body: JSON.stringify({
860
+ project: project.name,
861
+ ...(reason === undefined ? {} : { reason }),
862
+ }),
863
+ },
864
+ );
865
+ const payload = (await response.json()) as {
866
+ error?: unknown;
867
+ runId?: unknown;
868
+ phase?: unknown;
869
+ outcome?: unknown;
870
+ state?: unknown;
871
+ reason?: unknown;
872
+ salvageSha?: unknown;
873
+ salvageError?: unknown;
874
+ worktree?: unknown;
875
+ };
876
+ if (!response.ok) {
877
+ throw new Error(
878
+ typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
879
+ );
880
+ }
881
+ if (sub === "stop") {
882
+ if (
883
+ typeof payload.runId !== "string" ||
884
+ typeof payload.state !== "string" ||
885
+ (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
886
+ ) {
887
+ throw new Error("daemon returned an invalid worker-stop response");
888
+ }
889
+ if (payload.outcome === "already-terminal") {
890
+ process.stdout.write(
891
+ `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
892
+ );
893
+ break;
894
+ }
895
+ if (typeof payload.reason !== "string") {
896
+ throw new Error("daemon returned an invalid worker-stop response");
897
+ }
898
+ process.stdout.write(
899
+ `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
900
+ );
901
+ if (typeof payload.salvageSha === "string") {
902
+ process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
903
+ }
904
+ if (typeof payload.salvageError === "string") {
905
+ process.stdout.write(
906
+ `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
907
+ `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
908
+ );
909
+ }
910
+ break;
911
+ }
912
+ if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
913
+ throw new Error("daemon returned an invalid worker-control response");
914
+ }
915
+ process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
916
+ break;
917
+ }
918
+
787
919
  case "unblock": {
788
920
  const issue = issueArg("unblock", argv[1]);
789
921
  const cfg = loadConfig();
@@ -873,6 +1005,7 @@ try {
873
1005
  pausedAt,
874
1006
  log: (m) => process.stderr.write(`${m}\n`),
875
1007
  now: () => Date.now(),
1008
+ chain: { readBaseChain },
876
1009
  },
877
1010
  channel,
878
1011
  { verb: name, args },
@@ -914,15 +1047,51 @@ try {
914
1047
  const store = openStore(dbPath());
915
1048
  try {
916
1049
  const at = Date.now();
1050
+ // #229: the policy decides what may interrupt the phone. A kind the
1051
+ // policy defers is refused here rather than silently turning into a
1052
+ // page, or a digest going out off-schedule.
1053
+ const policy = project.reporting;
1054
+ if (kind === "digest") {
1055
+ const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
1056
+ if (digestPolicy.cadence === "none") {
1057
+ process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
1058
+ process.exit(2);
1059
+ }
1060
+ if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
1061
+ const lastKey = store.lastDigestDedupeKey(project.name);
1062
+ const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
1063
+ if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
1064
+ process.stderr.write(
1065
+ `omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
1066
+ `${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
1067
+ );
1068
+ process.exit(2);
1069
+ }
1070
+ }
1071
+ }
1072
+ if (kind === "material") {
1073
+ const interruptOn = policy?.interruptOn;
1074
+ if (interruptOn !== undefined && !interruptOn.includes("material")) {
1075
+ process.stderr.write(
1076
+ "omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
1077
+ );
1078
+ process.exit(2);
1079
+ }
1080
+ }
917
1081
  const { report, deduped } = store.enqueueReport({
918
1082
  project: project.name,
919
1083
  kind,
920
1084
  body,
921
1085
  // Only the digest is at-most-once. A material report describes one
922
1086
  // event as it happens, and two of those in a day are two events.
923
- ...(kind === "digest" ? { dedupeKey: digestDedupeKey(at) } : {}),
1087
+ ...(kind === "digest"
1088
+ ? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
1089
+ : {}),
924
1090
  at,
925
1091
  });
1092
+ // The held notices an accepted digest re-surfaces are now owed by it,
1093
+ // whatever the model goes on to write.
1094
+ if (kind === "digest") store.markNoticesDigested(project.name, at);
926
1095
  process.stdout.write(
927
1096
  deduped
928
1097
  ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +