omp-conductor 0.3.4 → 0.3.6
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 +204 -6
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +98 -5
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/worker.md +16 -5
- package/src/cli.ts +60 -0
- package/src/config.ts +40 -4
- package/src/daemon.ts +15 -1
- package/src/graph.ts +508 -0
- package/src/orchestrator-tick.ts +433 -5
- package/src/plugin.ts +329 -90
- package/src/setup.ts +315 -2
- package/src/types.ts +25 -0
- package/src/worktree.ts +81 -2
package/src/config.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
17
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
18
18
|
import {
|
|
19
19
|
AUTHORITY_HOLDERS,
|
|
20
20
|
CONFIG_VERSION,
|
|
@@ -432,17 +432,47 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
432
432
|
problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
|
|
433
433
|
continue;
|
|
434
434
|
}
|
|
435
|
-
|
|
435
|
+
const target: RepoTarget = {
|
|
436
436
|
name: pickString(value?.["name"], key),
|
|
437
437
|
cloneUrl,
|
|
438
438
|
defaultBranch: pickString(value?.["defaultBranch"], "main"),
|
|
439
439
|
gates: normalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
|
|
440
440
|
};
|
|
441
|
+
const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
|
|
442
|
+
if (graph !== undefined) target.graphProject = graph;
|
|
443
|
+
repos[key] = target;
|
|
441
444
|
}
|
|
442
445
|
|
|
443
446
|
return repos;
|
|
444
447
|
}
|
|
445
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The path of the index-only clone whose code graph this repo's workers query,
|
|
451
|
+
* or `undefined` when the repo has none.
|
|
452
|
+
*
|
|
453
|
+
* A relative path is rejected rather than resolved, and that rejection is the
|
|
454
|
+
* whole reason this is validated here: the value is written in one process and
|
|
455
|
+
* *used* in another, by a session whose cwd is its own throwaway worktree. So
|
|
456
|
+
* `../graph/api` would name a different directory for every reader, and none of
|
|
457
|
+
* them the one that was indexed. There is no cwd this file could honestly
|
|
458
|
+
* resolve it against, so it says so rather than guessing.
|
|
459
|
+
*/
|
|
460
|
+
function normalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
461
|
+
if (parsed === undefined) return undefined;
|
|
462
|
+
if (!nonEmptyString(parsed)) {
|
|
463
|
+
problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const path = expandHome(parsed.trim());
|
|
468
|
+
if (isAbsolute(path)) return path;
|
|
469
|
+
problems.push(
|
|
470
|
+
`${label}.graphProject must be an absolute path — it is read by sessions whose cwd is their own ` +
|
|
471
|
+
`worktree — found ${JSON.stringify(parsed)}`,
|
|
472
|
+
);
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
446
476
|
/**
|
|
447
477
|
* Gates are the pre-push CI equivalent, so a malformed entry is an error, not
|
|
448
478
|
* something to drop quietly: a skipped gate is exactly how a lint failure
|
|
@@ -550,8 +580,14 @@ function pickLiteral<T extends string>(
|
|
|
550
580
|
return hit;
|
|
551
581
|
}
|
|
552
582
|
|
|
553
|
-
/**
|
|
554
|
-
|
|
583
|
+
/**
|
|
584
|
+
* `~/x` in a hand-written config must not create a literal `~` directory.
|
|
585
|
+
*
|
|
586
|
+
* Exported because the wizard and `graph-setup` derive paths the operator may
|
|
587
|
+
* have typed with a `~` in them, and one spelling of this rule in the package
|
|
588
|
+
* is the only way a path shown in a plan matches the path a validator accepts.
|
|
589
|
+
*/
|
|
590
|
+
export function expandHome(p: string): string {
|
|
555
591
|
if (p === "~") return homedir();
|
|
556
592
|
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
557
593
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
|
|
12
12
|
import { dirname, join, relative } from "node:path";
|
|
13
13
|
import { configPath, findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
14
14
|
import { createEscalator } from "./escalate.ts";
|
|
15
|
+
import { graphHint } from "./graph.ts";
|
|
15
16
|
import { livingDaemon } from "./lifecycle.ts";
|
|
16
17
|
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
17
18
|
import { startOrchestrator } from "./orchestrator.ts";
|
|
@@ -429,7 +430,16 @@ function endedBy(killedBy: KilledBy | undefined): string {
|
|
|
429
430
|
return "a failed run";
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
|
|
433
|
+
/**
|
|
434
|
+
* The worker's opening prompt.
|
|
435
|
+
*
|
|
436
|
+
* Exported for the same reason `salvageLines` is: this text is the entire
|
|
437
|
+
* context a session with the host's credentials gets, so the two things a test
|
|
438
|
+
* can hold it to are worth holding — that a configured graph reaches the worker,
|
|
439
|
+
* and that a project without one gets the brief this package has always shipped,
|
|
440
|
+
* to the byte.
|
|
441
|
+
*/
|
|
442
|
+
export async function buildBrief(
|
|
433
443
|
project: ProjectConfig,
|
|
434
444
|
r: Routed,
|
|
435
445
|
branch: string,
|
|
@@ -447,6 +457,10 @@ async function buildBrief(
|
|
|
447
457
|
WORKTREE: worktree,
|
|
448
458
|
ACCEPTANCE_CRITERIA: acceptanceCriteria(r.issue),
|
|
449
459
|
GATES: gatesBlock(r.repo),
|
|
460
|
+
// Empty for a repo with no `graphProject`, and empty means *nothing*: the
|
|
461
|
+
// placeholder sits flush against the next list item in the template, so an
|
|
462
|
+
// unconfigured render leaves no blank line where a hint would have gone.
|
|
463
|
+
GRAPH_HINT: graphHint(r.repo),
|
|
450
464
|
});
|
|
451
465
|
}
|
|
452
466
|
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-graph discovery: the indexes workers query instead of grepping, and the
|
|
3
|
+
* commands that create and refresh them.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists at all, measured on the dogfood fleet rather than assumed:
|
|
6
|
+
* workers spend roughly four fifths of a 120-turn budget *finding* code — 30–62
|
|
7
|
+
* `read` calls and 32–69 `bash` calls against 9–24 edits per run, 215–390k
|
|
8
|
+
* characters of tool output. A code graph answers "who calls this" and "where is
|
|
9
|
+
* this defined" in one call, which is the difference between a run that lands
|
|
10
|
+
* and a run that dies mid-refactor with the work unfinished.
|
|
11
|
+
*
|
|
12
|
+
* Two hard boundaries hold everything here together:
|
|
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.
|
|
20
|
+
* - **A worker never queries its own worktree.** An index is keyed by the
|
|
21
|
+
* realpath of the directory it was built from, with no git-worktree awareness,
|
|
22
|
+
* so a run's `worktrees/<issue>` path is always an empty project. Workers are
|
|
23
|
+
* pointed at {@link RepoTarget.graphProject} — a conductor-owned clone nothing
|
|
24
|
+
* human edits — and {@link graphHint} is the text that makes that unmissable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { homedir, userInfo } from "node:os";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
30
|
+
import { expandHome, stateDir } from "./config.ts";
|
|
31
|
+
import type { ProjectConfig, RepoTarget } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The indexer's own CLI, invoked by name rather than by path so the generated
|
|
35
|
+
* unit's explicit `PATH` is the single place a host's install location is
|
|
36
|
+
* spelled out. `cli <tool> <json>` runs one tool without an MCP session.
|
|
37
|
+
*/
|
|
38
|
+
const INDEXER = "codebase-memory-mcp";
|
|
39
|
+
|
|
40
|
+
/** Both units and the script share this stem; `cbm` is the indexer's own prefix. */
|
|
41
|
+
export const REINDEX_UNIT = "cbm-reindex";
|
|
42
|
+
|
|
43
|
+
/** Where a system timer has to live to be enabled by `systemctl`. */
|
|
44
|
+
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
45
|
+
|
|
46
|
+
/** Where the upstream indexer lives, for an operator who has to go install it. */
|
|
47
|
+
const INDEXER_SOURCE = "https://github.com/DeusData/codebase-memory-mcp";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The two things that must be true of the *host* before any index is useful,
|
|
51
|
+
* neither of which conductor installs: the indexer has to be on PATH, and the
|
|
52
|
+
* agent has to mount it as an MCP server or worker sessions get no graph tools
|
|
53
|
+
* at all. Indexing without the mount produces a perfectly good database that
|
|
54
|
+
* nothing can read — the failure this preflight exists to make visible.
|
|
55
|
+
*/
|
|
56
|
+
export interface GraphPrereqs {
|
|
57
|
+
/** Resolved indexer path, or null when nothing on PATH answers to the name. */
|
|
58
|
+
indexer: string | null;
|
|
59
|
+
/** The agent's MCP server config, whether or not it exists yet. */
|
|
60
|
+
mcpConfig: string;
|
|
61
|
+
/** Whether that config already mounts the indexer for sessions. */
|
|
62
|
+
mounted: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reads the host. Split from {@link formatGraphSetup} so the plan stays pure. */
|
|
66
|
+
export function resolvePrereqs(home: string = homedir()): GraphPrereqs {
|
|
67
|
+
const onPath = (process.env["PATH"] ?? "")
|
|
68
|
+
.split(":")
|
|
69
|
+
.filter((d) => d !== "")
|
|
70
|
+
.map((d) => join(d, INDEXER))
|
|
71
|
+
.find((c) => existsSync(c));
|
|
72
|
+
|
|
73
|
+
const mcpConfig = join(home, ".omp", "agent", "mcp.json");
|
|
74
|
+
let mounted = false;
|
|
75
|
+
try {
|
|
76
|
+
// Any mention of the binary counts as mounted. Parsing the whole schema to
|
|
77
|
+
// decide would make this preflight fail on configs it does not understand,
|
|
78
|
+
// and a false "not mounted" costs an operator a confusing duplicate entry.
|
|
79
|
+
const raw = JSON.parse(readFileSync(mcpConfig, "utf8")) as Record<string, unknown>;
|
|
80
|
+
const servers = (raw["mcpServers"] ?? raw) as Record<string, unknown>;
|
|
81
|
+
mounted = Object.keys(servers).some((k) => k.includes(INDEXER));
|
|
82
|
+
} catch {
|
|
83
|
+
// No file, or unreadable: not mounted, and the plan says how to add it.
|
|
84
|
+
}
|
|
85
|
+
return { indexer: onPath ?? null, mcpConfig, mounted };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The `mcp.json` entry a fresh host needs, using the resolved path when known. */
|
|
89
|
+
export function mcpEntry(prereqs: GraphPrereqs): string {
|
|
90
|
+
const command = prereqs.indexer ?? `/usr/local/bin/${INDEXER}`;
|
|
91
|
+
return JSON.stringify({ [INDEXER]: { type: "stdio", command } }, null, 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Default parent of every index-only clone, under the cache directory because
|
|
96
|
+
* that is exactly what these are: derived data, disposable, re-creatable from a
|
|
97
|
+
* clone URL. Deliberately *not* `~/projects/<org>` — that is where a human's own
|
|
98
|
+
* checkouts live, and pointing a reindexer at one either destroys their
|
|
99
|
+
* uncommitted work or indexes whatever branch they left checked out.
|
|
100
|
+
*
|
|
101
|
+
* `trackerRepo` supplies the org so a fleet's clones land together, which is
|
|
102
|
+
* also the answer for the common case where the tracker and the code share one
|
|
103
|
+
* GitHub organisation.
|
|
104
|
+
*/
|
|
105
|
+
export function defaultGraphRoot(trackerRepo: string): string {
|
|
106
|
+
const org = trackerRepo.split("/")[0] ?? trackerRepo;
|
|
107
|
+
return join(homedir(), ".cache", "conductor-graph", org);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One repo's clone under a chosen root. `~` is expanded here so a path an
|
|
111
|
+
* operator typed matches the absolute path the validator accepts. */
|
|
112
|
+
export function graphProjectPath(root: string, repoName: string): string {
|
|
113
|
+
return join(expandHome(root.trim()), repoName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A repo that has a graph, narrowed so callers need no further guard. */
|
|
117
|
+
export type GraphRepo = RepoTarget & { graphProject: string };
|
|
118
|
+
|
|
119
|
+
/** The project's repos that have a graph configured, in config order. */
|
|
120
|
+
export function graphRepos(p: ProjectConfig): GraphRepo[] {
|
|
121
|
+
return Object.values(p.routing.repos).filter((r): r is GraphRepo => r.graphProject !== undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The paragraph a worker's brief carries about its repo's graph, or `""` when
|
|
126
|
+
* the repo has none — in which case the rendered brief is byte-for-byte the one
|
|
127
|
+
* this package shipped before graphs existed.
|
|
128
|
+
*
|
|
129
|
+
* The leading newline and the three-space indent are load-bearing: the
|
|
130
|
+
* placeholder sits immediately before the next numbered item in
|
|
131
|
+
* `briefs/worker.md`, so an empty value leaves no blank line behind and a
|
|
132
|
+
* non-empty one reads as a continuation of the item above it.
|
|
133
|
+
*
|
|
134
|
+
* Every sentence here is defending against one specific failure. A worker that
|
|
135
|
+
* passes its own cwd gets an empty answer and concludes there is no graph. A
|
|
136
|
+
* worker that trusts the graph as current edits against a snapshot that predates
|
|
137
|
+
* its own branch. Both end the same way — a confident diff in the wrong place —
|
|
138
|
+
* so the wording says the quiet part out loud rather than describing the tool.
|
|
139
|
+
*/
|
|
140
|
+
export function graphHint(repo: RepoTarget): string {
|
|
141
|
+
const path = repo.graphProject;
|
|
142
|
+
if (path === undefined) return "";
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
"\n" +
|
|
146
|
+
" **This repo has a code graph, and it was not built from your worktree.**\n" +
|
|
147
|
+
" Call `list_projects` first, find the single entry whose `root_path` is\n" +
|
|
148
|
+
" exactly\n" +
|
|
149
|
+
` \`${path}\`\n` +
|
|
150
|
+
" and pass that entry's `name` as the `project` argument to every graph\n" +
|
|
151
|
+
" tool. Never pass a path, and never pass your own cwd: that clone is what\n" +
|
|
152
|
+
" was indexed, your worktree has no index and never will, so a cwd-based\n" +
|
|
153
|
+
" lookup answers nothing and you lose the run to grep.\n" +
|
|
154
|
+
"\n" +
|
|
155
|
+
" Read what it tells you as a snapshot of that clone's default branch at\n" +
|
|
156
|
+
" the last reindex: it does not contain your edits, and it can be hours\n" +
|
|
157
|
+
" behind the branch you are on. So orient with the graph, then read the\n" +
|
|
158
|
+
" real file in your worktree before you change it. If those tools are not\n" +
|
|
159
|
+
" mounted in this session, say so in your report and fall back to grep.\n"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Where the generated refresh script lands: conductor state, not a unit
|
|
164
|
+
* directory, because it is ours to regenerate and needs no root to write. */
|
|
165
|
+
export function reindexScriptPath(): string {
|
|
166
|
+
return join(stateDir(), `${REINDEX_UNIT}.sh`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Both unit files, from the one stem `systemctl enable` will be given. */
|
|
170
|
+
export function unitPaths(unitDir = SYSTEMD_UNIT_DIR): { service: string; timer: string } {
|
|
171
|
+
return {
|
|
172
|
+
service: join(unitDir, `${REINDEX_UNIT}.service`),
|
|
173
|
+
timer: join(unitDir, `${REINDEX_UNIT}.timer`),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `git clone` for one repo's index-only clone.
|
|
179
|
+
*
|
|
180
|
+
* `--single-branch` so the working tree can only ever hold the branch the graph
|
|
181
|
+
* claims to describe, and the destination is quoted because the root is
|
|
182
|
+
* operator-typed and a space in it would otherwise clone into two directories.
|
|
183
|
+
*/
|
|
184
|
+
export function cloneCommand(r: GraphRepo): string {
|
|
185
|
+
return `git clone --single-branch --branch ${r.defaultBranch} ${r.cloneUrl} "${r.graphProject}"`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The one-shot index command, as a human would run it to seed a clone. */
|
|
189
|
+
export function indexCommand(r: GraphRepo): string {
|
|
190
|
+
return `${INDEXER} cli index_repository '{"repo_path": "${r.graphProject}"}'`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The refresh-and-reindex script both the timer and a human run.
|
|
195
|
+
*
|
|
196
|
+
* It fails loudly on purpose, and that is the one thing about it worth
|
|
197
|
+
* protecting. A first draft of this — hand-written on the live host — used
|
|
198
|
+
* `git fetch … || true; git pull --ff-only || true`, which meant a fetch that
|
|
199
|
+
* failed for a week still indexed the stale tree and still exited 0: a green
|
|
200
|
+
* timer serving a month-old graph, and workers orienting against code that no
|
|
201
|
+
* longer exists. So: `set -euo pipefail`, no swallowed failures anywhere, and
|
|
202
|
+
* the first broken repo takes the whole run non-zero where `systemctl status`
|
|
203
|
+
* and `systemctl is-failed` will report it.
|
|
204
|
+
*
|
|
205
|
+
* `git reset --hard origin/<defaultBranch>` rather than a merge or a pull is
|
|
206
|
+
* safe *because* of what these clones are — conductor's own, never edited by a
|
|
207
|
+
* human — and it is the only refresh with no failure mode of its own: no
|
|
208
|
+
* conflict, no divergence, no detached state to recover from. Each repo's own
|
|
209
|
+
* configured branch is used, because a fleet with a `master` repo in it would
|
|
210
|
+
* otherwise silently index nothing.
|
|
211
|
+
*/
|
|
212
|
+
export function reindexScript(p: ProjectConfig): string {
|
|
213
|
+
const lines = [
|
|
214
|
+
"#!/usr/bin/env bash",
|
|
215
|
+
`# Refresh and reindex the code graphs for omp-conductor project "${p.name}".`,
|
|
216
|
+
"#",
|
|
217
|
+
"# Generated by \`omp-conductor graph-setup\`. Regenerate it rather than editing:",
|
|
218
|
+
"# the repo list, branches and paths all come from that project's config.json.",
|
|
219
|
+
"#",
|
|
220
|
+
"# Every clone below is conductor's own, index-only and never edited by a human,",
|
|
221
|
+
"# which is what makes the hard reset safe. Do not point one at a checkout you",
|
|
222
|
+
"# work in: the reset would destroy uncommitted work.",
|
|
223
|
+
"#",
|
|
224
|
+
"# Fails loud and stops at the first problem, deliberately. A refresh that",
|
|
225
|
+
"# swallowed its errors would index a stale tree and still exit 0 — a green",
|
|
226
|
+
"# timer serving a month-old graph is worse than no graph at all.",
|
|
227
|
+
"set -euo pipefail",
|
|
228
|
+
"",
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
for (const r of graphRepos(p)) {
|
|
232
|
+
lines.push(
|
|
233
|
+
`# ${r.name} — ${r.cloneUrl} @ ${r.defaultBranch}`,
|
|
234
|
+
`cd "${r.graphProject}"`,
|
|
235
|
+
"git fetch --prune origin",
|
|
236
|
+
`git reset --hard origin/${r.defaultBranch}`,
|
|
237
|
+
indexCommand(r),
|
|
238
|
+
"",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The service half. `Type=oneshot` with no `[Install]` section: it is started by
|
|
247
|
+
* its timer, and a service enabled on its own would run once at boot and never
|
|
248
|
+
* again, which looks exactly like a working install.
|
|
249
|
+
*/
|
|
250
|
+
export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath()): string {
|
|
251
|
+
const home = homedir();
|
|
252
|
+
const user = userInfo().username;
|
|
253
|
+
return [
|
|
254
|
+
"[Unit]",
|
|
255
|
+
`Description=Reindex the code graphs omp-conductor project "${p.name}" hands its workers`,
|
|
256
|
+
"After=network-online.target",
|
|
257
|
+
"Wants=network-online.target",
|
|
258
|
+
"",
|
|
259
|
+
"[Service]",
|
|
260
|
+
"Type=oneshot",
|
|
261
|
+
`ExecStart=/bin/bash ${scriptPath}`,
|
|
262
|
+
"# Pinned to the account that generated this, and it has to be the account the",
|
|
263
|
+
"# fleet runs as. A systemd service defaults to root, and root is wrong three",
|
|
264
|
+
"# ways at once here: the indexer would write its store under /root/.cache",
|
|
265
|
+
"# where no worker session ever looks, a private clone would fetch with root's",
|
|
266
|
+
"# SSH credentials rather than the fleet's, and every path below points into a",
|
|
267
|
+
"# different account's home. All three fail silently — the timer goes green",
|
|
268
|
+
"# and the graph a worker queries is simply never the graph this built.",
|
|
269
|
+
`User=${user}`,
|
|
270
|
+
"# Both of these are spelled out because systemd supplies neither usefully.",
|
|
271
|
+
`# The indexer resolves its store from HOME (${join(home, ".cache", "codebase-memory-mcp")}),`,
|
|
272
|
+
"# so an unset HOME would build a second index nobody queries; and systemd's",
|
|
273
|
+
"# default PATH has no ~/.local/bin, while the indexer itself shells out to git.",
|
|
274
|
+
`Environment=HOME=${home}`,
|
|
275
|
+
`Environment=PATH=${["/.local/bin", "/.bun/bin"].map((d) => join(home, d)).join(":")}` +
|
|
276
|
+
":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
277
|
+
"# Indexing is CPU- and IO-heavy, and this host is also running the fleet whose",
|
|
278
|
+
"# workers read the result.",
|
|
279
|
+
"Nice=10",
|
|
280
|
+
"IOSchedulingClass=idle",
|
|
281
|
+
"# A wedged index must fail rather than hold its timer open indefinitely.",
|
|
282
|
+
"TimeoutStartSec=1h",
|
|
283
|
+
"",
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The timer half — and the reason there is a timer at all.
|
|
289
|
+
*
|
|
290
|
+
* The graph server's own auto-watch lives inside a connected MCP session and
|
|
291
|
+
* dies with it, and v0.9.0 ships no daemon. An ephemeral worker session
|
|
292
|
+
* therefore keeps nothing fresh: whatever it mounts, it un-mounts minutes later.
|
|
293
|
+
* So the refresh has to come from outside the fleet entirely, on a schedule
|
|
294
|
+
* nothing in a run can influence.
|
|
295
|
+
*/
|
|
296
|
+
export function reindexTimer(p: ProjectConfig): string {
|
|
297
|
+
return [
|
|
298
|
+
"[Unit]",
|
|
299
|
+
`Description=Periodic code-graph reindex for omp-conductor project "${p.name}"`,
|
|
300
|
+
"",
|
|
301
|
+
"[Timer]",
|
|
302
|
+
"# Twenty minutes, because the measured cost is small and the cost of",
|
|
303
|
+
"# staleness is not: refreshing four repos takes ~40s of CPU, which at this",
|
|
304
|
+
"# interval is roughly 3% of one core, and the service runs at Nice=10 with",
|
|
305
|
+
"# idle IO so it yields to the fleet. A nightly refresh would be cheaper and",
|
|
306
|
+
"# much worse — a fleet merging several PRs a day would spend most of its",
|
|
307
|
+
"# dispatches querying a graph that predates the code the worker was sent to",
|
|
308
|
+
"# change, which is the one way this feature actively misleads. Lengthen it",
|
|
309
|
+
"# for quiet repos; the brief has workers verify against the real file",
|
|
310
|
+
"# regardless, so staleness degrades the graph rather than making it lie.",
|
|
311
|
+
"OnBootSec=3min",
|
|
312
|
+
"OnUnitActiveSec=20min",
|
|
313
|
+
"# Persistent catches a host that was down; the jitter keeps every install",
|
|
314
|
+
"# of this unit off the same second.",
|
|
315
|
+
"Persistent=true",
|
|
316
|
+
"RandomizedDelaySec=2m",
|
|
317
|
+
"AccuracySec=1min",
|
|
318
|
+
`Unit=${REINDEX_UNIT}.service`,
|
|
319
|
+
"",
|
|
320
|
+
"[Install]",
|
|
321
|
+
"WantedBy=timers.target",
|
|
322
|
+
"",
|
|
323
|
+
].join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The privileged tail, and the only part of this feature that needs root.
|
|
328
|
+
*
|
|
329
|
+
* Split out deliberately. Running the whole CLI under `sudo` looks convenient
|
|
330
|
+
* and is wrong: `loadConfig`, `stateDir`, `homedir` and `userInfo` would all
|
|
331
|
+
* resolve as root, so the config would be missed or the wrong one, the script
|
|
332
|
+
* would land in root's state directory, and the generated unit would bake
|
|
333
|
+
* root's HOME with no `User=` — indexes written where no worker reads them.
|
|
334
|
+
* So generation runs unprivileged as the fleet user, and only the copy into
|
|
335
|
+
* the unit directory is elevated.
|
|
336
|
+
*/
|
|
337
|
+
export function installCommands(unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
|
|
338
|
+
const { service, timer } = unitPaths(from);
|
|
339
|
+
return [
|
|
340
|
+
`sudo install -m 0644 ${service} ${timer} ${unitDir}/`,
|
|
341
|
+
`sudo systemctl daemon-reload && sudo systemctl enable --now ${REINDEX_UNIT}.timer`,
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function block(title: string, body: string): string[] {
|
|
346
|
+
return [`--- ${title} ---`, "", body.trimEnd(), ""];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The whole plan as text, with nothing done. This is the default mode of
|
|
351
|
+
* `graph-setup`, and it is a plan an operator can read, paste, or ignore —
|
|
352
|
+
* including on a host where they are not root and `--write` would fail.
|
|
353
|
+
*/
|
|
354
|
+
export function formatGraphSetup(
|
|
355
|
+
p: ProjectConfig,
|
|
356
|
+
unitDir = SYSTEMD_UNIT_DIR,
|
|
357
|
+
prereqs: GraphPrereqs = resolvePrereqs(),
|
|
358
|
+
): string {
|
|
359
|
+
const repos = graphRepos(p);
|
|
360
|
+
const missing = repos.filter((r) => !existsSync(r.graphProject));
|
|
361
|
+
// Seeded with 0 so these are still widths when the caller ignored the exit-1
|
|
362
|
+
// guard and asked for a plan for a project with no graph at all.
|
|
363
|
+
const nameWidth = Math.max(0, ...repos.map((r) => r.name.length));
|
|
364
|
+
const pathWidth = Math.max(0, ...repos.map((r) => r.graphProject.length));
|
|
365
|
+
|
|
366
|
+
const lines = [
|
|
367
|
+
`code-graph discovery for project "${p.name}"`,
|
|
368
|
+
"",
|
|
369
|
+
"Workers spend most of a run finding code rather than changing it. These",
|
|
370
|
+
'indexes answer "who calls this" and "where is this defined" in one call, so',
|
|
371
|
+
"the turns go into the work instead. Nothing below has been run.",
|
|
372
|
+
"",
|
|
373
|
+
`repos with a graph configured (${repos.length}):`,
|
|
374
|
+
];
|
|
375
|
+
for (const r of repos) {
|
|
376
|
+
// Padded so the (missing) markers line up: which clones do not exist yet is
|
|
377
|
+
// the one thing an operator scans this list for.
|
|
378
|
+
const marker = missing.includes(r) ? " (missing)" : "";
|
|
379
|
+
lines.push(` ${r.name.padEnd(nameWidth)} ${r.graphProject.padEnd(marker === "" ? 0 : pathWidth)}${marker}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Before anything else, because both of these are host state conductor does
|
|
383
|
+
// not own and neither failure is self-announcing: a missing binary surfaces
|
|
384
|
+
// as command-not-found halfway down the plan, and a missing mount surfaces
|
|
385
|
+
// as workers that never mention the graph and quietly grep instead.
|
|
386
|
+
lines.push("", "0. host prerequisites", "");
|
|
387
|
+
lines.push(
|
|
388
|
+
prereqs.indexer === null
|
|
389
|
+
? ` [ ] ${INDEXER} is NOT on your PATH. Install it first — conductor never
|
|
390
|
+
does, and never depends on it: ${INDEXER_SOURCE}`
|
|
391
|
+
: ` [x] indexer: ${prereqs.indexer}`,
|
|
392
|
+
);
|
|
393
|
+
if (prereqs.mounted) {
|
|
394
|
+
lines.push(` [x] mounted for sessions in ${prereqs.mcpConfig}`);
|
|
395
|
+
} else {
|
|
396
|
+
lines.push(
|
|
397
|
+
` [ ] NOT mounted as an MCP server, so worker sessions have no graph`,
|
|
398
|
+
` tools and every index below would be unreadable. Add to`,
|
|
399
|
+
` ${prereqs.mcpConfig}:`,
|
|
400
|
+
"",
|
|
401
|
+
...mcpEntry(prereqs)
|
|
402
|
+
.split("\n")
|
|
403
|
+
.map((l) => ` ${l}`),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
lines.push("", "1. create the clones that are missing", "");
|
|
408
|
+
if (missing.length === 0) {
|
|
409
|
+
lines.push(" every clone above already exists — nothing to create.");
|
|
410
|
+
} else {
|
|
411
|
+
lines.push(
|
|
412
|
+
" These are conductor's, not yours. Nothing human edits them, which is what",
|
|
413
|
+
" makes step 3's hard reset both safe and deterministic — so never point a",
|
|
414
|
+
" graphProject at a checkout you work in.",
|
|
415
|
+
"",
|
|
416
|
+
);
|
|
417
|
+
for (const r of missing) lines.push(` ${cloneCommand(r)}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
lines.push(
|
|
421
|
+
"",
|
|
422
|
+
"2. index each one once now, so the first worker does not wait for the timer",
|
|
423
|
+
"",
|
|
424
|
+
);
|
|
425
|
+
for (const r of repos) lines.push(` ${indexCommand(r)}`);
|
|
426
|
+
lines.push(
|
|
427
|
+
"",
|
|
428
|
+
" Then check what a worker will see. Each root_path below must match a path",
|
|
429
|
+
" above exactly, and the name beside it is what a worker passes as `project`:",
|
|
430
|
+
"",
|
|
431
|
+
` ${INDEXER} cli list_projects`,
|
|
432
|
+
"",
|
|
433
|
+
"3. keep them current",
|
|
434
|
+
"",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const script = reindexScriptPath();
|
|
438
|
+
const { service, timer } = unitPaths(stateDir());
|
|
439
|
+
lines.push(
|
|
440
|
+
` \`graph-setup --write\` writes these three files for you, all under`,
|
|
441
|
+
` ${stateDir()}. Run it as the account the fleet runs as — never under`,
|
|
442
|
+
" sudo, which would resolve the config, the state directory and the unit's",
|
|
443
|
+
" own User= as root and quietly build indexes no worker can read.",
|
|
444
|
+
"",
|
|
445
|
+
...block(script, reindexScript(p)),
|
|
446
|
+
...block(service, reindexService(p, script)),
|
|
447
|
+
...block(timer, reindexTimer(p)),
|
|
448
|
+
` then install them, which is the only step that needs root:`,
|
|
449
|
+
"",
|
|
450
|
+
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
return lines.join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** What `graph-setup --write` did, and the root-only steps it deliberately left. */
|
|
457
|
+
export interface GraphSetupWrite {
|
|
458
|
+
written: string[];
|
|
459
|
+
next: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Writes the script and both units, and returns what to do next.
|
|
464
|
+
*
|
|
465
|
+
* Deliberately stops there. Running `systemctl` would need root the wizard and
|
|
466
|
+
* the CLI may not have, and a package that enables system timers behind an
|
|
467
|
+
* operator's back is one you cannot audit by reading its output.
|
|
468
|
+
*/
|
|
469
|
+
export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupWrite {
|
|
470
|
+
const script = reindexScriptPath();
|
|
471
|
+
// All three land in the state directory, which this account owns — so the
|
|
472
|
+
// whole command runs unprivileged and there is no sudo path that could
|
|
473
|
+
// resolve HOME, the config or the unit's User= as the wrong account.
|
|
474
|
+
const { service, timer } = unitPaths(stateDir());
|
|
475
|
+
|
|
476
|
+
mkdirSync(dirname(script), { recursive: true });
|
|
477
|
+
writeFileSync(script, reindexScript(p));
|
|
478
|
+
// Executable so an operator can run the refresh by hand before trusting a
|
|
479
|
+
// timer with it; the unit calls bash explicitly either way.
|
|
480
|
+
chmodSync(script, 0o755);
|
|
481
|
+
writeFileSync(service, reindexService(p, script));
|
|
482
|
+
writeFileSync(timer, reindexTimer(p));
|
|
483
|
+
|
|
484
|
+
const missing = graphRepos(p).filter((r) => !existsSync(r.graphProject));
|
|
485
|
+
const next = [
|
|
486
|
+
"nothing has been installed, enabled or started — this command needs no root",
|
|
487
|
+
`and takes none. The units are staged in ${stateDir()}.`,
|
|
488
|
+
"",
|
|
489
|
+
"to install them, which is the only privileged step:",
|
|
490
|
+
"",
|
|
491
|
+
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
492
|
+
"",
|
|
493
|
+
"then watch one real run before trusting the schedule (it takes minutes per repo):",
|
|
494
|
+
"",
|
|
495
|
+
` sudo systemctl start ${REINDEX_UNIT}.service && systemctl status ${REINDEX_UNIT}.service`,
|
|
496
|
+
...(missing.length === 0
|
|
497
|
+
? []
|
|
498
|
+
: [
|
|
499
|
+
"",
|
|
500
|
+
`first, though: ${missing.length} clone(s) do not exist yet, and the script fails`,
|
|
501
|
+
"loudly rather than skipping them —",
|
|
502
|
+
"",
|
|
503
|
+
...missing.map((r) => ` ${cloneCommand(r)}`),
|
|
504
|
+
]),
|
|
505
|
+
].join("\n");
|
|
506
|
+
|
|
507
|
+
return { written: [script, service, timer], next };
|
|
508
|
+
}
|