omp-conductor 0.3.13 → 0.3.16
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 +227 -70
- package/package.json +3 -2
- package/skills/conductor-update/SKILL.md +157 -0
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +3 -2
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +161 -40
- package/src/confinement.ts +123 -0
- package/src/daemon.ts +55 -2
- package/src/fleet.ts +1271 -0
- package/src/host.ts +90 -0
- package/src/lifecycle.ts +182 -77
- package/src/omp.ts +12 -0
- package/src/orchestrator-tick.ts +64 -3
- package/src/plugin.ts +110 -17
- package/src/tracker/github.ts +25 -1
- package/src/types.ts +10 -0
- package/src/worker.ts +2 -0
- package/systemd/omp-conductor.service.example +54 -0
package/src/host.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host facts the wizard and status surfaces need without dragging in systemd
|
|
3
|
+
* or the tracker. Pure so tests pin the thresholds without a real machine.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { DEFAULT_CAPS } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** 16 GiB — below this, two in-process omp sessions plus the orchestrator are
|
|
11
|
+
* a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */
|
|
12
|
+
export const SMALL_HOST_RAM_BYTES = 16 * 1024 ** 3;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* MemTotal from a `/proc/meminfo` body, in bytes. kB × 1024, matching what
|
|
16
|
+
* Linux reports; undefined when the line is missing or unparsable.
|
|
17
|
+
*/
|
|
18
|
+
export function ramBytesFromMeminfo(text: string): number | undefined {
|
|
19
|
+
const m = /^MemTotal:\s+(\d+)\s+kB\s*$/m.exec(text);
|
|
20
|
+
if (m === null) return undefined;
|
|
21
|
+
const kib = Number(m[1]);
|
|
22
|
+
if (!Number.isFinite(kib) || kib <= 0) return undefined;
|
|
23
|
+
return kib * 1024;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Darwin `sysctl -n hw.memsize` stdout → bytes. */
|
|
27
|
+
export function ramBytesFromSysctl(raw: string): number | undefined {
|
|
28
|
+
const n = Number(raw.trim());
|
|
29
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Total installed RAM when the host will say, otherwise undefined. Never
|
|
35
|
+
* throws: a container without /proc or a locked-down sysctl is "unknown", and
|
|
36
|
+
* callers fall back to the shipped worker default.
|
|
37
|
+
*/
|
|
38
|
+
export function hostRamBytes(): number | undefined {
|
|
39
|
+
if (process.platform === "linux") {
|
|
40
|
+
try {
|
|
41
|
+
return ramBytesFromMeminfo(readFileSync("/proc/meminfo", "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (process.platform === "darwin") {
|
|
47
|
+
const ran = spawnSync("sysctl", ["-n", "hw.memsize"], { encoding: "utf8" });
|
|
48
|
+
if (ran.status !== 0 || ran.stdout === null) return undefined;
|
|
49
|
+
return ramBytesFromSysctl(ran.stdout);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Setup default for `maxConcurrentWorkers`. Small hosts get 1; everyone else
|
|
56
|
+
* keeps {@link DEFAULT_CAPS.maxConcurrentWorkers}. Unknown RAM keeps the
|
|
57
|
+
* shipped default — guessing low would silently halve throughput on a beefy
|
|
58
|
+
* box whose /proc we cannot read.
|
|
59
|
+
*/
|
|
60
|
+
export function recommendedMaxWorkers(ramBytes: number | undefined): number {
|
|
61
|
+
if (ramBytes === undefined) return DEFAULT_CAPS.maxConcurrentWorkers;
|
|
62
|
+
return ramBytes < SMALL_HOST_RAM_BYTES ? 1 : DEFAULT_CAPS.maxConcurrentWorkers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
|
|
66
|
+
export function formatRss(bytes: number): string {
|
|
67
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
68
|
+
const gb = bytes / 1024 ** 3;
|
|
69
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
70
|
+
const mb = bytes / 1024 ** 2;
|
|
71
|
+
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
|
72
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `rssBytes` from a `/healthz` JSON body. Old daemons omit the field; garbage
|
|
77
|
+
* answers undefined rather than NaN so status can hide the line.
|
|
78
|
+
*/
|
|
79
|
+
export function rssBytesFromHealthz(body: string | undefined): number | undefined {
|
|
80
|
+
if (body === undefined || body.length === 0) return undefined;
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(body);
|
|
83
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
84
|
+
const n = (parsed as { rssBytes?: unknown }).rssBytes;
|
|
85
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return undefined;
|
|
86
|
+
return n;
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -313,39 +313,34 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
313
313
|
// `livingDaemon` already cleared a stale file; this covers the unparseable
|
|
314
314
|
// one it refused to read. Still ask systemd: a unit can be running with
|
|
315
315
|
// no pidfile (boot race, wiped runtime dir) and stop must still land.
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
`daemon pid ${unitPid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
|
|
323
|
-
`check \`systemctl status ${SYSTEMD_UNIT}\``,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
316
|
+
const decision = decideSystemdStop(undefined);
|
|
317
|
+
if (decision.kind === "unknown") {
|
|
318
|
+
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
319
|
+
}
|
|
320
|
+
if (decision.kind === "stop") {
|
|
321
|
+
await runSystemdStop(decision.mainPid, o.timeoutMs);
|
|
326
322
|
clearRecord();
|
|
327
|
-
return { kind: "stopped", pid:
|
|
323
|
+
return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
|
|
328
324
|
}
|
|
329
325
|
clearRecord();
|
|
330
326
|
return { kind: "not-running" };
|
|
331
327
|
}
|
|
332
328
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
`daemon pid ${rec.pid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
|
|
342
|
-
`check \`systemctl status ${SYSTEMD_UNIT}\``,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
329
|
+
const decision = decideSystemdStop(rec.pid);
|
|
330
|
+
if (decision.kind === "unknown") {
|
|
331
|
+
// Pidfile stays: we could not prove the unit does *not* own this pid, and
|
|
332
|
+
// signalling it under that uncertainty is the bounce under Restart=on-failure.
|
|
333
|
+
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
334
|
+
}
|
|
335
|
+
if (decision.kind === "stop") {
|
|
336
|
+
await runSystemdStop(rec.pid, o.timeoutMs);
|
|
345
337
|
clearRecord();
|
|
346
338
|
return { kind: "stopped", pid: rec.pid, via: "systemctl" };
|
|
347
339
|
}
|
|
348
340
|
|
|
341
|
+
// decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
|
|
342
|
+
// binary, or a unit whose MainPID is somebody else. Only a *confirmed*
|
|
343
|
+
// negative is safe to signal.
|
|
349
344
|
const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
|
|
350
345
|
if (!gone) {
|
|
351
346
|
// The record stays: something is still holding that pid, and forgetting
|
|
@@ -360,11 +355,11 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
360
355
|
* Restarts the daemon.
|
|
361
356
|
*
|
|
362
357
|
* Same ownership rule as {@link stopDaemon}: when the unit owns the live pid,
|
|
363
|
-
* `systemctl restart`
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
358
|
+
* `systemctl restart` is the *only* path — a failed manager call is terminal,
|
|
359
|
+
* never a fallthrough to raw signals. An *unanswered* ownership query is also
|
|
360
|
+
* terminal: "dbus blipped" is not "no unit". Falling back to stop+start is
|
|
361
|
+
* reserved for a confirmed hand-started daemon (no systemd, inactive unit, or
|
|
362
|
+
* a unit whose MainPID is someone else).
|
|
368
363
|
*
|
|
369
364
|
* Returns the record of the process that is now answering `/healthz`.
|
|
370
365
|
*/
|
|
@@ -372,34 +367,38 @@ export async function restartDaemon(
|
|
|
372
367
|
o: { port?: number; project?: string; timeoutMs?: number } = {},
|
|
373
368
|
): Promise<{ previous: DaemonRecord | undefined; record: DaemonRecord; via: "systemctl" | "cli" }> {
|
|
374
369
|
const previous = livingDaemon();
|
|
375
|
-
const
|
|
370
|
+
const ownership = probeUnit();
|
|
371
|
+
if (ownership.kind === "unknown") {
|
|
372
|
+
throw new Error(ownershipUnknown("restart", ownership.reason));
|
|
373
|
+
}
|
|
374
|
+
|
|
376
375
|
const unitOwns =
|
|
377
|
-
|
|
376
|
+
ownership.kind === "active" && (previous === undefined || previous.pid === ownership.pid);
|
|
378
377
|
|
|
379
378
|
if (unitOwns) {
|
|
379
|
+
// Ownership is proven. A refused/timed-out restart must not fall through
|
|
380
|
+
// to stopDaemon's signal path — that is the exact bounce this module exists
|
|
381
|
+
// to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
|
|
380
382
|
const ran = systemctl(["restart", SYSTEMD_UNIT]);
|
|
381
|
-
if (ran.ok) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (
|
|
393
|
-
await sleep(READY_POLL_MS);
|
|
383
|
+
if (!ran.ok) {
|
|
384
|
+
throw new Error(systemctlFailure("restart", ran));
|
|
385
|
+
}
|
|
386
|
+
// systemctl restart returns once the new MainPID is up; the pidfile is
|
|
387
|
+
// written by the daemon itself on boot, so wait for that rather than
|
|
388
|
+
// inventing a record from the unit alone.
|
|
389
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
390
|
+
for (;;) {
|
|
391
|
+
const rec = livingDaemon();
|
|
392
|
+
if (rec !== undefined) {
|
|
393
|
+
const health = await healthCheck(rec.port);
|
|
394
|
+
if (health.ok) return { previous, record: rec, via: "systemctl" };
|
|
394
395
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
);
|
|
396
|
+
if (Date.now() >= deadline) break;
|
|
397
|
+
await sleep(READY_POLL_MS);
|
|
398
398
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// reach through the unit.
|
|
399
|
+
throw new Error(
|
|
400
|
+
`systemctl restart ${SYSTEMD_UNIT} returned, but the daemon never answered /healthz`,
|
|
401
|
+
);
|
|
403
402
|
}
|
|
404
403
|
|
|
405
404
|
await stopDaemon({ timeoutMs: o.timeoutMs });
|
|
@@ -408,32 +407,70 @@ export async function restartDaemon(
|
|
|
408
407
|
}
|
|
409
408
|
|
|
410
409
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
410
|
+
* What we know about {@link SYSTEMD_UNIT}.
|
|
411
|
+
*
|
|
412
|
+
* - `active` — unit has a live MainPID (> 1). Includes transitional manager
|
|
413
|
+
* states (`activating`, `deactivating`, `reloading`, `reactivating`) — the
|
|
414
|
+
* pid is still systemd-owned, so a raw SIGTERM would bounce under
|
|
415
|
+
* `Restart=on-failure`.
|
|
416
|
+
* - `inactive` — confirmed not running (MainPID absent/0), unit absent, *or*
|
|
417
|
+
* no `systemctl` binary on this host. Safe to treat as "not supervised here".
|
|
418
|
+
* - `unknown` — the manager exists (or we cannot tell it does not) but the
|
|
419
|
+
* query failed: dbus blip, permission, timeout. Must not be collapsed into
|
|
420
|
+
* `inactive` — that is how a unit-owned daemon gets a raw SIGTERM.
|
|
414
421
|
*/
|
|
415
|
-
export
|
|
422
|
+
export type UnitOwnership =
|
|
423
|
+
| { kind: "active"; pid: number }
|
|
424
|
+
| { kind: "inactive" }
|
|
425
|
+
| { kind: "unknown"; reason: string };
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Probe {@link SYSTEMD_UNIT} ownership. Never throws — the caller decides
|
|
429
|
+
* whether `unknown` is fatal (stop/restart: yes).
|
|
430
|
+
*/
|
|
431
|
+
export function probeUnit(unit = SYSTEMD_UNIT): UnitOwnership {
|
|
416
432
|
const ran = systemctl(["show", unit, "--property=MainPID", "--property=ActiveState", "--value"]);
|
|
417
|
-
if (!ran.ok)
|
|
433
|
+
if (!ran.ok) {
|
|
434
|
+
// No binary at all → this host has no systemd manager, so nothing here can
|
|
435
|
+
// be unit-owned. Any other failure (dbus, auth, timeout) is unknown.
|
|
436
|
+
if (ran.missing) return { kind: "inactive" };
|
|
437
|
+
const detail = (ran.stderr.trim() || ran.stdout.trim() || "systemctl show failed").split("\n")[0]!;
|
|
438
|
+
return { kind: "unknown", reason: detail };
|
|
439
|
+
}
|
|
418
440
|
// `systemctl show --value` prints one property per line, MainPID then
|
|
419
441
|
// ActiveState, in the order requested. Tolerate either order and blank
|
|
420
442
|
// lines so a future systemctl rearrange does not silently disable the path.
|
|
443
|
+
// Unknown units still exit 0 with `0` / `inactive`, which is the confirmed
|
|
444
|
+
// negative we want — not an error.
|
|
445
|
+
//
|
|
446
|
+
// Ownership is the MainPID, not ActiveState. A successful probe that names a
|
|
447
|
+
// live pid (> 1) means systemd still owns that process — including during
|
|
448
|
+
// `activating` / `deactivating` / `reloading`. Filtering on ActiveState here
|
|
449
|
+
// used to label those transitional states "inactive" and hand the pid to a
|
|
450
|
+
// raw SIGTERM, which is exactly the Restart=on-failure bounce.
|
|
421
451
|
const lines = ran.stdout
|
|
422
452
|
.split("\n")
|
|
423
453
|
.map((l) => l.trim())
|
|
424
454
|
.filter((l) => l.length > 0);
|
|
425
455
|
let pid: number | undefined;
|
|
426
|
-
let active: string | undefined;
|
|
427
456
|
for (const line of lines) {
|
|
428
457
|
if (/^\d+$/.test(line)) {
|
|
429
458
|
const n = Number(line);
|
|
430
459
|
if (Number.isInteger(n) && n > 1) pid = n;
|
|
431
|
-
} else {
|
|
432
|
-
active = line;
|
|
433
460
|
}
|
|
434
461
|
}
|
|
435
|
-
if (
|
|
436
|
-
return pid;
|
|
462
|
+
if (pid === undefined) return { kind: "inactive" };
|
|
463
|
+
return { kind: "active", pid };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The MainPID of an *active* {@link SYSTEMD_UNIT}, or `undefined` when the
|
|
468
|
+
* unit is confirmed inactive/absent or when ownership could not be determined.
|
|
469
|
+
* Prefer {@link probeUnit} when the caller must distinguish those two.
|
|
470
|
+
*/
|
|
471
|
+
export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
472
|
+
const ownership = probeUnit(unit);
|
|
473
|
+
return ownership.kind === "active" ? ownership.pid : undefined;
|
|
437
474
|
}
|
|
438
475
|
|
|
439
476
|
// ---------------------------------------------------------------------------
|
|
@@ -441,40 +478,108 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
|
441
478
|
// ---------------------------------------------------------------------------
|
|
442
479
|
|
|
443
480
|
/**
|
|
444
|
-
*
|
|
481
|
+
* Whether systemd should stop this daemon.
|
|
482
|
+
*
|
|
483
|
+
* - `stop` — unit is active and owns `pid` (or there is no pidfile and the
|
|
484
|
+
* unit is the only candidate). Caller MUST go through systemctl; a failed
|
|
485
|
+
* manager call is terminal.
|
|
486
|
+
* - `not-ours` — confirmed inactive/absent unit, no systemd binary, or a unit
|
|
487
|
+
* whose MainPID is someone else. Caller may SIGTERM its own pidfile process.
|
|
488
|
+
* - `unknown` — ownership query failed. Caller MUST NOT signal.
|
|
489
|
+
*/
|
|
490
|
+
type SystemdStopDecision =
|
|
491
|
+
| { kind: "stop"; mainPid: number }
|
|
492
|
+
| { kind: "not-ours" }
|
|
493
|
+
| { kind: "unknown"; reason: string };
|
|
494
|
+
|
|
495
|
+
function decideSystemdStop(pid: number | undefined): SystemdStopDecision {
|
|
496
|
+
const ownership = probeUnit();
|
|
497
|
+
if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
|
|
498
|
+
if (ownership.kind === "inactive") return { kind: "not-ours" };
|
|
499
|
+
if (pid !== undefined && ownership.pid !== pid) return { kind: "not-ours" };
|
|
500
|
+
return { kind: "stop", mainPid: ownership.pid };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* `systemctl stop` for a pid we have already proven the unit owns.
|
|
445
505
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
* down a neighbour, and the signal path handles *our* pid.
|
|
506
|
+
* Throws on manager refusal/timeout — never returns "false" for the caller to
|
|
507
|
+
* reinterpret as "try SIGTERM instead". That reinterpretation is how stop
|
|
508
|
+
* bounced under `Restart=on-failure`.
|
|
450
509
|
*/
|
|
451
|
-
async function
|
|
452
|
-
const main = systemdMainPid();
|
|
453
|
-
if (main === undefined) return false;
|
|
454
|
-
if (pid !== undefined && main !== pid) return false;
|
|
510
|
+
async function runSystemdStop(pid: number, timeoutMs?: number): Promise<void> {
|
|
455
511
|
const ran = systemctl(["stop", SYSTEMD_UNIT]);
|
|
456
|
-
|
|
512
|
+
if (!ran.ok) {
|
|
513
|
+
// Record stays: the unit still owns the process, and clearing it would let
|
|
514
|
+
// the next `start` believe the port is free while systemd still holds it.
|
|
515
|
+
throw new Error(systemctlFailure("stop", ran));
|
|
516
|
+
}
|
|
517
|
+
// systemctl stop is synchronous for Type=simple, but a slow drain still
|
|
518
|
+
// holds the old pid briefly and the next start would refuse against it.
|
|
519
|
+
const deadline = Date.now() + (timeoutMs ?? STOP_TIMEOUT_MS);
|
|
520
|
+
while (isAlive(pid) && Date.now() < deadline) await sleep(100);
|
|
521
|
+
if (isAlive(pid)) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`daemon pid ${pid} still alive after systemctl stop ${SYSTEMD_UNIT} — ` +
|
|
524
|
+
`check \`systemctl status ${SYSTEMD_UNIT}\``,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function systemctlFailure(verb: "stop" | "restart", ran: SystemctlResult): string {
|
|
530
|
+
const detail = (ran.stderr.trim() || ran.stdout.trim() || "no output").split("\n")[0] ?? "no output";
|
|
531
|
+
return (
|
|
532
|
+
`systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ` +
|
|
533
|
+
`refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce); ` +
|
|
534
|
+
`fix the unit or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function ownershipUnknown(verb: "stop" | "restart", reason: string): string {
|
|
539
|
+
return (
|
|
540
|
+
`cannot determine whether ${SYSTEMD_UNIT} owns the daemon (${reason}) — ` +
|
|
541
|
+
`refusing to ${verb} via signal while ownership is unknown ` +
|
|
542
|
+
`(a raw SIGTERM under Restart=on-failure is a bounce); ` +
|
|
543
|
+
`retry when systemctl answers, or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
|
|
544
|
+
);
|
|
457
545
|
}
|
|
458
546
|
|
|
459
547
|
/**
|
|
460
|
-
* Run one `systemctl` invocation. Captures output and never throws:
|
|
461
|
-
*
|
|
462
|
-
*
|
|
548
|
+
* Run one `systemctl` invocation. Captures output and never throws: the caller
|
|
549
|
+
* classifies the result. `missing: true` means the binary is not on PATH — that
|
|
550
|
+
* is a confirmed "no systemd on this host", not a transient query failure.
|
|
463
551
|
*
|
|
464
552
|
* The default shells out. Tests replace it with {@link setSystemctlForTest}
|
|
465
553
|
* so the ownership decision is exercised without a real systemd.
|
|
466
554
|
*/
|
|
467
|
-
export type
|
|
555
|
+
export type SystemctlResult = {
|
|
556
|
+
ok: boolean;
|
|
557
|
+
stdout: string;
|
|
558
|
+
stderr: string;
|
|
559
|
+
/** Binary not found (ENOENT). Distinct from a failed invocation of a present binary. */
|
|
560
|
+
missing?: boolean;
|
|
561
|
+
};
|
|
468
562
|
|
|
469
|
-
|
|
563
|
+
export type SystemctlFn = (args: string[]) => SystemctlResult;
|
|
564
|
+
|
|
565
|
+
function defaultSystemctl(args: string[]): SystemctlResult {
|
|
470
566
|
try {
|
|
471
567
|
const res = spawnSync("systemctl", args, {
|
|
472
568
|
encoding: "utf8",
|
|
473
|
-
//
|
|
569
|
+
// Bound the wait: a hung dbus becomes `unknown` ownership, not a hang.
|
|
570
|
+
// Callers must NOT treat that timeout as "no unit".
|
|
474
571
|
timeout: 15_000,
|
|
475
572
|
env: process.env,
|
|
476
573
|
});
|
|
477
|
-
if (res.error)
|
|
574
|
+
if (res.error) {
|
|
575
|
+
const err = res.error as NodeJS.ErrnoException;
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
stdout: "",
|
|
579
|
+
stderr: err.message,
|
|
580
|
+
...(err.code === "ENOENT" ? { missing: true } : {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
478
583
|
return {
|
|
479
584
|
ok: res.status === 0,
|
|
480
585
|
stdout: res.stdout ?? "",
|
package/src/omp.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* site on purpose: a non-literal specifier stops `tsc` from trying to resolve
|
|
14
14
|
* the module, which is the whole reason this shim exists.
|
|
15
15
|
*/
|
|
16
|
+
import { worktreeConfinement } from "./confinement.ts";
|
|
17
|
+
|
|
16
18
|
const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -114,6 +116,12 @@ export async function createSession(opts: {
|
|
|
114
116
|
sessionDir?: string;
|
|
115
117
|
model?: string;
|
|
116
118
|
resume?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Install the worktree `tool_call` gate so structured file tools cannot
|
|
121
|
+
* leave `cwd`. Workers pass true; the orchestrator leaves this off — it has
|
|
122
|
+
* to read the state directory and briefs.
|
|
123
|
+
*/
|
|
124
|
+
confineToCwd?: boolean;
|
|
117
125
|
}): Promise<AgentSessionLike> {
|
|
118
126
|
let loaded: unknown;
|
|
119
127
|
try {
|
|
@@ -167,6 +175,10 @@ export async function createSession(opts: {
|
|
|
167
175
|
// agentDir (~/.omp/agent/mcp.json) — without this, workers grep-only and
|
|
168
176
|
// burn the turns cap on discovery (#29).
|
|
169
177
|
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)] } : {}),
|
|
170
182
|
});
|
|
171
183
|
const raw = asRawSession(created);
|
|
172
184
|
// Surfaced rather than swallowed: this is how a quiet downgrade to a weaker
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
import { spawnSync } from "node:child_process";
|
|
44
|
-
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { isAbsolute, join, resolve } from "node:path";
|
|
46
46
|
import { findProject, loadConfig } from "./config.ts";
|
|
47
47
|
import {
|
|
@@ -107,6 +107,43 @@ export const STALL_TICKS = 2;
|
|
|
107
107
|
*/
|
|
108
108
|
export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
|
|
109
109
|
|
|
110
|
+
/** Runtime heartbeat schedule consumed by `omp-conductor status`. */
|
|
111
|
+
export const TICK_STATUS_FILE = ".conductor-tick-status.json";
|
|
112
|
+
|
|
113
|
+
export interface TickRuntimeStatus {
|
|
114
|
+
pid: number;
|
|
115
|
+
intervalSeconds: number;
|
|
116
|
+
nextTickAt: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readTickRuntimeStatus(cwd: string): TickRuntimeStatus | undefined {
|
|
120
|
+
let parsed: unknown;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(readFileSync(join(cwd, TICK_STATUS_FILE), "utf8"));
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
127
|
+
const row = parsed as Record<string, unknown>;
|
|
128
|
+
if (
|
|
129
|
+
typeof row["pid"] !== "number" ||
|
|
130
|
+
!Number.isInteger(row["pid"]) ||
|
|
131
|
+
row["pid"] <= 1 ||
|
|
132
|
+
typeof row["intervalSeconds"] !== "number" ||
|
|
133
|
+
!Number.isInteger(row["intervalSeconds"]) ||
|
|
134
|
+
row["intervalSeconds"] < MIN_INTERVAL_SECONDS ||
|
|
135
|
+
typeof row["nextTickAt"] !== "string" ||
|
|
136
|
+
!Number.isFinite(Date.parse(row["nextTickAt"]))
|
|
137
|
+
) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
pid: row["pid"],
|
|
142
|
+
intervalSeconds: row["intervalSeconds"],
|
|
143
|
+
nextTickAt: row["nextTickAt"],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
/**
|
|
111
148
|
* The marker's one line after its ISO timestamp, and the middle of the error
|
|
112
149
|
* log. Shared so the file and the log can never describe different failures.
|
|
@@ -334,7 +371,7 @@ export function resolveTickScope(): {
|
|
|
334
371
|
* live `POLICY.md`.
|
|
335
372
|
*
|
|
336
373
|
* Runs on **every** successful send — including ticks that use a custom
|
|
337
|
-
* `message` — so protocol updates land after
|
|
374
|
+
* `message` — so protocol updates land after a package upgrade without waiting for
|
|
338
375
|
* the default prompt path. Failures (no config, no `POLICY.md`, unreadable
|
|
339
376
|
* overlay) are silent: the tick still goes out.
|
|
340
377
|
*/
|
|
@@ -971,6 +1008,23 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
971
1008
|
clearTickRequest(pi, ctx.cwd);
|
|
972
1009
|
}
|
|
973
1010
|
|
|
1011
|
+
function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: number): void {
|
|
1012
|
+
const path = join(cwd, TICK_STATUS_FILE);
|
|
1013
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1014
|
+
const status: TickRuntimeStatus = {
|
|
1015
|
+
pid: process.pid,
|
|
1016
|
+
intervalSeconds,
|
|
1017
|
+
nextTickAt: new Date(Date.now() + intervalSeconds * 1000).toISOString(),
|
|
1018
|
+
};
|
|
1019
|
+
try {
|
|
1020
|
+
writeFileSync(tmp, `${JSON.stringify(status)}\n`);
|
|
1021
|
+
renameSync(tmp, path);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
rmSync(tmp, { force: true });
|
|
1024
|
+
pi.logger.error(`[omp-conductor] could not write ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
974
1028
|
/**
|
|
975
1029
|
* Arm the interval heartbeat, then honour a recover poke if one is waiting.
|
|
976
1030
|
* Extracted so the ownership-retry path and the immediate-accept path cannot
|
|
@@ -978,7 +1032,14 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
978
1032
|
* behaviour.
|
|
979
1033
|
*/
|
|
980
1034
|
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
981
|
-
|
|
1035
|
+
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
1036
|
+
ctx.setInterval(() => {
|
|
1037
|
+
try {
|
|
1038
|
+
tick(pi, ctx, config, session);
|
|
1039
|
+
} finally {
|
|
1040
|
+
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
1041
|
+
}
|
|
1042
|
+
}, config.intervalSeconds * 1000);
|
|
982
1043
|
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
983
1044
|
pi.logger.info("[omp-conductor] tick requested by recover — firing without waiting for the interval");
|
|
984
1045
|
tick(pi, ctx, config, session);
|