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/README.md +352 -182
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +138 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +171 -3
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +537 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
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 {
|
|
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
|
-
?
|
|
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
|
|
1011
|
+
` new worker turns ${s.caps.workerMaxTurns}`,
|
|
960
1012
|
` worker wall clock ${Math.round(s.caps.workerWallClockMs / 60_000)}m`,
|
|
961
|
-
` attempts
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* commands, and with `--write` writes two systemd
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|