omp-conductor 0.3.18 → 0.3.20

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/fleet.ts CHANGED
@@ -28,7 +28,8 @@ import { createInterface } from "node:readline";
28
28
  import { homedir } from "node:os";
29
29
  import { dirname, join } from "node:path";
30
30
  import { findProject, loadConfig, stateDir } from "./config.ts";
31
- import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
31
+ import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
32
+ import { formatDispatchSummary, isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
32
33
  import {
33
34
  healthCheck,
34
35
  isAlive,
@@ -873,12 +874,61 @@ export function fleetLayers(projectName?: string): FleetLayers {
873
874
  };
874
875
  }
875
876
 
877
+ export function codeGraphFromHealthz(body: string | undefined, project: string): CodeGraphHealth | undefined {
878
+ if (body === undefined) return undefined;
879
+ try {
880
+ const parsed = JSON.parse(body) as Record<string, unknown>;
881
+ if (parsed["project"] !== project) return undefined;
882
+ const graph = parsed["codeGraph"];
883
+ if (graph === null || typeof graph !== "object" || Array.isArray(graph)) return undefined;
884
+ const value = graph as Record<string, unknown>;
885
+ if (value["configured"] === false) return { configured: false };
886
+ if (
887
+ value["configured"] !== true ||
888
+ !["healthy", "degraded", "unknown"].includes(String(value["status"])) ||
889
+ typeof value["checkedAt"] !== "string" ||
890
+ value["prerequisites"] === null ||
891
+ typeof value["prerequisites"] !== "object" ||
892
+ !Array.isArray(value["repos"]) ||
893
+ value["timer"] === null ||
894
+ typeof value["timer"] !== "object" ||
895
+ value["refresh"] === null ||
896
+ typeof value["refresh"] !== "object" ||
897
+ !Array.isArray(value["reasons"])
898
+ ) {
899
+ return undefined;
900
+ }
901
+ return graph as CodeGraphHealth;
902
+ } catch {
903
+ return undefined;
904
+ }
905
+ }
906
+
907
+ export function formatCodeGraphHealth(graph: CodeGraphHealth, now = Date.now()): string | undefined {
908
+ if (!graph.configured) return undefined;
909
+ const indexed = graph.repos.filter((repo) => repo.index === "present").length;
910
+ let refresh: string = graph.refresh.result;
911
+ if (graph.refresh.lastSuccessAt !== undefined) {
912
+ const ageMs = Math.max(0, now - Date.parse(graph.refresh.lastSuccessAt));
913
+ refresh = `${graph.refresh.lastSuccessAt} (${Math.max(1, Math.ceil(ageMs / 60_000))}m ago)`;
914
+ }
915
+ return [
916
+ `code graph ${graph.status} ${indexed}/${graph.repos.length} repos indexed`,
917
+ ` indexer ${graph.prerequisites.indexer}`,
918
+ ` MCP mount ${graph.prerequisites.mcpMount}`,
919
+ ` timer ${graph.timer.enabled} / ${graph.timer.active}`,
920
+ ` refresh ${refresh}`,
921
+ ...graph.reasons.map((reason) => ` - ${reason}`),
922
+ ].join("\n");
923
+ }
924
+
876
925
  export function formatFleetStatus(
877
926
  s: StatusSnapshot,
878
927
  layers: FleetLayers,
879
928
  daemonHealth?: { ok: boolean; body?: string },
880
929
  telegram: TelegramHealth = { kind: "unprobed" },
881
930
  now = Date.now(),
931
+ codeGraph: CodeGraphHealth = { configured: false },
882
932
  ): string {
883
933
  const tickLine =
884
934
  layers.ticksDetail === undefined
@@ -917,7 +967,7 @@ export function formatFleetStatus(
917
967
  daemonHealth === undefined
918
968
  ? "unprobed"
919
969
  : daemonHealth.ok
920
- ? `ok ${daemonHealth.body ?? ""}`.trimEnd()
970
+ ? "ok"
921
971
  : "unreachable — the process is up but not serving";
922
972
  const rss = rssBytesFromHealthz(daemonHealth?.body);
923
973
  daemonBlock = [
@@ -930,6 +980,7 @@ export function formatFleetStatus(
930
980
  ].join("\n");
931
981
  }
932
982
 
983
+ const graphBlock = formatCodeGraphHealth(codeGraph, now);
933
984
  return [
934
985
  `dispatch ${layers.dispatch}`,
935
986
  tickLine,
@@ -938,6 +989,7 @@ export function formatFleetStatus(
938
989
  recoveryLine,
939
990
  herdrLine,
940
991
  telegramLine,
992
+ ...(graphBlock === undefined ? [] : [graphBlock]),
941
993
  daemonBlock,
942
994
  "",
943
995
  formatProjectBody(s),
@@ -956,9 +1008,12 @@ function formatProjectBody(s: StatusSnapshot): string {
956
1008
  s.caps.dailySpendUsd === null
957
1009
  ? ` spend today $${s.spendTodayUsd.toFixed(2)} (no daily cap)`
958
1010
  : ` spend today $${s.spendTodayUsd.toFixed(2)} / $${s.caps.dailySpendUsd.toFixed(2)}`,
959
- ` worker max turns ${s.caps.workerMaxTurns}`,
1011
+ ` new worker turns ${s.caps.workerMaxTurns}`,
960
1012
  ` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
961
- ` attempts per issue ${s.caps.maxAttemptsPerIssue}`,
1013
+ ` failed attempts ${s.caps.maxAttemptsPerIssue}`,
1014
+ ` continuations ${s.caps.maxContinuationsPerIssue}`,
1015
+ "",
1016
+ formatDispatchSummary(s.dispatch),
962
1017
  "",
963
1018
  ];
964
1019
  if (s.activeRuns.length === 0) {
@@ -968,7 +1023,7 @@ function formatProjectBody(s: StatusSnapshot): string {
968
1023
  for (const r of s.activeRuns) {
969
1024
  lines.push(
970
1025
  ` #${r.issue} ${r.repo} ${r.state} attempt ${r.attempt} ` +
971
- `${r.turns} turns $${r.spendUsd.toFixed(2)} ${r.branch}` +
1026
+ `${r.turns}/${r.maxTurns} turns $${r.spendUsd.toFixed(2)} ${r.branch}` +
972
1027
  (r.prUrl ? ` ${r.prUrl}` : ""),
973
1028
  );
974
1029
  }
@@ -986,12 +1041,15 @@ function formatProjectBody(s: StatusSnapshot): string {
986
1041
  export async function renderStatus(projectName?: string): Promise<string> {
987
1042
  const s = statusSnapshot(projectName);
988
1043
  const layers = fleetLayers(projectName);
1044
+ const project = findProject(loadConfig(), projectName);
989
1045
  const rec = livingDaemon();
990
1046
  const [health, telegram] = await Promise.all([
991
1047
  rec === undefined ? undefined : healthCheck(rec.port),
992
1048
  probeTelegramHealth(projectName),
993
1049
  ]);
994
- return formatFleetStatus(s, layers, health, telegram);
1050
+ const cached = codeGraphFromHealthz(health?.body, project.name);
1051
+ const codeGraph = cached ?? (await probeCodeGraph(project));
1052
+ return formatFleetStatus(s, layers, health, telegram, Date.now(), codeGraph);
995
1053
  }
996
1054
 
997
1055
  // ---------------------------------------------------------------------------
@@ -0,0 +1,296 @@
1
+ import { existsSync } from "node:fs";
2
+ import { graphRepos, REINDEX_UNIT, resolvePrereqs, type GraphPrereqs } from "./graph.ts";
3
+ import type { ProjectConfig } from "./types.ts";
4
+
5
+ export const GRAPH_PROBE_TIMEOUT_MS = 1_000;
6
+ export const GRAPH_FRESHNESS_MS = 45 * 60_000;
7
+
8
+ type CheckState = "present" | "missing" | "unknown";
9
+
10
+ export interface CodeGraphRepoHealth {
11
+ name: string;
12
+ path: string;
13
+ clone: CheckState;
14
+ index: CheckState;
15
+ }
16
+
17
+ export type CodeGraphHealth =
18
+ | { configured: false }
19
+ | {
20
+ configured: true;
21
+ status: "healthy" | "degraded" | "unknown";
22
+ checkedAt: string;
23
+ prerequisites: {
24
+ indexer: CheckState;
25
+ mcpMount: CheckState;
26
+ };
27
+ repos: CodeGraphRepoHealth[];
28
+ timer: {
29
+ enabled: "enabled" | "disabled" | "unknown";
30
+ active: "active" | "inactive" | "unknown";
31
+ };
32
+ refresh: {
33
+ result: "success" | "failed" | "unknown";
34
+ fresh: boolean | null;
35
+ lastSuccessAt?: string;
36
+ ageMs?: number;
37
+ };
38
+ reasons: string[];
39
+ };
40
+
41
+ export type ReadOnlyCommandResult =
42
+ | { kind: "completed"; exitCode: number; stdout: string }
43
+ | { kind: "timeout" }
44
+ | { kind: "unavailable" };
45
+
46
+ /** Immediate cache value while the first bounded host probe is in flight. */
47
+ export function pendingCodeGraph(project: ProjectConfig, now = Date.now()): CodeGraphHealth {
48
+ const repos = graphRepos(project);
49
+ if (repos.length === 0) return { configured: false };
50
+ return {
51
+ configured: true,
52
+ status: "unknown",
53
+ checkedAt: new Date(now).toISOString(),
54
+ prerequisites: { indexer: "unknown", mcpMount: "unknown" },
55
+ repos: repos.map((repo) => ({
56
+ name: repo.name,
57
+ path: repo.graphProject,
58
+ clone: "unknown",
59
+ index: "unknown",
60
+ })),
61
+ timer: { enabled: "unknown", active: "unknown" },
62
+ refresh: { result: "unknown", fresh: null },
63
+ reasons: ["graph health probe pending"],
64
+ };
65
+ }
66
+
67
+ export interface CodeGraphProbeDeps {
68
+ prereqs(): GraphPrereqs;
69
+ exists(path: string): boolean;
70
+ run(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult>;
71
+ now(): number;
72
+ }
73
+
74
+ async function runReadOnly(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult> {
75
+ try {
76
+ const proc = Bun.spawn([command, ...args], {
77
+ stdin: "ignore",
78
+ stdout: "pipe",
79
+ stderr: "ignore",
80
+ });
81
+ let timedOut = false;
82
+ const timer = setTimeout(() => {
83
+ timedOut = true;
84
+ proc.kill("SIGKILL");
85
+ }, GRAPH_PROBE_TIMEOUT_MS);
86
+ try {
87
+ const [exitCode, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]);
88
+ if (timedOut) return { kind: "timeout" };
89
+ return { kind: "completed", exitCode, stdout };
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ } catch {
94
+ return { kind: "unavailable" };
95
+ }
96
+ }
97
+
98
+ const DEFAULT_DEPS: CodeGraphProbeDeps = {
99
+ prereqs: resolvePrereqs,
100
+ exists: existsSync,
101
+ run: runReadOnly,
102
+ now: Date.now,
103
+ };
104
+
105
+ function indexedRoots(raw: string): Set<string> | undefined {
106
+ let value: unknown;
107
+ try {
108
+ value = JSON.parse(raw);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
113
+ const projects = Reflect.get(value, "projects");
114
+ if (!Array.isArray(projects)) return undefined;
115
+ const roots = new Set<string>();
116
+ for (const project of projects) {
117
+ if (project === null || typeof project !== "object" || Array.isArray(project)) return undefined;
118
+ const root = Reflect.get(project, "root_path");
119
+ if (typeof root !== "string" || root.length === 0) return undefined;
120
+ roots.add(root);
121
+ }
122
+ return roots;
123
+ }
124
+
125
+ function timerEnabled(result: ReadOnlyCommandResult): "enabled" | "disabled" | "unknown" {
126
+ if (result.kind !== "completed") return "unknown";
127
+ switch (result.stdout.trim()) {
128
+ case "enabled":
129
+ case "enabled-runtime":
130
+ return "enabled";
131
+ case "disabled":
132
+ case "masked":
133
+ case "masked-runtime":
134
+ case "not-found":
135
+ case "static":
136
+ return "disabled";
137
+ default:
138
+ return "unknown";
139
+ }
140
+ }
141
+
142
+ function timerActive(result: ReadOnlyCommandResult): "active" | "inactive" | "unknown" {
143
+ if (result.kind !== "completed") return "unknown";
144
+ switch (result.stdout.trim()) {
145
+ case "active":
146
+ return "active";
147
+ case "inactive":
148
+ case "failed":
149
+ case "activating":
150
+ case "deactivating":
151
+ return "inactive";
152
+ default:
153
+ return "unknown";
154
+ }
155
+ }
156
+
157
+ interface RefreshResult {
158
+ health: Extract<CodeGraphHealth, { configured: true }>["refresh"];
159
+ reason?: string;
160
+ uncertain: boolean;
161
+ }
162
+
163
+ function refreshResult(result: ReadOnlyCommandResult, now: number): RefreshResult {
164
+ const unknown = (reason: string, uncertain: boolean): RefreshResult => ({
165
+ health: { result: "unknown", fresh: null },
166
+ reason,
167
+ uncertain,
168
+ });
169
+ if (result.kind === "timeout") return unknown("refresh service probe timed out", true);
170
+ if (result.kind === "unavailable") return unknown("refresh service state unavailable", true);
171
+ if (result.exitCode !== 0) return unknown("refresh service state unavailable", true);
172
+
173
+ const fields = new Map<string, string>();
174
+ for (const line of result.stdout.trim().split("\n")) {
175
+ const equals = line.indexOf("=");
176
+ if (equals < 1) return unknown("refresh service returned an unrecognized result", true);
177
+ fields.set(line.slice(0, equals), line.slice(equals + 1));
178
+ }
179
+ const serviceResult = fields.get("Result");
180
+ const exitStatus = fields.get("ExecMainStatus");
181
+ const completedAt = fields.get("ExecMainExitTimestamp");
182
+ if (serviceResult === undefined || exitStatus === undefined || completedAt === undefined) {
183
+ return unknown("refresh service returned an unrecognized result", true);
184
+ }
185
+ if (serviceResult !== "success" || exitStatus !== "0") {
186
+ return {
187
+ health: { result: "failed", fresh: false },
188
+ reason: `refresh service failed (${serviceResult || "no result"}, exit ${exitStatus || "unknown"})`,
189
+ uncertain: false,
190
+ };
191
+ }
192
+ if (completedAt === "" || completedAt === "n/a") {
193
+ return unknown("no successful refresh has been recorded", false);
194
+ }
195
+ const completedMs = Date.parse(completedAt);
196
+ const ageMs = now - completedMs;
197
+ if (!Number.isFinite(completedMs) || ageMs < 0) {
198
+ return unknown("refresh service returned an invalid completion time", true);
199
+ }
200
+ const lastSuccessAt = new Date(completedMs).toISOString();
201
+ if (ageMs > GRAPH_FRESHNESS_MS) {
202
+ return {
203
+ health: { result: "success", fresh: false, lastSuccessAt, ageMs },
204
+ reason: `last successful refresh is stale (${Math.round(ageMs / 60_000)}m old)`,
205
+ uncertain: false,
206
+ };
207
+ }
208
+ return {
209
+ health: { result: "success", fresh: true, lastSuccessAt, ageMs },
210
+ uncertain: false,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Read-only, bounded evidence for the optional code graph. Every child command
216
+ * is a query; this module never clones, fetches, indexes, or changes systemd.
217
+ */
218
+ export async function probeCodeGraph(
219
+ project: ProjectConfig,
220
+ deps: CodeGraphProbeDeps = DEFAULT_DEPS,
221
+ ): Promise<CodeGraphHealth> {
222
+ const configured = graphRepos(project);
223
+ if (configured.length === 0) return { configured: false };
224
+
225
+ const checkedAt = new Date(deps.now()).toISOString();
226
+ const prereqs = deps.prereqs();
227
+ const reasons: string[] = [];
228
+ let uncertain = false;
229
+
230
+ const indexer: CheckState = prereqs.indexer === null ? "missing" : "present";
231
+ const mcpMount: CheckState = prereqs.mounted ? "present" : "missing";
232
+ if (indexer === "missing") reasons.push("indexer is not present on PATH");
233
+ if (mcpMount === "missing") reasons.push("worker MCP configuration does not mount the indexer");
234
+
235
+ const [projectsResult, enabledResult, activeResult, serviceResult] = await Promise.all([
236
+ prereqs.indexer === null
237
+ ? Promise.resolve<ReadOnlyCommandResult>({ kind: "unavailable" })
238
+ : deps.run(prereqs.indexer, ["cli", "list_projects", "{}"]),
239
+ deps.run("systemctl", ["is-enabled", `${REINDEX_UNIT}.timer`]),
240
+ deps.run("systemctl", ["is-active", `${REINDEX_UNIT}.timer`]),
241
+ deps.run("systemctl", [
242
+ "show",
243
+ `${REINDEX_UNIT}.service`,
244
+ "--property=Result",
245
+ "--property=ExecMainStatus",
246
+ "--property=ExecMainExitTimestamp",
247
+ ]),
248
+ ]);
249
+
250
+ let roots: Set<string> | undefined;
251
+ if (prereqs.indexer !== null) {
252
+ if (projectsResult.kind === "completed" && projectsResult.exitCode === 0) {
253
+ roots = indexedRoots(projectsResult.stdout);
254
+ if (roots === undefined) {
255
+ uncertain = true;
256
+ reasons.push("indexer returned malformed project data");
257
+ }
258
+ } else {
259
+ uncertain = true;
260
+ reasons.push(projectsResult.kind === "timeout" ? "indexer project probe timed out" : "indexer project state unavailable");
261
+ }
262
+ }
263
+
264
+ const repos: CodeGraphRepoHealth[] = configured.map((repo) => {
265
+ const clone: CheckState = deps.exists(repo.graphProject) ? "present" : "missing";
266
+ const index: CheckState = roots === undefined ? "unknown" : roots.has(repo.graphProject) ? "present" : "missing";
267
+ if (clone === "missing") reasons.push(`${repo.name}: configured clone is missing`);
268
+ if (index === "missing") reasons.push(`${repo.name}: no indexed project exactly matches ${repo.graphProject}`);
269
+ return { name: repo.name, path: repo.graphProject, clone, index };
270
+ });
271
+
272
+ const enabled = timerEnabled(enabledResult);
273
+ const active = timerActive(activeResult);
274
+ if (enabled === "unknown" || active === "unknown") {
275
+ uncertain = true;
276
+ reasons.push("refresh timer state is unavailable or unrecognized");
277
+ } else {
278
+ if (enabled === "disabled") reasons.push("refresh timer is disabled");
279
+ if (active === "inactive") reasons.push("refresh timer is inactive");
280
+ }
281
+
282
+ const refresh = refreshResult(serviceResult, deps.now());
283
+ if (refresh.reason !== undefined) reasons.push(refresh.reason);
284
+ uncertain ||= refresh.uncertain;
285
+
286
+ return {
287
+ configured: true,
288
+ status: uncertain ? "unknown" : reasons.length === 0 ? "healthy" : "degraded",
289
+ checkedAt,
290
+ prerequisites: { indexer, mcpMount },
291
+ repos,
292
+ timer: { enabled, active },
293
+ refresh: refresh.health,
294
+ reasons,
295
+ };
296
+ }
package/src/graph.ts CHANGED
@@ -11,12 +11,12 @@
11
11
  *
12
12
  * Two hard boundaries hold everything here together:
13
13
  *
14
- * - **This package never runs an indexer, and never depends on one.** Nothing
15
- * below spawns the graph server, imports it, or checks for it; the daemon's
16
- * dispatch, caps and escalation paths do not mention it. `graph-setup` prints
17
- * commands, and with `--write` writes two systemd units — it does not even run
18
- * `systemctl`, because the wizard and the CLI are not root and a package that
19
- * silently writes root-level state is not one you can trust with a fleet.
14
+ * - **This package never builds or mutates an index, and never depends on the
15
+ * indexer for dispatch.** The optional health surface runs the indexer's
16
+ * read-only `list_projects` query; nothing spawns the graph server or imports
17
+ * it. `graph-setup` prints commands, and with `--write` writes two systemd
18
+ * units it does not enable them, because a package that silently writes
19
+ * root-level state is not one you can trust with a fleet.
20
20
  * - **A worker never queries its own worktree.** An index is keyed by the
21
21
  * realpath of the directory it was built from, with no git-worktree awareness,
22
22
  * so a run's `worktrees/<issue>` path is always an empty project. Workers are
package/src/omp.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * the module, which is the whole reason this shim exists.
15
15
  */
16
16
  import { worktreeConfinement } from "./confinement.ts";
17
+ import { releasePolicyTripwire, type ReleaseShape } from "./release-policy.ts";
18
+ import type { ReleasePolicy } from "./types.ts";
17
19
 
18
20
  const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
19
21
 
@@ -122,6 +124,10 @@ export async function createSession(opts: {
122
124
  * to read the state directory and briefs.
123
125
  */
124
126
  confineToCwd?: boolean;
127
+ /** Install the release/deploy tool-call gate for this session. */
128
+ releasePolicy?: ReleasePolicy;
129
+ /** Durable audit callback invoked only when that gate rejects a call. */
130
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
125
131
  }): Promise<AgentSessionLike> {
126
132
  let loaded: unknown;
127
133
  try {
@@ -158,6 +164,12 @@ export async function createSession(opts: {
158
164
  // `session.sessionFile` undefined, and once the worktree is gone the
159
165
  // transcript is the only record of what the worker actually did.
160
166
  const sessionManager = await openSessionManager(mod, opts);
167
+ const extensions = [
168
+ ...(opts.confineToCwd ? [worktreeConfinement(opts.cwd)] : []),
169
+ ...(opts.releasePolicy === undefined
170
+ ? []
171
+ : [releasePolicyTripwire(opts.releasePolicy, opts.onReleaseBlocked)]),
172
+ ];
161
173
 
162
174
  const created = await mod.createAgentSession({
163
175
  cwd: opts.cwd,
@@ -175,10 +187,9 @@ export async function createSession(opts: {
175
187
  // agentDir (~/.omp/agent/mcp.json) — without this, workers grep-only and
176
188
  // burn the turns cap on discovery (#29).
177
189
  enableMCP: true,
178
- // Mechanical worktree gate (#24): structured write/edit/read/grep/glob
179
- // whose path resolves outside cwd are blocked before execution. Inline
180
- // extension the harness has no separate fs-policy field.
181
- ...(opts.confineToCwd ? { extensions: [worktreeConfinement(opts.cwd)] } : {}),
190
+ // Mechanical tool gates: confinement prevents structured worktree I/O from
191
+ // escaping cwd; release policy blocks release/deploy calls when configured.
192
+ ...(extensions.length === 0 ? {} : { extensions }),
182
193
  });
183
194
  const raw = asRawSession(created);
184
195
  // Surfaced rather than swallowed: this is how a quiet downgrade to a weaker