omp-conductor 0.15.10 → 0.15.12

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.
Files changed (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +150 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +100 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +32 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The model-failover chain (#286): which model the next attempt of one run
3
+ * chain dispatches on, and how the choice is worded so a merged branch built
4
+ * on a different model stays attributable months later.
5
+ *
6
+ * Pure and synchronous: the daemon reads the run rows from the store, hands
7
+ * them here, and does what the returned choice says. Nothing in this module
8
+ * reaches a store or a provider — the same split as `failure-class.ts`, kept
9
+ * here (and NOT there) because `failure-class.ts` owns the *verdict* while
10
+ * this owns the *response to a run of provider verdicts*.
11
+ *
12
+ * The trigger is deliberately counted from the store, never from the
13
+ * harness's `modelFallbackMessage`: that message means the harness already
14
+ * downgraded internally and is evidence about a run, not a decision; keying
15
+ * on it would fire the feature when nothing needed switching and stay silent
16
+ * when a provider returns 402 cleanly.
17
+ */
18
+
19
+ import type { FailureClass, RunRecord } from "./types.ts";
20
+
21
+ /**
22
+ * Failure classes that move a chain onto its next model — and ONLY these: a
23
+ * cap kill means the slice was too big, and switching models in response
24
+ * would hide a decomposition problem behind a provider one (#286, #490).
25
+ */
26
+ export const FAILOVER_CLASSES: readonly FailureClass[] = [
27
+ "provider-transient",
28
+ "provider-credit",
29
+ ];
30
+
31
+ /** The default for a project's `modelFallbackThreshold` when it is absent or
32
+ * unusable. Two consecutive provider-class failures, then the next attempt
33
+ * moves to the next model. */
34
+ export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2;
35
+
36
+ /** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider
37
+ * failure can never push a chain sideways. */
38
+ export function isProviderFailureClass(cls: string | undefined): cls is FailureClass {
39
+ return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls);
40
+ }
41
+
42
+ /** Consecutive provider-class failures at the head of one run chain. */
43
+ export interface ProviderFailureFacts {
44
+ /** How many terminal runs at the head of the chain failed as a provider. */
45
+ streak: number;
46
+ /** Those runs' classes, newest first — the set is either one class or a mix
47
+ * of `provider-transient` and `provider-credit`. */
48
+ classes: FailureClass[];
49
+ /**
50
+ * The model the most recent failure dispatched on, when its row recorded
51
+ * one. Undefined when the rows predate the column or the project has no
52
+ * chain — the log then falls back to the configured primary, or to "harness
53
+ * default" for a project with no `workerModel`.
54
+ */
55
+ previousModel: string | undefined;
56
+ }
57
+
58
+ /**
59
+ * Counts the consecutive provider-class failures at the head of a run chain.
60
+ * `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk
61
+ * starts at the newest row and stops at the first row that is not a
62
+ * provider-class failure — a success, a non-provider failure, an unclassified
63
+ * row, a live row — so "sticky per chain, never global" falls out: a fresh
64
+ * issue has no rows and starts on the primary, and one issue's bad luck can
65
+ * never move another issue's chain.
66
+ */
67
+ export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts {
68
+ const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined };
69
+ for (let i = runs.length - 1; i >= 0; i--) {
70
+ const run = runs[i]!;
71
+ if (!isProviderFailureClass(run.failureClass)) break;
72
+ facts.streak += 1;
73
+ facts.classes.push(run.failureClass);
74
+ if (facts.previousModel === undefined && run.model !== undefined) {
75
+ facts.previousModel = run.model;
76
+ }
77
+ }
78
+ return facts;
79
+ }
80
+
81
+ /** The model the next attempt should dispatch on, and whether that is a
82
+ * failover at all. */
83
+ export interface ModelChoice {
84
+ /** Model to dispatch on; `undefined` leaves the harness default, exactly as
85
+ * an unconfigured project dispatches today. */
86
+ model: string | undefined;
87
+ /** True when the choice came from the fallback chain — the failover has
88
+ * fired and the tick report must say so. */
89
+ fallback: boolean;
90
+ }
91
+
92
+ /**
93
+ * Where one run chain's next attempt dispatches, given the chain configuration
94
+ * and the store-derived failure streak.
95
+ *
96
+ * With no chain (empty or absent `modelFallbacks`) the primary model is
97
+ * returned unchanged and `fallback` stays false — today's dispatch byte for
98
+ * byte. Once the streak reaches the threshold the chain advances one slot per
99
+ * extra failure, clamped to the last model: the chain is exhausted there, and
100
+ * the existing escalation path (the provider-transient strike cap) settles it,
101
+ * naming every model tried.
102
+ */
103
+ export function resolveDispatchModel(args: {
104
+ workerModel: string | undefined;
105
+ modelFallbacks: readonly string[] | undefined;
106
+ threshold: number;
107
+ streak: number;
108
+ }): ModelChoice {
109
+ const { workerModel, modelFallbacks, threshold, streak } = args;
110
+ if (modelFallbacks === undefined || modelFallbacks.length === 0) {
111
+ return { model: workerModel, fallback: false };
112
+ }
113
+ if (streak < threshold) return { model: workerModel, fallback: false };
114
+ const index = Math.min(streak - threshold, modelFallbacks.length - 1);
115
+ return { model: modelFallbacks[index] ?? workerModel, fallback: true };
116
+ }
117
+
118
+ /**
119
+ * The clause the tick report annexes to a failover dispatch:
120
+ * `on <fallback> after N provider-transient failures on <previous>`, the shape
121
+ * the issue asked for (`#123 attempt 3 on <fallback> after 2
122
+ * provider-transient failures on <primary>`). `undefined` when no failover
123
+ * fired, so the ordinary dispatch log line is untouched.
124
+ */
125
+ export function fallbackClause(
126
+ choice: ModelChoice,
127
+ facts: ProviderFailureFacts,
128
+ workerModel: string | undefined,
129
+ ): string | undefined {
130
+ if (!choice.fallback) return undefined;
131
+ const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider";
132
+ const countWord = facts.streak === 1 ? "failure" : "failures";
133
+ const previous = facts.previousModel ?? workerModel ?? "harness default";
134
+ return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`;
135
+ }
136
+
137
+ /** One recorded model dispatch in a chain: which model, on which attempt. */
138
+ export interface ModelTried {
139
+ model: string;
140
+ attempt: number;
141
+ }
142
+
143
+ /** Every recorded model of a chain, in chain order — what the exhaustion
144
+ * escalation names when it says "every model tried". Rows without a recorded
145
+ * model (no chain configured, or written before the column existed) are
146
+ * absent: an unnamed primary is the harness default, which the escalation
147
+ * wording covers without pretending to know its name. */
148
+ export function modelsTried(runs: readonly RunRecord[]): ModelTried[] {
149
+ const tried: ModelTried[] = [];
150
+ for (const run of runs) {
151
+ if (run.model === undefined) continue;
152
+ tried.push({ model: run.model, attempt: run.attempt });
153
+ }
154
+ return tried;
155
+ }
156
+
157
+ /**
158
+ * `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts
159
+ * grouped under their model, in chain order.
160
+ */
161
+ export function formatModelsTried(tried: readonly ModelTried[]): string {
162
+ const groups: { model: string; attempts: number[] }[] = [];
163
+ for (const { model, attempt } of tried) {
164
+ const last = groups[groups.length - 1];
165
+ if (last !== undefined && last.model === model) last.attempts.push(attempt);
166
+ else groups.push({ model, attempts: [attempt] });
167
+ }
168
+ return groups
169
+ .map(({ model, attempts }) => {
170
+ const span =
171
+ attempts.length === 1
172
+ ? `attempt ${attempts[0]}`
173
+ : `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`;
174
+ return `${model} (${span})`;
175
+ })
176
+ .join(", ");
177
+ }
package/src/notices.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** The deterministic primary id of a held notice derived from its escalation
4
+ * key. Sha256 of the key — 64 hex characters, a shape the digest handoff
5
+ * accepts verbatim — keeps the re-derived notice from a later poll idempotent
6
+ * against `ON CONFLICT(id) DO NOTHING` while remaining a real ledger row id. */
7
+ export function heldNoticeId(key: string): string {
8
+ return createHash("sha256").update(`held-notice\0${key}`).digest("hex");
9
+ }
package/src/omp.ts CHANGED
@@ -19,14 +19,15 @@ import { tmpdir } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
 
21
21
  import { readOnlySession, worktreeConfinement } from "./confinement.ts";
22
- import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
22
+ import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
23
23
  import type {
24
24
  HostToParent,
25
25
  ParentToHost,
26
26
  SessionHostSpec,
27
27
  } from "./session-host.ts";
28
28
  import { decodeFrames, encodeFrame } from "./session-host.ts";
29
- import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
29
+ import type { ResolvedGrants, SessionRole } from "./types.ts";
30
+ import { SESSION_ROLE_ENV } from "./types.ts";
30
31
  import { conductorVerbs } from "./verbs/client.ts";
31
32
 
32
33
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -158,7 +159,7 @@ export async function createLocalSession(opts: {
158
159
  /** Install the release/deploy tool-call gate with these per-shape grants. */
159
160
  releaseGrants?: ResolvedGrants;
160
161
  /** Durable audit callback invoked only when that gate rejects a call. */
161
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
162
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
162
163
  /**
163
164
  * The conductor verb socket this session's mutation tools call (#126).
164
165
  *
@@ -414,7 +415,7 @@ export interface CreateSessionOptions {
414
415
  resume?: boolean;
415
416
  role: SessionRole;
416
417
  releaseGrants?: ResolvedGrants;
417
- onReleaseBlocked?: (shape: ReleaseShape, context: ReleaseBlockContext) => void;
418
+ onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
418
419
 
419
420
  /**
420
421
  * Where the control socket is bound. The daemon puts it beside the run's own
@@ -559,6 +560,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
559
560
  const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
560
561
  const child = Bun.spawn(argv, {
561
562
  cwd: opts.cwd,
563
+ // Stamp the session's own role on the child, so a process the agent runs —
564
+ // `omp-conductor report` from its sandbox — can tell a worker session from
565
+ // the operator's shell. Direct CLI runs outside a spawned session inherit
566
+ // nothing and stay the orchestrator surface.
567
+ env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
562
568
  stdin: "ignore",
563
569
  // The harness writes progress to stdout; both streams are the daemon's log,
564
570
  // never the protocol. The protocol has its own socket precisely so a chatty
@@ -617,6 +623,67 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
617
623
  return stderrTail.trim();
618
624
  };
619
625
 
626
+ /**
627
+ * The child's own start failure, if it got one out over the socket before it
628
+ * died — the socket counterpart of the pipe drain.
629
+ *
630
+ * A child that cannot start can connect and write `start-error` in the same
631
+ * event-loop turn its exit is dispatched in: the connect already completed
632
+ * in the kernel, the accept is still queued, and closing the listener in
633
+ * that window drops the frame with the reason. So every exit route waits
634
+ * this out BEFORE {@link cleanup} closes the server, then prefers the frame
635
+ * over a bare exit code. Bounded like the pipes: a dead child that never
636
+ * connected has nothing on the wire, and 2s only ever delays an
637
+ * already-failed startup.
638
+ *
639
+ * Memoized: the pre-connect catch and the exit handler both arrive here, and
640
+ * both must read the SAME socket — two independent readers would race on the
641
+ * first `destroy()` and one of them would lose the frame.
642
+ */
643
+ let startErrorReading: Promise<string | undefined> | undefined;
644
+ const startErrorFromSocket = (): Promise<string | undefined> => {
645
+ startErrorReading ??= (async (): Promise<string | undefined> => {
646
+ const socket = await Promise.race([
647
+ attached.catch(() => undefined),
648
+ new Promise<undefined>((resolve) => {
649
+ setTimeout(() => resolve(undefined), DRAIN_GRACE_MS).unref?.();
650
+ }),
651
+ ]);
652
+ if (socket === undefined) return undefined;
653
+ try {
654
+ return await new Promise<string | undefined>((resolve) => {
655
+ let buffer = "";
656
+ let settled = false;
657
+ const finish = (message: string | undefined): void => {
658
+ if (settled) return;
659
+ settled = true;
660
+ clearTimeout(timer);
661
+ resolve(message);
662
+ };
663
+ const timer = setTimeout(() => finish(undefined), DRAIN_GRACE_MS);
664
+ timer.unref?.();
665
+ socket.on("data", (chunk: Buffer) => {
666
+ buffer += chunk.toString("utf8");
667
+ const { frames, rest } = decodeFrames(buffer);
668
+ buffer = rest;
669
+ for (const frame of frames) {
670
+ const message = frame as { t?: unknown; message?: unknown };
671
+ if (message.t === "start-error" && typeof message.message === "string") {
672
+ finish(message.message);
673
+ return;
674
+ }
675
+ }
676
+ });
677
+ socket.once("error", () => finish(undefined));
678
+ socket.once("close", () => finish(undefined));
679
+ });
680
+ } finally {
681
+ socket.destroy();
682
+ }
683
+ })();
684
+ return startErrorReading;
685
+ };
686
+
620
687
  const cleanup = (): void => {
621
688
  server.close();
622
689
  rmSync(socketPath, { force: true });
@@ -649,15 +716,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
649
716
 
650
717
  void child.exited.then(async (code) => {
651
718
  onExit();
719
+ // A child that cannot start writes `start-error` over the socket, not the
720
+ // pipes — and its exit can be dispatched before the accept of a connection
721
+ // that already completed in the kernel. Let the socket settle before the
722
+ // listener closes, so the child's own words survive the ordering.
723
+ if (disposing) {
724
+ cleanup();
725
+ return;
726
+ }
727
+ const startError = await startErrorFromSocket();
652
728
  cleanup();
653
- // A child that exits during teardown exited because we asked it to. Only an
654
- // exit while the session is supposed to be live is a failure — and it has
655
- // to reach whoever is awaiting a prompt, or the dispatcher waits forever
656
- // for a turn from a process that is gone.
657
- if (disposing) return;
658
729
  // The tail first, so the message carries the child's own words.
659
730
  await settledTail();
660
- fail(`omp-conductor session child exited ${String(code)} before the session ended`);
731
+ fail(
732
+ startError ?? `omp-conductor session child exited ${String(code)} before the session ended`,
733
+ );
661
734
  });
662
735
 
663
736
  let socket: Socket;
@@ -681,15 +754,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
681
754
  ]);
682
755
  } catch (err) {
683
756
  child.kill("SIGKILL");
757
+ // The child's exit can win the race above against the accept of a
758
+ // connection that completed in the kernel — then its `start-error` frame
759
+ // is still queued, and closing the listener now would throw it away.
760
+ // Read the socket first (bounded, like the pipes), and prefer its words
761
+ // over a bare exit code.
762
+ const startError = await startErrorFromSocket();
684
763
  cleanup();
685
764
  // Killed first, so the pipes are already closing, and only then read. This is
686
765
  // the route a child that dies before connecting takes — a missing peer
687
766
  // dependency, say — and reading `stderrTail` synchronously here raced the
688
767
  // drain loops and reported a bare exit code instead of the reason.
689
768
  const tail = await settledTail();
690
- throw new Error(
691
- `${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
692
- );
769
+ const reason = startError ?? (err instanceof Error ? err.message : String(err));
770
+ throw new Error(`${reason}${tail === "" ? "" : `\nchild output:\n${tail}`}`);
693
771
  }
694
772
 
695
773
  let buffer = "";
@@ -739,6 +817,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
739
817
  tool: message.tool,
740
818
  reason: message.reason,
741
819
  args: message.args,
820
+ ...(message.matched === undefined ? {} : { matched: message.matched }),
821
+ ...(message.invocation === undefined ? {} : { invocation: message.invocation }),
742
822
  });
743
823
  break;
744
824
  }