omp-conductor 0.3.25 → 0.4.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/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/omp.ts
CHANGED
|
@@ -13,9 +13,29 @@
|
|
|
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 {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
16
|
+
import { chmodSync, chownSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { createServer, type Server, type Socket } from "node:net";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
orchestratorConfinement,
|
|
23
|
+
orchestratorJailFromConfig,
|
|
24
|
+
worktreeConfinement,
|
|
25
|
+
type OrchestratorJail,
|
|
26
|
+
type OrchestratorRefusal,
|
|
27
|
+
} from "./confinement.ts";
|
|
28
|
+
import { recordConfinementRefusal } from "./confinement.ts";
|
|
29
|
+
import type { SessionBoundary } from "./credentials.ts";
|
|
30
|
+
import { releasePolicyTripwire } from "./release-policy.ts";
|
|
31
|
+
import type {
|
|
32
|
+
HostToParent,
|
|
33
|
+
ParentToHost,
|
|
34
|
+
SessionHostSpec,
|
|
35
|
+
} from "./session-host.ts";
|
|
36
|
+
import { decodeFrames, encodeFrame } from "./session-host.ts";
|
|
37
|
+
import type { ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
|
|
38
|
+
import { conductorVerbs } from "./verbs/client.ts";
|
|
19
39
|
|
|
20
40
|
const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
21
41
|
|
|
@@ -96,8 +116,15 @@ interface RawSession {
|
|
|
96
116
|
const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
|
|
97
117
|
|
|
98
118
|
/**
|
|
99
|
-
* Start one omp coding session rooted at `cwd`, with a
|
|
100
|
-
* and a file-backed transcript of its own.
|
|
119
|
+
* Start one omp coding session **in this process**, rooted at `cwd`, with a
|
|
120
|
+
* private agent registry and a file-backed transcript of its own.
|
|
121
|
+
*
|
|
122
|
+
* This is the far side of the boundary, not the dispatcher's entry point.
|
|
123
|
+
* Production callers want {@link createSession}, which runs this same function
|
|
124
|
+
* inside a child process under the run's own OS principal (#125); the only
|
|
125
|
+
* caller of this one is `session-host.ts`, plus the tests that pin what it
|
|
126
|
+
* builds. Calling it directly from the daemon puts the session back in the
|
|
127
|
+
* credential-holding process, which is the thing #125 exists to stop.
|
|
101
128
|
*
|
|
102
129
|
* `sessionDir` chooses the directory the harness writes that transcript into;
|
|
103
130
|
* omitted, the harness picks its default location for `cwd`. Either way the
|
|
@@ -113,21 +140,47 @@ const disposers = new WeakMap<AgentSessionLike, () => Promise<void>>();
|
|
|
113
140
|
* deployment mistake, and a stack trace about a failed dynamic import sends the
|
|
114
141
|
* reader looking in the wrong place.
|
|
115
142
|
*/
|
|
116
|
-
export async function
|
|
143
|
+
export async function createLocalSession(opts: {
|
|
117
144
|
cwd: string;
|
|
118
145
|
sessionDir?: string;
|
|
119
146
|
model?: string;
|
|
120
147
|
resume?: boolean;
|
|
121
148
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
149
|
+
* What kind of session this is. Explicit rather than derived from the caller:
|
|
150
|
+
* it selects the worktree confinement gate *and* is the role the release gate
|
|
151
|
+
* compares grants against, and those two must never be able to disagree about
|
|
152
|
+
* whether a session is a worker (#122).
|
|
125
153
|
*/
|
|
126
|
-
|
|
127
|
-
/** Install the release/deploy tool-call gate
|
|
128
|
-
|
|
154
|
+
role: SessionRole;
|
|
155
|
+
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
156
|
+
releaseGrants?: ResolvedGrants;
|
|
129
157
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
130
158
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
159
|
+
/**
|
|
160
|
+
* The allowlist an orchestrator session is held to (#127). Omitted, it is
|
|
161
|
+
* derived from the config on disk — which is the only honest default, since
|
|
162
|
+
* a jail that silently resolved to "no jail" would leave the roots this gate
|
|
163
|
+
* exists to close wide open on the day a caller forgot the option.
|
|
164
|
+
*/
|
|
165
|
+
orchestratorJail?: OrchestratorJail;
|
|
166
|
+
/**
|
|
167
|
+
* Where an orchestrator confinement refusal is recorded. Defaults to the
|
|
168
|
+
* durable audit in the daemon's state directory — which is exactly why it is
|
|
169
|
+
* injectable: under `uid-pool` this function runs as a principal that cannot
|
|
170
|
+
* write that directory, so {@link runSessionHost} substitutes a forwarder and
|
|
171
|
+
* the *parent* performs the write. A refusal the operator never learns about
|
|
172
|
+
* is indistinguishable from a session that behaved (#127).
|
|
173
|
+
*/
|
|
174
|
+
onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
|
|
175
|
+
/**
|
|
176
|
+
* The conductor verb socket this session's mutation tools call (#126).
|
|
177
|
+
*
|
|
178
|
+
* The tools are registered either way. Omitted, every one of them fails
|
|
179
|
+
* closed with an explanation, which is the correct behaviour for a session
|
|
180
|
+
* whose daemon never gave it a channel — a tool that quietly vanished would
|
|
181
|
+
* leave the model to improvise `git push` instead.
|
|
182
|
+
*/
|
|
183
|
+
verbSocketPath?: string;
|
|
131
184
|
}): Promise<AgentSessionLike> {
|
|
132
185
|
let loaded: unknown;
|
|
133
186
|
try {
|
|
@@ -165,10 +218,27 @@ export async function createSession(opts: {
|
|
|
165
218
|
// transcript is the only record of what the worker actually did.
|
|
166
219
|
const sessionManager = await openSessionManager(mod, opts);
|
|
167
220
|
const extensions = [
|
|
168
|
-
|
|
169
|
-
|
|
221
|
+
// Two shapes, because the two sessions need different things: a worker is
|
|
222
|
+
// jailed to its checkout, while the orchestrator has to read the state
|
|
223
|
+
// directory and its briefs and is jailed to an allowlist instead (#127).
|
|
224
|
+
...(opts.role === "worker"
|
|
225
|
+
? [worktreeConfinement(opts.cwd)]
|
|
226
|
+
: [
|
|
227
|
+
orchestratorConfinement(
|
|
228
|
+
opts.orchestratorJail ?? orchestratorJailFromConfig(),
|
|
229
|
+
opts.cwd,
|
|
230
|
+
opts.onConfinementRefusal,
|
|
231
|
+
),
|
|
232
|
+
]),
|
|
233
|
+
...(opts.releaseGrants === undefined
|
|
170
234
|
? []
|
|
171
|
-
: [releasePolicyTripwire(opts.
|
|
235
|
+
: [releasePolicyTripwire(opts.releaseGrants, opts.role, opts.onReleaseBlocked)]),
|
|
236
|
+
// The sanctioned way back to GitHub (#126). A thin client: it forwards
|
|
237
|
+
// arguments over the run's own socket and renders what the daemon decided.
|
|
238
|
+
// Registered unconditionally, including on a session with no socket, so a
|
|
239
|
+
// missing channel is a named refusal rather than an absent tool the model
|
|
240
|
+
// routes around with bash.
|
|
241
|
+
conductorVerbs(opts.verbSocketPath),
|
|
172
242
|
];
|
|
173
243
|
|
|
174
244
|
const created = await mod.createAgentSession({
|
|
@@ -299,3 +369,389 @@ function asRawSession(created: unknown): RawSession {
|
|
|
299
369
|
}
|
|
300
370
|
return candidate;
|
|
301
371
|
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
// --------------------------------------------------------- the session proxy
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Absolute path of the child entrypoint, resolved against this package.
|
|
378
|
+
*
|
|
379
|
+
* Exported so the boundary suite can assert a slot principal may actually read
|
|
380
|
+
* it. The launcher execs this path under the slot uid, and on a real deployment
|
|
381
|
+
* it sits under the daemon's home — so a home hardened to `0700` makes every
|
|
382
|
+
* worker die before it connects, in a way no shell-based probe notices (#125).
|
|
383
|
+
*/
|
|
384
|
+
export const SESSION_HOST = join(import.meta.dir, "session-host.ts");
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* How long the parent waits for a child to connect and report `ready`.
|
|
388
|
+
*
|
|
389
|
+
* Generous on purpose: the child loads the harness, discovers MCP servers and
|
|
390
|
+
* opens a transcript before it can answer, and on a cold cache that is seconds
|
|
391
|
+
* rather than milliseconds. The timeout is not a performance guard, it is the
|
|
392
|
+
* difference between "the launcher was rejected" surfacing as a named error and
|
|
393
|
+
* surfacing as a dispatch that never returns.
|
|
394
|
+
*/
|
|
395
|
+
const START_TIMEOUT_MS = 180_000;
|
|
396
|
+
|
|
397
|
+
/** Bounded tail of the child's stderr, so a crash is legible without unbounded buffering. */
|
|
398
|
+
const STDERR_TAIL = 8_000;
|
|
399
|
+
|
|
400
|
+
/** How long a disposed child gets to exit before it is signalled. */
|
|
401
|
+
const DISPOSE_GRACE_MS = 5_000;
|
|
402
|
+
|
|
403
|
+
export interface CreateSessionOptions {
|
|
404
|
+
cwd: string;
|
|
405
|
+
sessionDir?: string;
|
|
406
|
+
model?: string;
|
|
407
|
+
resume?: boolean;
|
|
408
|
+
role: SessionRole;
|
|
409
|
+
releaseGrants?: ResolvedGrants;
|
|
410
|
+
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
411
|
+
orchestratorJail?: OrchestratorJail;
|
|
412
|
+
onConfinementRefusal?: (refusal: OrchestratorRefusal) => void;
|
|
413
|
+
/**
|
|
414
|
+
* The OS principal and environment this session runs behind (#125). Omitted,
|
|
415
|
+
* the child runs as the daemon's own user with the daemon's environment —
|
|
416
|
+
* which is the A1 shape: out of process, no security claim. The daemon always
|
|
417
|
+
* passes one, and `status` reports which mechanism it names.
|
|
418
|
+
*/
|
|
419
|
+
boundary?: SessionBoundary;
|
|
420
|
+
/**
|
|
421
|
+
* Where the control socket is bound. The daemon puts it inside the run's own
|
|
422
|
+
* boundary root so the slot principal can reach it; omitted, a private temp
|
|
423
|
+
* directory is used, which is what the tests and a `none` fleet want.
|
|
424
|
+
*/
|
|
425
|
+
socketPath?: string;
|
|
426
|
+
/**
|
|
427
|
+
* The conductor verb socket this session's mutation tools call (#126).
|
|
428
|
+
*
|
|
429
|
+
* A different socket from `socketPath` above, and deliberately so: that one
|
|
430
|
+
* is the parent's own control channel to the child, this one is the child's
|
|
431
|
+
* only route back to a mutation, and collapsing them would make the daemon's
|
|
432
|
+
* session plumbing an attack surface for verbs.
|
|
433
|
+
*/
|
|
434
|
+
verbSocketPath?: string;
|
|
435
|
+
/** Child stderr, line by line. Defaults to the process's own stderr. */
|
|
436
|
+
onChildLog?: (line: string) => void;
|
|
437
|
+
startupTimeoutMs?: number;
|
|
438
|
+
/**
|
|
439
|
+
* Test seam, and only that — the same shape as
|
|
440
|
+
* `OrchestratorOpts.createSessionImpl`. Production always wants
|
|
441
|
+
* `session-host.ts`; the parity suite points this at a child that calls
|
|
442
|
+
* {@link runSessionHost} with a hand-written session, so the proxy, the
|
|
443
|
+
* socket, the framing and the process lifecycle are all exercised for real
|
|
444
|
+
* without a live harness or a model bill.
|
|
445
|
+
*/
|
|
446
|
+
hostModule?: string;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Start one omp coding session in a **child process** and return a proxy for it.
|
|
451
|
+
*
|
|
452
|
+
* The proxy is the whole of `omp-conductor`'s view of a session, and it is
|
|
453
|
+
* deliberately thin: {@link AgentSessionLike} has five members, so there are
|
|
454
|
+
* five things to forward and nothing else belongs here. `prompt` and `abort`
|
|
455
|
+
* go out over a unix socket, harness events come back and are re-emitted to the
|
|
456
|
+
* same `on()` subscribers the in-process version served, and `sessionFile` is
|
|
457
|
+
* whatever path the child reports the session actually opened — never one this
|
|
458
|
+
* side invented.
|
|
459
|
+
*
|
|
460
|
+
* Two behaviours that were free in-process and are bought explicitly here:
|
|
461
|
+
*
|
|
462
|
+
* - **A child outliving its parent.** The session used to die with the daemon
|
|
463
|
+
* because it *was* the daemon. Now the child watches its socket and exits
|
|
464
|
+
* when the parent goes away, so a daemon crash still leaves no orphaned
|
|
465
|
+
* session holding a checkout and a model budget.
|
|
466
|
+
* - **A wedged harness.** An in-process `abort()` that the harness ignored hung
|
|
467
|
+
* the daemon. Disposal here escalates: dispose message, then `SIGTERM`, then
|
|
468
|
+
* `SIGKILL`. The cap-kill path therefore always terminates.
|
|
469
|
+
*
|
|
470
|
+
* @throws if the child cannot be launched or the session cannot be built,
|
|
471
|
+
* carrying the child's own message — a missing peer dependency still reads as
|
|
472
|
+
* the deployment mistake it is, not as an unexplained exit code.
|
|
473
|
+
*/
|
|
474
|
+
export async function createSession(opts: CreateSessionOptions): Promise<AgentSessionLike> {
|
|
475
|
+
const boundary = opts.boundary;
|
|
476
|
+
const owned = opts.socketPath === undefined;
|
|
477
|
+
// 0711, not 0700: a slot principal has to *traverse* to its own socket, and
|
|
478
|
+
// must not be able to list what else is in here or unlink the socket and
|
|
479
|
+
// bind its own. Same reasoning as the run workspace parent.
|
|
480
|
+
const socketDir = owned ? mkdtempSync(join(tmpdir(), "omp-session-")) : dirname(opts.socketPath ?? "");
|
|
481
|
+
const socketPath = opts.socketPath ?? join(socketDir, "s");
|
|
482
|
+
mkdirSync(socketDir, { recursive: true, mode: 0o711 });
|
|
483
|
+
chmodSync(socketDir, 0o711);
|
|
484
|
+
rmSync(socketPath, { force: true });
|
|
485
|
+
|
|
486
|
+
const server = createServer();
|
|
487
|
+
const { promise: attached, resolve: onAttach, reject: onAttachFail } =
|
|
488
|
+
Promise.withResolvers<Socket>();
|
|
489
|
+
server.once("connection", (socket: Socket) => {
|
|
490
|
+
socket.setNoDelay(true);
|
|
491
|
+
onAttach(socket);
|
|
492
|
+
});
|
|
493
|
+
server.once("error", (err: Error) => {
|
|
494
|
+
onAttachFail(err);
|
|
495
|
+
});
|
|
496
|
+
await new Promise<void>((resolve, reject) => {
|
|
497
|
+
server.once("error", reject);
|
|
498
|
+
server.listen(socketPath, () => {
|
|
499
|
+
resolve();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
// The socket is the run's own channel: only its principal may speak on it.
|
|
503
|
+
chmodSync(socketPath, 0o600);
|
|
504
|
+
if (boundary?.principal !== undefined) {
|
|
505
|
+
chownSync(socketPath, boundary.principal.uid, boundary.principal.gid);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const spec: SessionHostSpec = {
|
|
509
|
+
socket: socketPath,
|
|
510
|
+
cwd: opts.cwd,
|
|
511
|
+
role: opts.role,
|
|
512
|
+
...(opts.sessionDir === undefined ? {} : { sessionDir: opts.sessionDir }),
|
|
513
|
+
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
514
|
+
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
515
|
+
...(opts.releaseGrants === undefined ? {} : { releaseGrants: opts.releaseGrants }),
|
|
516
|
+
// Resolved here rather than in the child: the child may not be able to read
|
|
517
|
+
// the config the jail is derived from, and a jail that fell back to
|
|
518
|
+
// defaults would silently widen the gate #127 closed.
|
|
519
|
+
...(opts.role === "orchestrator"
|
|
520
|
+
? { orchestratorJail: opts.orchestratorJail ?? orchestratorJailFromConfig() }
|
|
521
|
+
: {}),
|
|
522
|
+
...(opts.verbSocketPath === undefined ? {} : { verbSocketPath: opts.verbSocketPath }),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const log = opts.onChildLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
526
|
+
const argv = [
|
|
527
|
+
...(boundary?.launcher ?? []),
|
|
528
|
+
process.execPath,
|
|
529
|
+
opts.hostModule ?? SESSION_HOST,
|
|
530
|
+
JSON.stringify(spec),
|
|
531
|
+
];
|
|
532
|
+
const child = Bun.spawn(argv, {
|
|
533
|
+
cwd: opts.cwd,
|
|
534
|
+
stdin: "ignore",
|
|
535
|
+
// The harness writes progress to stdout; both streams are the daemon's log,
|
|
536
|
+
// never the protocol. The protocol has its own socket precisely so a chatty
|
|
537
|
+
// harness build cannot corrupt it.
|
|
538
|
+
stdout: "pipe",
|
|
539
|
+
stderr: "pipe",
|
|
540
|
+
...(boundary === undefined || Object.keys(boundary.env).length === 0 ? {} : { env: boundary.env }),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
let stderrTail = "";
|
|
544
|
+
const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
|
|
545
|
+
if (stream === undefined) return;
|
|
546
|
+
const decoder = new TextDecoder();
|
|
547
|
+
let rest = "";
|
|
548
|
+
for await (const chunk of stream) {
|
|
549
|
+
rest += decoder.decode(chunk, { stream: true });
|
|
550
|
+
const lines = rest.split("\n");
|
|
551
|
+
rest = lines.pop() ?? "";
|
|
552
|
+
for (const line of lines) {
|
|
553
|
+
if (line === "") continue;
|
|
554
|
+
stderrTail = `${stderrTail}${line}\n`.slice(-STDERR_TAIL);
|
|
555
|
+
log(`${prefix}${line}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
void drain(child.stdout, "session: ");
|
|
560
|
+
void drain(child.stderr, "session: ");
|
|
561
|
+
|
|
562
|
+
const cleanup = (): void => {
|
|
563
|
+
server.close();
|
|
564
|
+
rmSync(socketPath, { force: true });
|
|
565
|
+
if (owned) rmSync(socketDir, { recursive: true, force: true });
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const handlers = new Map<string, ((e: unknown) => void)[]>();
|
|
569
|
+
const pending = new Map<number, { resolve: () => void; reject: (err: Error) => void }>();
|
|
570
|
+
let sessionFile: string | undefined;
|
|
571
|
+
let modelFallbackMessage: string | undefined;
|
|
572
|
+
let promptSeq = 0;
|
|
573
|
+
|
|
574
|
+
const { promise: ready, resolve: onReady, reject: onReadyFail } = Promise.withResolvers<void>();
|
|
575
|
+
// Parked immediately. `ready` is rejected from the child's exit handler,
|
|
576
|
+
// which can fire before anything awaits it — and an unhandled rejection in a
|
|
577
|
+
// daemon that supervises sessions is a process-level crash, not a failed run.
|
|
578
|
+
// The real awaiters below still see the rejection.
|
|
579
|
+
ready.catch(() => undefined);
|
|
580
|
+
const { promise: exited, resolve: onExit } = Promise.withResolvers<void>();
|
|
581
|
+
/** Set once teardown has begun, so a clean exit is not reported as a crash. */
|
|
582
|
+
let disposing = false;
|
|
583
|
+
|
|
584
|
+
const fail = (message: string): void => {
|
|
585
|
+
const tail = stderrTail.trim();
|
|
586
|
+
const err = new Error(tail === "" ? message : `${message}\nchild output:\n${tail}`);
|
|
587
|
+
onReadyFail(err);
|
|
588
|
+
for (const [, waiter] of pending) waiter.reject(err);
|
|
589
|
+
pending.clear();
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
void child.exited.then((code) => {
|
|
593
|
+
onExit();
|
|
594
|
+
cleanup();
|
|
595
|
+
// A child that exits during teardown exited because we asked it to. Only an
|
|
596
|
+
// exit while the session is supposed to be live is a failure — and it has
|
|
597
|
+
// to reach whoever is awaiting a prompt, or the dispatcher waits forever
|
|
598
|
+
// for a turn from a process that is gone.
|
|
599
|
+
if (disposing) return;
|
|
600
|
+
fail(`omp-conductor session child exited ${String(code)} before the session ended`);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
let socket: Socket;
|
|
604
|
+
try {
|
|
605
|
+
socket = await Promise.race([
|
|
606
|
+
attached,
|
|
607
|
+
new Promise<never>((_resolve, reject) => {
|
|
608
|
+
setTimeout(() => {
|
|
609
|
+
reject(
|
|
610
|
+
new Error(
|
|
611
|
+
`omp-conductor session child did not connect within ${String(
|
|
612
|
+
(opts.startupTimeoutMs ?? START_TIMEOUT_MS) / 1000,
|
|
613
|
+
)}s (launcher: ${argv.slice(0, Math.max(1, (boundary?.launcher ?? []).length)).join(" ") || "none"})`,
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
}, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
|
|
617
|
+
}),
|
|
618
|
+
child.exited.then((code): never => {
|
|
619
|
+
throw new Error(`omp-conductor session child exited ${String(code)} before connecting`);
|
|
620
|
+
}),
|
|
621
|
+
]);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
child.kill("SIGKILL");
|
|
624
|
+
cleanup();
|
|
625
|
+
const tail = stderrTail.trim();
|
|
626
|
+
throw new Error(
|
|
627
|
+
`${err instanceof Error ? err.message : String(err)}${tail === "" ? "" : `\nchild output:\n${tail}`}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
let buffer = "";
|
|
632
|
+
socket.on("data", (chunk: Buffer) => {
|
|
633
|
+
buffer += chunk.toString("utf8");
|
|
634
|
+
const { frames, rest } = decodeFrames(buffer);
|
|
635
|
+
buffer = rest;
|
|
636
|
+
for (const frame of frames) {
|
|
637
|
+
const message = frame as HostToParent;
|
|
638
|
+
switch (message.t) {
|
|
639
|
+
case "ready":
|
|
640
|
+
sessionFile = message.sessionFile;
|
|
641
|
+
modelFallbackMessage = message.modelFallbackMessage;
|
|
642
|
+
onReady();
|
|
643
|
+
break;
|
|
644
|
+
case "start-error":
|
|
645
|
+
fail(message.message);
|
|
646
|
+
break;
|
|
647
|
+
case "session-file":
|
|
648
|
+
sessionFile = message.path;
|
|
649
|
+
break;
|
|
650
|
+
case "event": {
|
|
651
|
+
const type = (message.event as { type?: unknown } | null | undefined)?.type;
|
|
652
|
+
if (typeof type !== "string") break;
|
|
653
|
+
for (const cb of handlers.get(type) ?? []) cb(message.event);
|
|
654
|
+
for (const cb of handlers.get("*") ?? []) cb(message.event);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case "prompt-result": {
|
|
658
|
+
const waiter = pending.get(message.id);
|
|
659
|
+
if (waiter === undefined) break;
|
|
660
|
+
pending.delete(message.id);
|
|
661
|
+
if (message.ok) waiter.resolve();
|
|
662
|
+
else waiter.reject(new Error(message.error ?? "session prompt failed"));
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
case "release-blocked":
|
|
666
|
+
opts.onReleaseBlocked?.(message.shape);
|
|
667
|
+
break;
|
|
668
|
+
case "confinement-refusal":
|
|
669
|
+
// Performed on this side because the child may not be able to write
|
|
670
|
+
// the state directory the audit lives in. Wrapped for the same reason
|
|
671
|
+
// the in-process version is: the audit is evidence, not the gate.
|
|
672
|
+
try {
|
|
673
|
+
(opts.onConfinementRefusal ?? recordConfinementRefusal)(message.refusal);
|
|
674
|
+
} catch {
|
|
675
|
+
// A full disk must not turn a deny into an allow.
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const write = (message: ParentToHost): void => {
|
|
683
|
+
if (!socket.writableEnded) socket.write(encodeFrame(message));
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
await Promise.race([
|
|
687
|
+
ready,
|
|
688
|
+
new Promise<never>((_resolve, reject) => {
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
reject(new Error("omp-conductor session child connected but never reported a session"));
|
|
691
|
+
}, opts.startupTimeoutMs ?? START_TIMEOUT_MS).unref?.();
|
|
692
|
+
}),
|
|
693
|
+
]).catch((err: unknown) => {
|
|
694
|
+
child.kill("SIGKILL");
|
|
695
|
+
cleanup();
|
|
696
|
+
throw err;
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const session: AgentSessionLike = {
|
|
700
|
+
prompt(text, promptOpts) {
|
|
701
|
+
promptSeq += 1;
|
|
702
|
+
const id = promptSeq;
|
|
703
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
704
|
+
pending.set(id, { resolve, reject });
|
|
705
|
+
write({ t: "prompt", id, text, ...(promptOpts === undefined ? {} : { opts: promptOpts }) });
|
|
706
|
+
// Resolves to `undefined`: no caller in this package reads a prompt's
|
|
707
|
+
// return value, and shipping the harness's own object across the wire
|
|
708
|
+
// would make this seam depend on a shape it deliberately does not name.
|
|
709
|
+
return promise.then(() => undefined);
|
|
710
|
+
},
|
|
711
|
+
on(event, cb) {
|
|
712
|
+
const list = handlers.get(event);
|
|
713
|
+
if (list) list.push(cb);
|
|
714
|
+
else handlers.set(event, [cb]);
|
|
715
|
+
},
|
|
716
|
+
abort() {
|
|
717
|
+
write({ t: "abort" });
|
|
718
|
+
},
|
|
719
|
+
get sessionFile() {
|
|
720
|
+
return sessionFile;
|
|
721
|
+
},
|
|
722
|
+
get modelFallbackMessage() {
|
|
723
|
+
return modelFallbackMessage;
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
disposers.set(session, async () => {
|
|
728
|
+
disposing = true;
|
|
729
|
+
write({ t: "dispose" });
|
|
730
|
+
socket.end();
|
|
731
|
+
const settled = await Promise.race([
|
|
732
|
+
exited.then(() => true),
|
|
733
|
+
new Promise<false>((resolve) => {
|
|
734
|
+
setTimeout(() => {
|
|
735
|
+
resolve(false);
|
|
736
|
+
}, DISPOSE_GRACE_MS).unref?.();
|
|
737
|
+
}),
|
|
738
|
+
]);
|
|
739
|
+
if (!settled) {
|
|
740
|
+
// The escalation that makes a cap-kill final. In-process, a harness that
|
|
741
|
+
// ignored `abort()` hung the daemon; here it costs one signal.
|
|
742
|
+
child.kill("SIGTERM");
|
|
743
|
+
const stopped = await Promise.race([
|
|
744
|
+
exited.then(() => true),
|
|
745
|
+
new Promise<false>((resolve) => {
|
|
746
|
+
setTimeout(() => {
|
|
747
|
+
resolve(false);
|
|
748
|
+
}, DISPOSE_GRACE_MS).unref?.();
|
|
749
|
+
}),
|
|
750
|
+
]);
|
|
751
|
+
if (!stopped) child.kill("SIGKILL");
|
|
752
|
+
}
|
|
753
|
+
cleanup();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
return session;
|
|
757
|
+
}
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
import { spawnSync } from "node:child_process";
|
|
48
48
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
49
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
50
|
-
import { findProject, loadConfig,
|
|
50
|
+
import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
|
|
51
51
|
import {
|
|
52
52
|
bridgeTokenBound,
|
|
53
53
|
hasBotToken,
|
|
@@ -61,11 +61,19 @@ import {
|
|
|
61
61
|
} from "./setup.ts";
|
|
62
62
|
import {
|
|
63
63
|
recordReleaseBlock,
|
|
64
|
-
releaseDecision,
|
|
65
64
|
releaseDriftDigestLine,
|
|
65
|
+
releaseRefusal,
|
|
66
|
+
releaseShapeFromTool,
|
|
66
67
|
type ReleaseDecision,
|
|
67
68
|
} from "./release-policy.ts";
|
|
68
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
DEFAULT_REPORT_SCOPE,
|
|
71
|
+
DENIED_RELEASE_GRANTS,
|
|
72
|
+
type FrictionSignal,
|
|
73
|
+
type ReportScope,
|
|
74
|
+
type ResolvedGrants,
|
|
75
|
+
type Store,
|
|
76
|
+
} from "./types.ts";
|
|
69
77
|
import { dbPath, openStore } from "./store.ts";
|
|
70
78
|
|
|
71
79
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
@@ -339,12 +347,21 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
|
|
|
339
347
|
* so it is never such a turn, and a session that believes otherwise reports
|
|
340
348
|
* into a void: on 2026-08-06 the fleet this extension runs produced a release
|
|
341
349
|
* report and two tier-2 escalations as end-of-turn text, and not one of the
|
|
342
|
-
* three reached anybody.
|
|
343
|
-
*
|
|
344
|
-
*
|
|
350
|
+
* three reached anybody.
|
|
351
|
+
*
|
|
352
|
+
* The clause used to name `telegram_send`, and that held — but it was still an
|
|
353
|
+
* instruction where a mechanism was needed: the same miss recurs whenever the
|
|
354
|
+
* session is distracted, compacted or interrupted mid-report, and an unsent
|
|
355
|
+
* report is indistinguishable from a quiet tick. #123 moved delivery to the
|
|
356
|
+
* daemon, so the clause now names the handover instead. `omp-conductor report`
|
|
357
|
+
* persists the text before anything is sent and the daemon retries it until it
|
|
358
|
+
* lands; a handover that returns a report id has therefore *already* survived
|
|
359
|
+
* the failure mode this rule exists for, which a watched `telegram_send` never
|
|
360
|
+
* could. `telegram_send` remains the right call for an interactive reply to a
|
|
361
|
+
* person who is waiting — that is a conversation, not a report.
|
|
345
362
|
*/
|
|
346
363
|
export const TICK_DELIVERY_RULE =
|
|
347
|
-
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator.
|
|
364
|
+
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
|
|
348
365
|
|
|
349
366
|
/** Re-exported so the tick's own contract stays readable from one file: the
|
|
350
367
|
* constant itself lives beside the check that decides whether it is callable. */
|
|
@@ -1289,8 +1306,8 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1289
1306
|
};
|
|
1290
1307
|
let releaseGateArmed = false;
|
|
1291
1308
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1292
|
-
// which pane owns it. The gate therefore starts closed and only honours
|
|
1293
|
-
//
|
|
1309
|
+
// which pane owns it. The gate therefore starts closed and only honours a
|
|
1310
|
+
// configured grant after ownership is accepted.
|
|
1294
1311
|
let releaseAuthorityAccepted = false;
|
|
1295
1312
|
const armReleaseGate = (): void => {
|
|
1296
1313
|
if (releaseGateArmed) return;
|
|
@@ -1304,35 +1321,38 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1304
1321
|
) => ReleaseDecision | undefined,
|
|
1305
1322
|
): void;
|
|
1306
1323
|
}).on("tool_call", (event) => {
|
|
1307
|
-
// Most tool calls are ordinary tracker/file work.
|
|
1308
|
-
//
|
|
1309
|
-
const
|
|
1310
|
-
if (
|
|
1324
|
+
// Most tool calls are ordinary tracker/file work. Classify first so they
|
|
1325
|
+
// do not re-read config or emit policy diagnostics.
|
|
1326
|
+
const shape = releaseShapeFromTool(event.toolName, event.input);
|
|
1327
|
+
if (shape === undefined) return undefined;
|
|
1311
1328
|
let projectName: string | undefined;
|
|
1312
|
-
let
|
|
1329
|
+
let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
|
|
1313
1330
|
let external = true;
|
|
1314
1331
|
try {
|
|
1315
1332
|
const project = findProject(loadConfig());
|
|
1316
1333
|
projectName = project.name;
|
|
1317
|
-
|
|
1334
|
+
grants = resolveReleaseGrants(project);
|
|
1318
1335
|
external = project.escalation.orchestrator === "external";
|
|
1319
1336
|
} catch (err) {
|
|
1320
1337
|
// A missing/unreadable config cannot open a release gate. Log only when
|
|
1321
1338
|
// a release-shaped call actually reaches this handler.
|
|
1322
1339
|
pi.logger.error(
|
|
1323
|
-
`[omp-conductor] release policy unreadable;
|
|
1340
|
+
`[omp-conductor] release policy unreadable; denying every release shape: ${
|
|
1324
1341
|
err instanceof Error ? err.message : String(err)
|
|
1325
1342
|
}`,
|
|
1326
1343
|
);
|
|
1327
1344
|
}
|
|
1328
|
-
|
|
1345
|
+
// Per shape since #122: a fleet may hold `git-tag` and still be refused
|
|
1346
|
+
// `deploy`, so this asks about the shape in hand, not about one policy word.
|
|
1347
|
+
const refusal = releaseRefusal(grants, "orchestrator", shape);
|
|
1348
|
+
if (releaseAuthorityAccepted && refusal === undefined) return undefined;
|
|
1329
1349
|
// Embedded orchestrators carry the same tripwire inline through
|
|
1330
1350
|
// `createSession`; suppress this second copy only after this session has
|
|
1331
1351
|
// proved it owns the external heartbeat.
|
|
1332
1352
|
if (releaseAuthorityAccepted && !external) return undefined;
|
|
1333
1353
|
if (projectName !== undefined) {
|
|
1334
1354
|
try {
|
|
1335
|
-
recordReleaseBlock(projectName, "orchestrator",
|
|
1355
|
+
recordReleaseBlock(projectName, "orchestrator", shape);
|
|
1336
1356
|
} catch (err) {
|
|
1337
1357
|
pi.logger.error(
|
|
1338
1358
|
`[omp-conductor] could not record release-policy block: ${
|
|
@@ -1341,7 +1361,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1341
1361
|
);
|
|
1342
1362
|
}
|
|
1343
1363
|
}
|
|
1344
|
-
|
|
1364
|
+
// Before ownership is proved this session holds no grant at all, so a
|
|
1365
|
+
// covered shape still refuses — with the wording it would get from a
|
|
1366
|
+
// deny-all map rather than a claim about a grant it cannot yet use.
|
|
1367
|
+
return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
1345
1368
|
});
|
|
1346
1369
|
};
|
|
1347
1370
|
|