omp-conductor 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The one place conductor runs a command as root.
3
+ *
4
+ * Every host-provisioning step used to be *printed* as a `sudo …` line for the
5
+ * operator to retype, which is not caution — it is an audit trail nobody keeps
6
+ * and a transcription error nobody catches. This module keeps the audit and
7
+ * removes the retyping: the caller composes structured steps, this renders all
8
+ * of them with their exact argv, takes **one** confirm, and then runs them.
9
+ *
10
+ * Three invariants, each of which a convenient shortcut would break:
11
+ *
12
+ * - **The CLI never escalates itself.** Only the individual steps below are
13
+ * privileged. `sudo omp-conductor setup host` would resolve the config,
14
+ * `$OMP_CONDUCTOR_HOME`, `homedir()` and the unit's own `User=` as root and
15
+ * bake the wrong account into a unit for a fleet that runs as somebody
16
+ * else — a timer that goes green while writing state no worker can read.
17
+ * {@link assertNoSelfExec} refuses to build such a step at all.
18
+ * - **One confirm, everything named.** A confirm that authorises commands it
19
+ * did not print is not consent, so the render happens first and lists the
20
+ * exact argv — including which steps deliberately run *without* `sudo`.
21
+ * - **A failure hands the work back.** The first non-`bestEffort` failure
22
+ * stops the batch and prints the failed title, its stderr, and every
23
+ * not-yet-run command verbatim, so an operator can finish by hand from the
24
+ * terminal they are already looking at.
25
+ */
26
+
27
+ import type { WizardUi } from "./wizard-ui.ts";
28
+
29
+ export interface PrivilegedStep {
30
+ /** One line naming what this step accomplishes, shown in the plan. */
31
+ title: string;
32
+ /** The exact argv. No shell: no quoting rules, no word splitting, no `&&`. */
33
+ argv: readonly string[];
34
+ /**
35
+ * A failure here is reported and the batch continues. For steps whose failure
36
+ * is not a broken install — reloading a systemd that was never running, say.
37
+ */
38
+ bestEffort?: boolean;
39
+ /**
40
+ * Runs as the invoking account with **no** `sudo` prefix, while still
41
+ * appearing in the same plan under the same confirm.
42
+ *
43
+ * `setup graph`'s index-only clones are the case this exists for: a
44
+ * root-owned clone under the fleet user's cache is precisely the failure the
45
+ * escalation guard exists to prevent, but the operator must still see the
46
+ * clone and the timer install as one plan they approve once.
47
+ */
48
+ unprivileged?: boolean;
49
+ }
50
+
51
+ /** One spawn the runner needs. Injectable so tests never touch a real `sudo`. */
52
+ export interface PrivilegedSpawn {
53
+ argv: readonly string[];
54
+ /**
55
+ * `sudo -v` needs the terminal for its password prompt, so its stdio is
56
+ * inherited whole and nothing is captured.
57
+ */
58
+ interactive: boolean;
59
+ }
60
+
61
+ export interface PrivilegedResult {
62
+ exitCode: number;
63
+ /** Captured stderr, or `""` for an interactive spawn that owned the terminal. */
64
+ stderr: string;
65
+ }
66
+
67
+ export interface PrivilegedDeps {
68
+ spawn(request: PrivilegedSpawn): Promise<PrivilegedResult>;
69
+ /** `undefined` on a platform without uids, which is never privileged here. */
70
+ getuid(): number | undefined;
71
+ }
72
+
73
+ export type PrivilegedOutcome =
74
+ /** The confirm was answered no, or dismissed. Nothing ran. */
75
+ | { kind: "declined" }
76
+ /** Every step ran; `bestEffort` failures are listed but did not stop the batch. */
77
+ | { kind: "completed"; softFailures: { step: PrivilegedStep; stderr: string }[] }
78
+ /** A required step failed. `remaining` are the steps that never ran. */
79
+ | {
80
+ kind: "failed";
81
+ step: PrivilegedStep;
82
+ exitCode: number;
83
+ stderr: string;
84
+ remaining: readonly PrivilegedStep[];
85
+ };
86
+
87
+ export interface RunPrivilegedOptions {
88
+ deps?: PrivilegedDeps;
89
+ /** Confirm title. Defaults to a generic one; callers name their verb. */
90
+ title?: string;
91
+ /** Extra lines shown above the step list — what this batch is for. */
92
+ preamble?: readonly string[];
93
+ }
94
+
95
+ /**
96
+ * Shell-quoted rendering of one step, exactly as an operator would type it.
97
+ *
98
+ * The steps are argv precisely so nothing here is load-bearing at execution
99
+ * time; this is for the plan, the failure remainder and the print-only modes.
100
+ */
101
+ export function formatStep(step: PrivilegedStep, asRoot = false): string {
102
+ const prefix = step.unprivileged || asRoot ? [] : ["sudo"];
103
+ return [...prefix, ...step.argv].map(shellQuote).join(" ");
104
+ }
105
+
106
+ function shellQuote(value: string): string {
107
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
108
+ }
109
+
110
+ /**
111
+ * Refuses to build a step that re-runs conductor itself.
112
+ *
113
+ * Called on every step before anything is rendered, because the failure it
114
+ * prevents is invisible: `sudo omp-conductor …` exits 0 and leaves a unit
115
+ * naming the wrong account. Cheap, and it turns a future refactor's mistake
116
+ * into a test failure rather than a broken fleet.
117
+ */
118
+ function assertNoSelfExec(steps: readonly PrivilegedStep[]): void {
119
+ for (const step of steps) {
120
+ const offender = step.argv.find((a) => {
121
+ const base = a.split("/").pop() ?? a;
122
+ return base === "omp-conductor" || base === "cli.ts";
123
+ });
124
+ if (offender !== undefined) {
125
+ throw new Error(
126
+ `privileged step "${step.title}" would re-exec conductor (${offender}) — ` +
127
+ "only individual structured steps are ever privileged",
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ async function spawnReal(request: PrivilegedSpawn): Promise<PrivilegedResult> {
134
+ const proc = Bun.spawn(request.argv as string[], {
135
+ // stdin is inherited even for captured steps: a `sudo` whose timestamp
136
+ // expired mid-batch must be able to prompt rather than fail on a closed fd.
137
+ stdin: "inherit",
138
+ // stdout is inherited so a long step (`systemctl start` on a oneshot that
139
+ // indexes for minutes) is visible while it runs instead of after it.
140
+ stdout: "inherit",
141
+ stderr: request.interactive ? "inherit" : "pipe",
142
+ });
143
+ const [exitCode, stderr] = await Promise.all([
144
+ proc.exited,
145
+ request.interactive ? Promise.resolve("") : new Response(proc.stderr).text(),
146
+ ]);
147
+ return { exitCode, stderr };
148
+ }
149
+
150
+ export const DEFAULT_PRIVILEGED_DEPS: PrivilegedDeps = {
151
+ spawn: spawnReal,
152
+ getuid: () => process.getuid?.(),
153
+ };
154
+
155
+ /**
156
+ * Renders `steps`, takes one confirm, then runs them in order.
157
+ *
158
+ * As root, every step runs directly: there is nothing to escalate, and a `sudo`
159
+ * prefix on a root-run fleet is an extra dependency for no gain. Otherwise one
160
+ * `sudo -v` primes the credential with the terminal it needs, and each
161
+ * privileged step then runs as `sudo -- <argv>` — `--` so a step whose first
162
+ * argument begins with a dash cannot be read as a `sudo` flag.
163
+ */
164
+ export async function runPrivileged(
165
+ steps: readonly PrivilegedStep[],
166
+ ui: WizardUi,
167
+ options: RunPrivilegedOptions = {},
168
+ ): Promise<PrivilegedOutcome> {
169
+ assertNoSelfExec(steps);
170
+ const deps = options.deps ?? DEFAULT_PRIVILEGED_DEPS;
171
+ const asRoot = deps.getuid() === 0;
172
+ const needsSudo = !asRoot && steps.some((s) => s.unprivileged !== true);
173
+
174
+ ui.notify(
175
+ [
176
+ ...(options.preamble ?? []),
177
+ ...(options.preamble === undefined || options.preamble.length === 0 ? [] : [""]),
178
+ ...steps.flatMap((step, index) => [
179
+ `${index + 1}. ${step.title}${step.unprivileged === true && !asRoot ? " (as you, not root)" : ""}`,
180
+ ` ${formatStep(step, asRoot)}`,
181
+ ]),
182
+ "",
183
+ asRoot
184
+ ? "Running as root — these run directly, with no sudo."
185
+ : needsSudo
186
+ ? "sudo will ask for your password once, before the first step."
187
+ : "None of these need root.",
188
+ "Nothing has been run yet.",
189
+ ].join("\n"),
190
+ "info",
191
+ );
192
+
193
+ const go = await ui.confirm(
194
+ options.title ?? "Run these steps now?",
195
+ `${steps.length} step(s), in the order shown. Anything that fails stops the rest and prints what is left.`,
196
+ );
197
+ // `undefined` is a dismissal rather than a "no", but for a batch that has not
198
+ // started they mean the same thing: run nothing.
199
+ if (go !== true) return { kind: "declined" };
200
+
201
+ if (needsSudo) {
202
+ const primed = await deps.spawn({ argv: ["sudo", "-v"], interactive: true });
203
+ if (primed.exitCode !== 0) {
204
+ return {
205
+ kind: "failed",
206
+ step: { title: "authenticate with sudo", argv: ["sudo", "-v"] },
207
+ exitCode: primed.exitCode,
208
+ stderr: primed.stderr,
209
+ remaining: steps,
210
+ };
211
+ }
212
+ }
213
+
214
+ const softFailures: { step: PrivilegedStep; stderr: string }[] = [];
215
+ for (const [index, step] of steps.entries()) {
216
+ ui.notify(`[${index + 1}/${steps.length}] ${step.title}`, "info");
217
+ const argv =
218
+ step.unprivileged === true || asRoot ? step.argv : ["sudo", "--", ...step.argv];
219
+ const result = await deps.spawn({ argv, interactive: false });
220
+ if (result.exitCode === 0) continue;
221
+ if (step.bestEffort === true) {
222
+ softFailures.push({ step, stderr: result.stderr });
223
+ ui.notify(` continued past a best-effort failure: ${firstLine(result.stderr)}`, "warning");
224
+ continue;
225
+ }
226
+ const remaining = steps.slice(index + 1);
227
+ ui.notify(formatFailure(step, result, remaining, asRoot), "error");
228
+ return { kind: "failed", step, exitCode: result.exitCode, stderr: result.stderr, remaining };
229
+ }
230
+ return { kind: "completed", softFailures };
231
+ }
232
+
233
+ function firstLine(stderr: string): string {
234
+ const line = stderr.trim().split("\n")[0];
235
+ return line === undefined || line === "" ? "no stderr" : line;
236
+ }
237
+
238
+ /**
239
+ * What the operator is left holding when a step fails: the step, why, and the
240
+ * rest of the batch verbatim so finishing by hand is a paste rather than a
241
+ * reconstruction.
242
+ */
243
+ export function formatFailure(
244
+ step: PrivilegedStep,
245
+ result: PrivilegedResult,
246
+ remaining: readonly PrivilegedStep[],
247
+ asRoot = false,
248
+ ): string {
249
+ return [
250
+ `Stopped: ${step.title} exited ${result.exitCode}.`,
251
+ ` ${formatStep(step, asRoot)}`,
252
+ ...(result.stderr.trim() === ""
253
+ ? [" (no stderr — its output is above)"]
254
+ : result.stderr.trimEnd().split("\n").map((l) => ` ${l}`)),
255
+ ...(remaining.length === 0
256
+ ? ["", "Nothing was left to run."]
257
+ : [
258
+ "",
259
+ `${remaining.length} step(s) did NOT run. To finish by hand:`,
260
+ "",
261
+ ...remaining.map((s) => ` ${formatStep(s, asRoot)}`),
262
+ ]),
263
+ ].join("\n");
264
+ }
package/src/reports.ts CHANGED
@@ -284,7 +284,7 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
284
284
  "definitive",
285
285
  );
286
286
  }
287
- return await sendTelegram(token, chatId, text);
287
+ return await sendTelegram(token, chatId, text, { topicId: p.escalation.telegramTopicId });
288
288
  };
289
289
  }
290
290
 
@@ -49,6 +49,8 @@ export interface SessionHostSpec {
49
49
  * Absent, the verbs are registered and every one of them fails closed.
50
50
  */
51
51
  verbSocketPath?: string;
52
+ /** Deny every tool but reading and searching (#307) — the setup probes. */
53
+ readOnly?: boolean;
52
54
  }
53
55
 
54
56
  /** Parent → child. */
@@ -184,6 +186,7 @@ export async function runSessionHost(
184
186
  ...(spec.resume === undefined ? {} : { resume: spec.resume }),
185
187
  ...(spec.releaseGrants === undefined ? {} : { releaseGrants: spec.releaseGrants }),
186
188
  ...(spec.verbSocketPath === undefined ? {} : { verbSocketPath: spec.verbSocketPath }),
189
+ ...(spec.readOnly === undefined ? {} : { readOnly: spec.readOnly }),
187
190
  // The release audit lives in the daemon's state directory, which this
188
191
  // process may not be able to write and must not be trusted to. It
189
192
  // becomes a message; the parent performs the durable write.
package/src/setup-host.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { homedir, userInfo } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
- import { configPath, stateDir } from "./config.ts";
5
+ import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
5
6
  import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
6
7
  import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
7
8
  import {
@@ -14,11 +15,13 @@ import {
14
15
  type StopResult,
15
16
  } from "./lifecycle.ts";
16
17
  import {
18
+ legacyArmedMarkerPath,
17
19
  readTickConfig,
18
20
  TICK_CONFIG_FILE,
21
+ tickConfigMatchesProject,
19
22
  type TickConfig,
20
23
  } from "./orchestrator-tick.ts";
21
- import type { Caps, ProjectConfig } from "./types.ts";
24
+ import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
22
25
 
23
26
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
24
27
  export const STAGED_SERVICE_NAME = "omp-conductor.service";
@@ -36,6 +39,21 @@ export interface HostRuntimePlan {
36
39
  tick?: PlannedWrite<TickConfig>;
37
40
  installCommands: readonly string[];
38
41
  cliSource: "global" | "plugin";
42
+ /** Absolute path of the unit systemd actually reads. */
43
+ installedPath: string;
44
+ /**
45
+ * What installing would do to the unit **systemd reads**, which is a different
46
+ * question from {@link HostRuntimePlan.service}'s action — that one compares the
47
+ * staged copy under the state directory.
48
+ *
49
+ * The distinction is load-bearing: staging has always happened during setup,
50
+ * so on any fleet configured before the install was executed the staged file is
51
+ * already current (`service.action === "keep"`) while `/etc/systemd/system`
52
+ * holds nothing at all. Gating the install offer on the staged action therefore
53
+ * skipped exactly the fleets that had never installed the unit, and told them
54
+ * the installed unit matched.
55
+ */
56
+ installedAction: PlannedWrite<string>["action"];
39
57
  }
40
58
 
41
59
  export interface ServiceRuntime {
@@ -66,6 +84,143 @@ function actionFor(path: string, content: string): PlannedWrite<string>["action"
66
84
  return "update";
67
85
  }
68
86
  }
87
+ /**
88
+ * Which account this host's fleet belongs to, and how we know.
89
+ *
90
+ * Two sources, in order of authority: the installed unit's own `User=` is what
91
+ * systemd will actually run as, and the owner of `$OMP_CONDUCTOR_HOME` is what
92
+ * owns the config, the store and the state directory. Either one disagreeing
93
+ * with the invoking account means staging would write the wrong identity.
94
+ */
95
+ export interface FleetAccount {
96
+ name: string | undefined;
97
+ source: "installed unit" | "$OMP_CONDUCTOR_HOME owner" | "nothing on this host";
98
+ }
99
+
100
+ export interface EscalationDeps {
101
+ invokingUser(): string;
102
+ /** `$SUDO_USER`, which `sudo` sets and `sudo -i` / `su -` clear. */
103
+ sudoUser(): string | undefined;
104
+ /** `User=` from the installed unit, or `undefined` when there is no unit. */
105
+ unitUser(): string | undefined;
106
+ /** Owner of `$OMP_CONDUCTOR_HOME`, or `undefined` when it does not exist. */
107
+ homeOwner(): string | undefined;
108
+ conductorHome(): string;
109
+ }
110
+
111
+ /** uid → account name. `id -un` because no Node builtin maps a foreign uid. */
112
+ function accountName(uid: number): string {
113
+ const ran = spawnSync("id", ["-un", String(uid)], { encoding: "utf8" });
114
+ const name = ran.status === 0 ? (ran.stdout ?? "").trim() : "";
115
+ return name === "" ? `uid ${uid}` : name;
116
+ }
117
+
118
+ function installedUnitUser(): string | undefined {
119
+ try {
120
+ const unit = readFileSync(join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME), "utf8");
121
+ const match = /^User=(.*)$/m.exec(unit);
122
+ if (match === null) return undefined;
123
+ // Unquoted in what we generate, but systemd accepts quotes and a
124
+ // hand-edited unit is exactly the case this guard has to read correctly.
125
+ const value = match[1]?.trim().replace(/^"(.*)"$/, "$1") ?? "";
126
+ return value === "" ? undefined : value;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ export const DEFAULT_ESCALATION_DEPS: EscalationDeps = {
133
+ invokingUser: () => userInfo().username,
134
+ sudoUser: () => process.env["SUDO_USER"],
135
+ unitUser: installedUnitUser,
136
+ homeOwner: () => {
137
+ try {
138
+ return accountName(statSync(dirname(configPath())).uid);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ },
143
+ conductorHome: () => dirname(configPath()),
144
+ };
145
+
146
+ /** The account this host's fleet runs as, from whichever source knows. */
147
+ export function resolveFleetAccount(deps: EscalationDeps = DEFAULT_ESCALATION_DEPS): FleetAccount {
148
+ const fromUnit = deps.unitUser();
149
+ if (fromUnit !== undefined) return { name: fromUnit, source: "installed unit" };
150
+ const fromHome = deps.homeOwner();
151
+ if (fromHome !== undefined) return { name: fromHome, source: "$OMP_CONDUCTOR_HOME owner" };
152
+ return { name: undefined, source: "nothing on this host" };
153
+ }
154
+
155
+ export type EscalationVerdict = { kind: "ok" } | { kind: "refuse"; message: string };
156
+
157
+ /**
158
+ * Refuses an *escalated* invocation of any setup path — and only that.
159
+ *
160
+ * The distinction is the whole point. Staging derives the unit's `User=` and
161
+ * `HOME=` from the invoking account ({@link defaultServiceRuntime}), so a
162
+ * `sudo omp-conductor setup host` writes `User=root` + `HOME=/root` into a unit
163
+ * for a fleet that runs as somebody else, and the config it loads, the state
164
+ * directory it writes and the indexes it points at all resolve as root too.
165
+ * Nothing about that announces itself: the unit starts, the timer goes green,
166
+ * and the fleet reads none of it. So it is refused rather than accommodated.
167
+ *
168
+ * `uid 0` is *not* the test. A fleet that legitimately runs as root — root owns
169
+ * `$OMP_CONDUCTOR_HOME` and the unit says `User=root` — is the case this verb
170
+ * exists for on single-tenant boxes, and refusing there would break exactly the
171
+ * hosts it targets. Two checks instead:
172
+ *
173
+ * - `$SUDO_USER` is set, which is `sudo` announcing the escalation itself;
174
+ * - the invoking account disagrees with the fleet account, which is what
175
+ * catches `sudo -i` and `su -` — both clear `$SUDO_USER`, so the
176
+ * environment check alone is not sufficient.
177
+ */
178
+ export function checkEscalation(
179
+ verb: string,
180
+ deps: EscalationDeps = DEFAULT_ESCALATION_DEPS,
181
+ ): EscalationVerdict {
182
+ const invoking = deps.invokingUser();
183
+ const sudoUser = deps.sudoUser();
184
+ const fleet = resolveFleetAccount(deps);
185
+
186
+ if (sudoUser !== undefined && sudoUser !== "") {
187
+ return {
188
+ kind: "refuse",
189
+ message: refusal(verb, invoking, sudoUser, fleet, deps.conductorHome()),
190
+ };
191
+ }
192
+ if (fleet.name !== undefined && fleet.name !== invoking) {
193
+ return {
194
+ kind: "refuse",
195
+ message: refusal(verb, invoking, undefined, fleet, deps.conductorHome()),
196
+ };
197
+ }
198
+ return { kind: "ok" };
199
+ }
200
+
201
+ function refusal(
202
+ verb: string,
203
+ invoking: string,
204
+ sudoUser: string | undefined,
205
+ fleet: FleetAccount,
206
+ home: string,
207
+ ): string {
208
+ const fleetName = fleet.name ?? sudoUser ?? "the fleet's own account";
209
+ return [
210
+ `omp-conductor: run ${verb} as the account the fleet runs as, not escalated.`,
211
+ sudoUser === undefined
212
+ ? `This is running as "${invoking}", but the fleet runs as "${fleetName}" (${fleet.source}).`
213
+ : `This is running as "${invoking}" under sudo from "${sudoUser}"` +
214
+ (fleet.name === undefined ? "." : `, and the fleet runs as "${fleet.name}" (${fleet.source}).`),
215
+ `As "${invoking}" the config, ${home}, ~/.cache and the unit's own User= all resolve`,
216
+ `as "${invoking}" instead, and the result is a unit that starts and a fleet that reads`,
217
+ "none of it. Nothing has been written.",
218
+ "",
219
+ `Run it as "${fleetName}". Only the individual install steps need root, and`,
220
+ `${verb} runs those for you with sudo after showing you each one.`,
221
+ ].join("\n");
222
+ }
223
+
69
224
 
70
225
  function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
71
226
  const home = homedir();
@@ -86,16 +241,19 @@ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
86
241
  };
87
242
  }
88
243
 
89
- export function renderDaemonService(
90
- project: ProjectConfig,
91
- caps: Caps,
92
- runtime: ServiceRuntime,
93
- ): string {
244
+ export function totalConfiguredWorkers(cfg: ConductorConfig = loadConfig()): number {
245
+ return cfg.projects.reduce(
246
+ (total, project) => total + resolveCaps(project, cfg.defaults).maxConcurrentWorkers,
247
+ 0,
248
+ );
249
+ }
250
+
251
+ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: number): string {
94
252
  const command =
95
253
  runtime.cli === undefined
96
- ? [runtime.bun, runtime.packageCli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)]
97
- : [runtime.cli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)];
98
- const memoryMax = caps.maxConcurrentWorkers <= 1 ? "3G" : "5G";
254
+ ? [runtime.bun, runtime.packageCli, "daemon", "--port", String(DEFAULT_PORT)]
255
+ : [runtime.cli, "daemon", "--port", String(DEFAULT_PORT)];
256
+ const memoryMax = totalWorkers <= 1 ? "3G" : "5G";
99
257
  return [
100
258
  "[Unit]",
101
259
  "Description=omp-conductor dispatch daemon",
@@ -128,6 +286,19 @@ function tickSearchRoots(project: ProjectConfig): string[] {
128
286
  return roots.filter((root, index) => roots.indexOf(root) === index);
129
287
  }
130
288
 
289
+ /**
290
+ * The tick config for one project's fleet cwd.
291
+ *
292
+ * Everything per-project here used to be shared, and that was two bugs: one
293
+ * `<stateDir>/armed` marker meant arming project A armed B as well, and one
294
+ * `agentName` meant every pane claimed the identity `fleet`, so herdr recovery
295
+ * and tick ownership could not tell two fleets apart.
296
+ *
297
+ * An existing config keeps an explicit `armedFile`/`agentName` only when it
298
+ * differs from those shared defaults. A value equal to a shared default cannot
299
+ * have been a deliberate per-project choice — it *is* the collision — so it is
300
+ * rewritten; anything else is operator intent and survives untouched.
301
+ */
131
302
  function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
132
303
  let existing: { path: string; config: TickConfig } | undefined;
133
304
  for (const root of tickSearchRoots(project)) {
@@ -135,24 +306,37 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
135
306
  if (result.kind === "invalid") {
136
307
  throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
137
308
  }
138
- if (result.kind === "ok") {
309
+ // A config stamped for another project is that project's file: the search
310
+ // roots overlap, and restamping it here would hand this project's identity
311
+ // to the other fleet's cwd.
312
+ if (result.kind === "ok" && tickConfigMatchesProject(result.config, project.name)) {
139
313
  existing = { path: result.path, config: result.config };
140
314
  break;
141
315
  }
142
316
  }
143
317
 
318
+ const armedFile = join(stateDir(), `armed-${project.name}`);
144
319
  const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
145
320
  const config: TickConfig = existing === undefined
146
321
  ? {
147
322
  intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
148
- armedFile: join(stateDir(), "armed"),
323
+ project: project.name,
324
+ armedFile,
149
325
  accessFile: join(telegramStateDir, "access.json"),
150
- agentName: DEFAULT_FLEET_AGENT_NAME,
326
+ agentName: project.name,
151
327
  }
152
328
  : {
153
329
  ...existing.config,
154
- armedFile: existing.config.armedFile ?? join(stateDir(), "armed"),
330
+ project: project.name,
331
+ armedFile:
332
+ existing.config.armedFile === undefined || existing.config.armedFile === legacyArmedMarkerPath()
333
+ ? armedFile
334
+ : existing.config.armedFile,
155
335
  accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
336
+ agentName:
337
+ existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME
338
+ ? project.name
339
+ : existing.config.agentName,
156
340
  };
157
341
  const content = `${JSON.stringify(config, null, 2)}\n`;
158
342
  return { path, action: actionFor(path, content), content, value: config };
@@ -160,12 +344,16 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
160
344
 
161
345
  export function planHostRuntime(
162
346
  project: ProjectConfig,
163
- caps: Caps,
347
+ _caps: Caps,
164
348
  telegramStateDir: string,
165
349
  runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
350
+ // Callers that know the fleet pass the sum of resolved maxConcurrentWorkers.
351
+ // Defaulting through loadConfig() would throw in install tests that stage
352
+ // files before a config exists, and would hide a missing total at the call site.
353
+ totalWorkers: number = 1,
166
354
  ): HostRuntimePlan {
167
355
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
168
- const serviceContent = renderDaemonService(project, caps, runtime);
356
+ const serviceContent = renderDaemonService(runtime, totalWorkers);
169
357
  const service: PlannedWrite<string> = {
170
358
  path: servicePath,
171
359
  action: actionFor(servicePath, serviceContent),
@@ -185,6 +373,8 @@ export function planHostRuntime(
185
373
  `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
186
374
  ],
187
375
  cliSource: runtime.cli === undefined ? "plugin" : "global",
376
+ installedPath,
377
+ installedAction: actionFor(installedPath, serviceContent),
188
378
  };
189
379
  }
190
380
 
@@ -242,7 +432,7 @@ export interface SetupSmokeResult {
242
432
  }
243
433
 
244
434
  export interface SetupSmokeDeps {
245
- paused(): boolean;
435
+ paused(project: string): boolean;
246
436
  runOnce(project: string): Promise<void>;
247
437
  living(): DaemonRecord | undefined;
248
438
  health(port: number): Promise<{ ok: boolean; body?: string }>;
@@ -265,7 +455,7 @@ export async function runSetupSmoke(
265
455
  project: string,
266
456
  deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
267
457
  ): Promise<SetupSmokeResult> {
268
- if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
458
+ if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
269
459
  await deps.runOnce(project);
270
460
  const existing = deps.living();
271
461
  if (existing !== undefined) {
@@ -283,8 +473,3 @@ export async function runSetupSmoke(
283
473
  await deps.stop();
284
474
  }
285
475
  }
286
-
287
- /** Absolute path of the unit systemd actually reads. */
288
- export function installedUnitPath(): string {
289
- return join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
290
- }