omp-conductor 0.15.12 → 0.16.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.
- package/REFERENCE.md +81 -6
- package/package.json +2 -1
- package/schema/config.schema.json +6 -0
- package/src/admission.ts +745 -0
- package/src/ask.ts +47 -0
- package/src/backups.ts +19 -7
- package/src/board.ts +1 -2
- package/src/briefs/orchestrator.md +62 -4
- package/src/cli.ts +26 -0
- package/src/commands/context.ts +3 -0
- package/src/commands/decision.ts +10 -1
- package/src/commands/doctor.ts +2 -0
- package/src/commands/message.ts +8 -1
- package/src/commands/restart.ts +93 -54
- package/src/commands/restore-db.ts +146 -0
- package/src/commands/stop.ts +66 -34
- package/src/commands/unfreeze.ts +56 -0
- package/src/commands/watch.ts +77 -0
- package/src/config-schema.ts +9 -0
- package/src/config.ts +24 -0
- package/src/daemon.ts +485 -577
- package/src/dashboard/server.ts +2 -1
- package/src/decisions.ts +32 -7
- package/src/depends-on.ts +73 -0
- package/src/doctor.ts +418 -8
- package/src/escalate.ts +122 -15
- package/src/failure-class.ts +47 -0
- package/src/fleet.ts +55 -377
- package/src/gitops.ts +86 -1
- package/src/lifecycle.ts +113 -2
- package/src/log.ts +40 -0
- package/src/model-fallback.ts +3 -2
- package/src/omp-settings.ts +114 -0
- package/src/omp.ts +63 -0
- package/src/orchestrator-down.ts +231 -0
- package/src/orchestrator-tick.ts +14 -1
- package/src/orchestrator.ts +14 -0
- package/src/release-policy.ts +163 -18
- package/src/reports.ts +124 -12
- package/src/session-host.ts +6 -0
- package/src/setup-host.ts +386 -17
- package/src/setup-install.ts +40 -2
- package/src/setup-wizard.ts +314 -113
- package/src/setup.ts +58 -1
- package/src/status-render.ts +445 -0
- package/src/stop-provenance.ts +119 -0
- package/src/store.ts +533 -11
- package/src/types.ts +298 -4
- package/src/unblock.ts +1 -1
- package/src/upgrade-verify.ts +1 -1
- package/src/upgrade.ts +27 -8
- package/src/verbs/protocol.ts +16 -3
- package/src/verbs/server.ts +52 -1
- package/src/wizard-ui.ts +261 -46
- package/src/worker.ts +183 -10
package/src/lifecycle.ts
CHANGED
|
@@ -25,6 +25,10 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
25
25
|
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { join } from "node:path";
|
|
28
|
+
// Type-only: erased at runtime, so the "free of every other module" property
|
|
29
|
+
// below survives — this module still opens no store, loads no config and runs
|
|
30
|
+
// no `gh`, and `stop`/`status` keep working when the config is broken.
|
|
31
|
+
import type { DaemonStopDraft } from "./types.ts";
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The systemd unit name operators are expected to install for a supervised
|
|
@@ -480,6 +484,58 @@ export async function startDaemon(
|
|
|
480
484
|
);
|
|
481
485
|
}
|
|
482
486
|
|
|
487
|
+
/**
|
|
488
|
+
* The delivery facts a mediated stop/restart records at the exact chokepoint:
|
|
489
|
+
* the request's own provenance, how the stop is about to be delivered, and the
|
|
490
|
+
* daemon pid it targets (#378). The recorder is injected by the CLI callers
|
|
491
|
+
* (which own the store); this module stays free of it.
|
|
492
|
+
*/
|
|
493
|
+
export interface StopDelivery {
|
|
494
|
+
/** The request facts built by the CLI (commands/stop.ts, commands/restart.ts). */
|
|
495
|
+
provenance: DaemonStopDraft;
|
|
496
|
+
/** Whether the stop is about to go through `systemctl` or a raw signal. */
|
|
497
|
+
via: "systemctl" | "signal";
|
|
498
|
+
/** The daemon pid being stopped, when one was known. */
|
|
499
|
+
pid?: number;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The injected recorder invoked immediately before a stop/restart is signalled. */
|
|
503
|
+
export type StopDeliveryFn = (stop: StopDelivery) => void;
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The honest provenance for a daemon that received a stop with no mediated
|
|
507
|
+
* request anywhere: unattributed by construction — Linux exposes no sender
|
|
508
|
+
* identity for a signal, so the record must say "unattributed" rather than
|
|
509
|
+
* guess one — plus what the receiving process actually knows: its own pid,
|
|
510
|
+
* the moment (stamped by the store), the runtime directory, and the projects
|
|
511
|
+
* it serves with their live-run counts.
|
|
512
|
+
*
|
|
513
|
+
* Storage belongs at the caller: the daemon's SIGINT/SIGTERM path writes the
|
|
514
|
+
* returned draft through the store before/while draining. This builds the
|
|
515
|
+
* facts, store-free, so a raw `kill`/out-of-band `systemctl stop` — no
|
|
516
|
+
* conductor CLI in the delivery path at all — still leaves a durable row the
|
|
517
|
+
* next `omp-conductor status` can show.
|
|
518
|
+
*/
|
|
519
|
+
export function externalStopProvenance(o: {
|
|
520
|
+
/** The daemon pid that received the signal, when known. */
|
|
521
|
+
daemonPid?: number;
|
|
522
|
+
/** The daemon's runtime directory (host paths only — never secrets). */
|
|
523
|
+
runtimeDir: string;
|
|
524
|
+
/** Every served project with its live-run count at signal time. */
|
|
525
|
+
affected: { project: string; live: number }[];
|
|
526
|
+
reason?: string;
|
|
527
|
+
}): DaemonStopDraft {
|
|
528
|
+
return {
|
|
529
|
+
controlPath: "external signal",
|
|
530
|
+
scope: "global",
|
|
531
|
+
...(o.daemonPid === undefined ? {} : { daemonPid: o.daemonPid }),
|
|
532
|
+
runtimeDir: o.runtimeDir,
|
|
533
|
+
affected: o.affected,
|
|
534
|
+
reason: o.reason ?? "external signal — no mediated stop request was recorded",
|
|
535
|
+
unattributed: true,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
483
539
|
/**
|
|
484
540
|
* How the last stop actually landed. Callers print this so an operator can
|
|
485
541
|
* tell a supervised stop from a bare SIGTERM without reading the journal.
|
|
@@ -510,7 +566,35 @@ export interface RestartResult {
|
|
|
510
566
|
* a stopped unit). The grace period is a deadline, not a clean drain: a tick
|
|
511
567
|
* with a worker in flight can run for that worker's whole wall clock.
|
|
512
568
|
*/
|
|
513
|
-
export async function stopDaemon(
|
|
569
|
+
export async function stopDaemon(
|
|
570
|
+
o: {
|
|
571
|
+
timeoutMs?: number;
|
|
572
|
+
/**
|
|
573
|
+
* The request facts for this stop, when a mediated caller carries them.
|
|
574
|
+
* With `record`, the durable row is written immediately before the signal
|
|
575
|
+
* is sent — the exact ordering "provenance precedes signalling" refers to
|
|
576
|
+
* — and only when a stop actually lands: a request that finds no daemon
|
|
577
|
+
* stops nothing and records nothing. Drain-style callers whose restart may
|
|
578
|
+
* never execute additionally record the request at entry themselves.
|
|
579
|
+
*/
|
|
580
|
+
provenance?: DaemonStopDraft;
|
|
581
|
+
/** Invoked just before the daemon is signalled, with the delivery method. */
|
|
582
|
+
record?: StopDeliveryFn;
|
|
583
|
+
} = {},
|
|
584
|
+
): Promise<StopResult> {
|
|
585
|
+
const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
|
|
586
|
+
if (o.provenance === undefined || o.record === undefined) return;
|
|
587
|
+
o.record({
|
|
588
|
+
provenance: {
|
|
589
|
+
...o.provenance,
|
|
590
|
+
controlPath: `${o.provenance.controlPath} via ${via}`,
|
|
591
|
+
...(pid === undefined ? {} : { daemonPid: pid }),
|
|
592
|
+
},
|
|
593
|
+
via,
|
|
594
|
+
pid,
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
514
598
|
const rec = livingDaemon();
|
|
515
599
|
if (rec === undefined) {
|
|
516
600
|
// `livingDaemon` already cleared a stale file; this covers the unparseable
|
|
@@ -521,6 +605,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
521
605
|
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
522
606
|
}
|
|
523
607
|
if (decision.kind === "stop") {
|
|
608
|
+
recordStop("systemctl", decision.mainPid);
|
|
524
609
|
await runSystemdStop(decision.mainPid, o.timeoutMs);
|
|
525
610
|
clearRecord();
|
|
526
611
|
return { kind: "stopped", pid: decision.mainPid, via: "systemctl" };
|
|
@@ -536,6 +621,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
536
621
|
throw new Error(ownershipUnknown("stop", decision.reason));
|
|
537
622
|
}
|
|
538
623
|
if (decision.kind === "stop") {
|
|
624
|
+
recordStop("systemctl", rec.pid);
|
|
539
625
|
await runSystemdStop(rec.pid, o.timeoutMs);
|
|
540
626
|
clearRecord();
|
|
541
627
|
return { kind: "stopped", pid: rec.pid, via: "systemctl" };
|
|
@@ -544,6 +630,7 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
544
630
|
// decision.kind === "not-ours": confirmed no unit, inactive unit, no systemd
|
|
545
631
|
// binary, or a unit whose MainPID is somebody else. Only a *confirmed*
|
|
546
632
|
// negative is safe to signal.
|
|
633
|
+
recordStop("signal", rec.pid);
|
|
547
634
|
const gone = await terminate(rec.pid, o.timeoutMs ?? STOP_TIMEOUT_MS);
|
|
548
635
|
if (!gone) {
|
|
549
636
|
// The record stays: something is still holding that pid, and forgetting
|
|
@@ -577,8 +664,29 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
577
664
|
* Returns the record of the process that is now answering `/healthz`.
|
|
578
665
|
*/
|
|
579
666
|
export async function restartDaemon(
|
|
580
|
-
o: {
|
|
667
|
+
o: {
|
|
668
|
+
port?: number;
|
|
669
|
+
project?: string;
|
|
670
|
+
timeoutMs?: number;
|
|
671
|
+
/** Request facts and recorder, same semantics as {@link stopDaemon}: the
|
|
672
|
+
* durable row is written immediately before the restarting signal. */
|
|
673
|
+
provenance?: DaemonStopDraft;
|
|
674
|
+
record?: StopDeliveryFn;
|
|
675
|
+
} = {},
|
|
581
676
|
): Promise<RestartResult> {
|
|
677
|
+
const recordStop = (via: "systemctl" | "signal", pid: number | undefined): void => {
|
|
678
|
+
if (o.provenance === undefined || o.record === undefined) return;
|
|
679
|
+
o.record({
|
|
680
|
+
provenance: {
|
|
681
|
+
...o.provenance,
|
|
682
|
+
controlPath: `${o.provenance.controlPath} via ${via}`,
|
|
683
|
+
...(pid === undefined ? {} : { daemonPid: pid }),
|
|
684
|
+
},
|
|
685
|
+
via,
|
|
686
|
+
pid,
|
|
687
|
+
});
|
|
688
|
+
};
|
|
689
|
+
|
|
582
690
|
const previous = livingDaemon();
|
|
583
691
|
const ownership = probeUnit();
|
|
584
692
|
if (ownership.kind === "unknown") {
|
|
@@ -591,6 +699,7 @@ export async function restartDaemon(
|
|
|
591
699
|
// startDaemon() — that leaves the unit failed while handing the operator
|
|
592
700
|
// a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
|
|
593
701
|
// Any manager refusal is terminal; so is unproven ownership afterwards.
|
|
702
|
+
recordStop("systemctl", previous?.pid);
|
|
594
703
|
const record = await restoreFailedUnit(o.timeoutMs, o.project);
|
|
595
704
|
return { previous, record, via: "systemctl" };
|
|
596
705
|
}
|
|
@@ -602,6 +711,7 @@ export async function restartDaemon(
|
|
|
602
711
|
// Ownership is proven. A refused/timed-out restart must not fall through
|
|
603
712
|
// to stopDaemon's signal path — that is the exact bounce this module exists
|
|
604
713
|
// to prevent (SIGTERM → exit 143 → Restart=on-failure → new MainPID).
|
|
714
|
+
recordStop("systemctl", ownership.pid);
|
|
605
715
|
const ran = systemctl(["restart", SYSTEMD_UNIT]);
|
|
606
716
|
if (!ran.ok) {
|
|
607
717
|
throw new Error(systemctlFailure("restart", ran));
|
|
@@ -615,6 +725,7 @@ export async function restartDaemon(
|
|
|
615
725
|
|
|
616
726
|
// Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
|
|
617
727
|
// is somebody else. The detached CLI daemon is the only path left.
|
|
728
|
+
recordStop("signal", previous?.pid);
|
|
618
729
|
await stopDaemon({ timeoutMs: o.timeoutMs });
|
|
619
730
|
const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
|
|
620
731
|
return { previous, record, via: "cli" };
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared daemon logging and best-effort escalation delivery.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `daemon.ts` so the extracted admission module (and any future
|
|
5
|
+
* extracted concern) can log and escalate without importing the composition
|
|
6
|
+
* root back — which would be the circular-import failure mode the daemon split
|
|
7
|
+
* (#571) exists to rule out. `daemon.ts` composes; the leaf modules talk only
|
|
8
|
+
* to other leaves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { escalationIssueRef } from "./escalate.ts";
|
|
12
|
+
import type { Escalation } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/** One timestamped line to stderr — the daemon's canonical log sink. */
|
|
15
|
+
export function log(msg: string): void {
|
|
16
|
+
process.stderr.write(`[conductor ${new Date().toISOString()}] ${msg}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A stable one-line rendering of an unknown thrown value. */
|
|
20
|
+
export function errText(e: unknown): string {
|
|
21
|
+
return e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort escalation delivery: never let a failed page take down the
|
|
26
|
+
* pass, and say in the log that it failed. Structural `escalate` only — the
|
|
27
|
+
* caller's full dep object satisfies it without importing anything back.
|
|
28
|
+
*/
|
|
29
|
+
export async function safeEscalate(
|
|
30
|
+
d: { escalate(e: Escalation): Promise<void> },
|
|
31
|
+
e: Escalation,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
await d.escalate(e);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log(`escalation for ${escalationIssueRef(e.issue)} could not be delivered: ${errText(err)}`);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/model-fallback.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { FailureClass, RunRecord } from "./types.ts";
|
|
|
26
26
|
export const FAILOVER_CLASSES: readonly FailureClass[] = [
|
|
27
27
|
"provider-transient",
|
|
28
28
|
"provider-credit",
|
|
29
|
+
"provider-capacity",
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
/** The default for a project's `modelFallbackThreshold` when it is absent or
|
|
@@ -44,7 +45,7 @@ export interface ProviderFailureFacts {
|
|
|
44
45
|
/** How many terminal runs at the head of the chain failed as a provider. */
|
|
45
46
|
streak: number;
|
|
46
47
|
/** Those runs' classes, newest first — the set is either one class or a mix
|
|
47
|
-
* of `provider-transient` and `provider-
|
|
48
|
+
* of `provider-transient`, `provider-credit` and `provider-capacity`. */
|
|
48
49
|
classes: FailureClass[];
|
|
49
50
|
/**
|
|
50
51
|
* The model the most recent failure dispatched on, when its row recorded
|
|
@@ -97,7 +98,7 @@ export interface ModelChoice {
|
|
|
97
98
|
* returned unchanged and `fallback` stays false — today's dispatch byte for
|
|
98
99
|
* byte. Once the streak reaches the threshold the chain advances one slot per
|
|
99
100
|
* extra failure, clamped to the last model: the chain is exhausted there, and
|
|
100
|
-
* the existing escalation path (the provider-
|
|
101
|
+
* the existing escalation path (the provider-class strike cap) settles it,
|
|
101
102
|
* naming every model tried.
|
|
102
103
|
*/
|
|
103
104
|
export function resolveDispatchModel(args: {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet-owned omp settings overlay (#537).
|
|
3
|
+
*
|
|
4
|
+
* Workers inherit omp configuration from the daemon account's global
|
|
5
|
+
* `~/.omp/agent/config.yml` and nothing else. This module is the channel for
|
|
6
|
+
* saying "this project's workers run with *these* omp settings" without
|
|
7
|
+
* editing that fleet-wide file: a project's `ompSettings` map (an opaque
|
|
8
|
+
* settings tree the harness owns) is materialised to a YAML overlay *outside
|
|
9
|
+
* the worktree* — under the run's own session directory, alongside the
|
|
10
|
+
* transcript and the control socket — and threaded to the session through
|
|
11
|
+
* omp's own `Settings.init({ configFiles: [<path>] })` seam.
|
|
12
|
+
*
|
|
13
|
+
* Two properties make this channel distinct from the obvious alternative of
|
|
14
|
+
* writing `<worktree>/.omp/config.yml`:
|
|
15
|
+
*
|
|
16
|
+
* - The overlay is fleet-owned, never a file in the git checkout, so the
|
|
17
|
+
* diff a worker eventually ships is untouched and nothing needs an ignore
|
|
18
|
+
* entry. The worktree ignore block's standing rule — an ignored new file
|
|
19
|
+
* is invisible to salvage — is exactly why an in-tree staging was refused.
|
|
20
|
+
* - It is rewritten from config on every dispatch, so a config edit takes
|
|
21
|
+
* effect on the next attempt (and a resumed attempt reuses the kept
|
|
22
|
+
* session directory with the *current* config, not the one as of first
|
|
23
|
+
* provision).
|
|
24
|
+
*
|
|
25
|
+
* Conductor validates YAML shape only — omp owns the schema. An unknown key or
|
|
26
|
+
* a wrong-typed value is omp's to reject when it resolves the overlay, never
|
|
27
|
+
* conductor's to interpret.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { stringify } from "yaml";
|
|
33
|
+
import { stateDir } from "./config.ts";
|
|
34
|
+
import type { ProjectConfig } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
/** The overlay filename inside a run's session directory. */
|
|
37
|
+
export const OMP_SETTINGS_FILE = "omp-settings.yml";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The effective overlay map for a project: the opaque `ompSettings` map plus
|
|
41
|
+
* the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)
|
|
42
|
+
* derived from `modelFallbacks` — #539's staging half, moved onto this general
|
|
43
|
+
* channel so there is one way to stage settings into a worker session, not one
|
|
44
|
+
* seam per feature.
|
|
45
|
+
*
|
|
46
|
+
* `ompSettings.retry` is the operator's explicit word: when it is already a
|
|
47
|
+
* mapping, the derived retry block is skipped entirely (the overlay then says
|
|
48
|
+
* what it says, and the dispatch-failover chain #286 reads is unchanged — only
|
|
49
|
+
* omp's *retry-time* model swap respects the explicit stanza). Otherwise the
|
|
50
|
+
* derived block is merged in, preserving #539's behaviour exactly.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when nothing is staged — a project with neither an
|
|
53
|
+
* `ompSettings` map nor a model-fallback chain gets no overlay at all, so its
|
|
54
|
+
* dispatch is byte-for-byte today's.
|
|
55
|
+
*/
|
|
56
|
+
export function ompSettingsOverlay(
|
|
57
|
+
p: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
|
|
58
|
+
): Record<string, unknown> | undefined {
|
|
59
|
+
const map: Record<string, unknown> = { ...(p.ompSettings ?? {}) };
|
|
60
|
+
const chain = p.modelFallbacks;
|
|
61
|
+
const explicitRetry =
|
|
62
|
+
typeof map.retry === "object" && map.retry !== null && !Array.isArray(map.retry);
|
|
63
|
+
if (chain !== undefined && chain.length > 0 && !explicitRetry) {
|
|
64
|
+
map.retry = {
|
|
65
|
+
modelFallback: true,
|
|
66
|
+
fallbackChains: { default: [...chain] },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(map).length === 0 ? undefined : map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory every run's session dir lives under — where the overlay is
|
|
74
|
+
* materialised at dispatch. `doctor` probes its writability to report whether
|
|
75
|
+
* the next dispatch's overlay can actually land.
|
|
76
|
+
*/
|
|
77
|
+
export function sessionRootDir(): string {
|
|
78
|
+
return join(stateDir(), "sessions");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Materialises a project's effective overlay to `<sessionDir>/omp-settings.yml`
|
|
83
|
+
* and returns its absolute path, or `undefined` when nothing is staged.
|
|
84
|
+
*
|
|
85
|
+
* When nothing is staged, any overlay a previous attempt wrote is removed, so
|
|
86
|
+
* a project that dropped its map leaves no stale file in a kept session dir —
|
|
87
|
+
* the resume path reuses that directory, and an orphaned overlay with nobody
|
|
88
|
+
* reading it would be the exact silent fake this channel exists to avoid.
|
|
89
|
+
*
|
|
90
|
+
* Re-runs every dispatch, so a kept worktree picks up a config change on the
|
|
91
|
+
* next attempt rather than at the next worktree creation. A non-staged
|
|
92
|
+
* project's dispatch degrades to the harness discovering settings exactly as
|
|
93
|
+
* it always has.
|
|
94
|
+
*/
|
|
95
|
+
export function materializeOmpSettings(
|
|
96
|
+
project: Pick<ProjectConfig, "ompSettings" | "modelFallbacks">,
|
|
97
|
+
sessionDir: string,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
const path = join(sessionDir, OMP_SETTINGS_FILE);
|
|
100
|
+
const overlay = ompSettingsOverlay(project);
|
|
101
|
+
if (overlay === undefined) {
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(path);
|
|
104
|
+
} catch {
|
|
105
|
+
// Nothing written — a fresh session dir, or the absent case on first
|
|
106
|
+
// dispatch. Either way there is no stale overlay to remove.
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// 0600: the overlay can carry provider names and model selectors, and the
|
|
111
|
+
// session dir already holds the run's private channels.
|
|
112
|
+
writeFileSync(path, stringify(overlay), { mode: 0o600 });
|
|
113
|
+
return path;
|
|
114
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -44,6 +44,14 @@ export interface AgentSessionLike {
|
|
|
44
44
|
* `"*"` for every event. The payload is the harness's own event union, which
|
|
45
45
|
* this package cannot name without the peer dependency, so it arrives as
|
|
46
46
|
* `unknown` and each caller narrows the two or three fields it reads.
|
|
47
|
+
*
|
|
48
|
+
* One event is not the harness's at all: `"session_exit"` fires when the
|
|
49
|
+
* underlying session *process* has terminated, carrying
|
|
50
|
+
* `{ type: "session_exit", code?: number | null }`. It is the single real
|
|
51
|
+
* terminal signal — a crashed or killed session is never confused with one
|
|
52
|
+
* that merely stopped streaming (`"agent_end"`). A supervisor may receive it
|
|
53
|
+
* after its own `dispose()`, and must decide what an exit means from its own
|
|
54
|
+
* state rather than assuming every exit is a crash.
|
|
47
55
|
*/
|
|
48
56
|
on(event: string, cb: (e: unknown) => void): void;
|
|
49
57
|
abort(): void;
|
|
@@ -160,6 +168,17 @@ export async function createLocalSession(opts: {
|
|
|
160
168
|
releaseGrants?: ResolvedGrants;
|
|
161
169
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
162
170
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
171
|
+
/**
|
|
172
|
+
* The fleet-owned omp settings overlay staged into this session's omp
|
|
173
|
+
* settings (#537): an absolute path to a YAML overlay the daemon materialised
|
|
174
|
+
* from the project's `ompSettings` map (plus the retry keys derived from
|
|
175
|
+
* `modelFallbacks`), passed to `Settings.init({ configFiles: [<path>] })` so
|
|
176
|
+
* it layers on top of the ordinary global/project discovery — never an
|
|
177
|
+
* isolated instance, which would drop providers and approvals out from under
|
|
178
|
+
* the worker. Absent, no settings are passed and the harness discovers
|
|
179
|
+
* exactly as it does today.
|
|
180
|
+
*/
|
|
181
|
+
ompSettingsFile?: string;
|
|
163
182
|
/**
|
|
164
183
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
165
184
|
*
|
|
@@ -235,11 +254,31 @@ export async function createLocalSession(opts: {
|
|
|
235
254
|
conductorVerbs(opts.verbSocketPath),
|
|
236
255
|
];
|
|
237
256
|
|
|
257
|
+
// The fleet-owned omp settings overlay (#537): when the daemon staged one,
|
|
258
|
+
// layer it into a Settings instance over the ordinary global/project
|
|
259
|
+
// discovery. `Settings.init({ cwd, configFiles })` keeps the daemon account's
|
|
260
|
+
// global config (providers, approvals, modelRoles) and any project
|
|
261
|
+
// `.omp/config.yml` intact — the overlay is deep-merged *after* them — and
|
|
262
|
+
// never `Settings.isolated()`, which would build from the overlay alone and
|
|
263
|
+
// drop everything out from under the worker. Absent a staged overlay, no
|
|
264
|
+
// settings are passed and the harness discovers exactly as it does today.
|
|
265
|
+
let overlaySettings: unknown = undefined;
|
|
266
|
+
if (opts.ompSettingsFile !== undefined) {
|
|
267
|
+
const Settings = Reflect.get(namespace, "Settings");
|
|
268
|
+
if (typeof Settings === "function" && typeof Settings.init === "function") {
|
|
269
|
+
overlaySettings = await Settings.init({
|
|
270
|
+
cwd: opts.cwd,
|
|
271
|
+
configFiles: [opts.ompSettingsFile],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
238
276
|
const created = await mod.createAgentSession({
|
|
239
277
|
cwd: opts.cwd,
|
|
240
278
|
// A raw pattern rather than a resolved Model: the harness resolves it
|
|
241
279
|
// after extensions load, so we never have to import its model registry.
|
|
242
280
|
...(opts.model === undefined ? {} : { modelPattern: opts.model }),
|
|
281
|
+
...(overlaySettings === undefined ? {} : { settings: overlaySettings }),
|
|
243
282
|
sessionManager,
|
|
244
283
|
// A private registry per session, never the process-global default: that
|
|
245
284
|
// one admits only one "Main" identity per generation, so a second session
|
|
@@ -416,6 +455,13 @@ export interface CreateSessionOptions {
|
|
|
416
455
|
role: SessionRole;
|
|
417
456
|
releaseGrants?: ResolvedGrants;
|
|
418
457
|
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
458
|
+
/**
|
|
459
|
+
* Absolute path to the fleet-owned omp settings overlay (#537), forwarded to
|
|
460
|
+
* the child through the host spec so the far side's `createLocalSession`
|
|
461
|
+
* loads it via `Settings.init({ configFiles: [<path>] })`. Absent, nothing
|
|
462
|
+
* is staged and the session discovers settings as it does today.
|
|
463
|
+
*/
|
|
464
|
+
ompSettingsFile?: string;
|
|
419
465
|
|
|
420
466
|
/**
|
|
421
467
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -554,6 +600,7 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
554
600
|
...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
|
|
555
601
|
...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
|
|
556
602
|
...(opts.readOnly === undefined ? {} : { readOnly: opts.readOnly }),
|
|
603
|
+
...(opts.ompSettingsFile === undefined ? {} : { ompSettingsFile: opts.ompSettingsFile }),
|
|
557
604
|
};
|
|
558
605
|
|
|
559
606
|
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
@@ -714,8 +761,24 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
714
761
|
pending.clear();
|
|
715
762
|
};
|
|
716
763
|
|
|
764
|
+
// The one terminal event this proxy owns outright: the session process is
|
|
765
|
+
// gone, whether by crash, kill, or a clean dispose of its own. Delivered to
|
|
766
|
+
// subscribers *before* any failure is surfaced, so a supervisor learning of
|
|
767
|
+
// the death can also read its exit code off the same event. `disposing` is
|
|
768
|
+
// the proxy's own intent (its disposer asked the child to stop), not the
|
|
769
|
+
// subscriber's, so the event is emitted in both branches and each observer
|
|
770
|
+
// decides what an exit means from its own state.
|
|
771
|
+
const emitSessionExit = (code: number | null): void => {
|
|
772
|
+
const type = "session_exit";
|
|
773
|
+
const event = { type, code };
|
|
774
|
+
for (const cb of handlers.get(type) ?? []) cb(event);
|
|
775
|
+
for (const cb of handlers.get("*") ?? []) cb(event);
|
|
776
|
+
};
|
|
777
|
+
|
|
717
778
|
void child.exited.then(async (code) => {
|
|
718
779
|
onExit();
|
|
780
|
+
const exitCode = typeof code === "number" ? code : null;
|
|
781
|
+
emitSessionExit(exitCode);
|
|
719
782
|
// A child that cannot start writes `start-error` over the socket, not the
|
|
720
783
|
// pipes — and its exit can be dispatched before the accept of a connection
|
|
721
784
|
// that already completed in the kernel. Let the socket settle before the
|