omp-conductor 0.15.10 → 0.15.12
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 +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +150 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +32 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- package/systemd/recover-unit-test.sh +428 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-failover chain (#286): which model the next attempt of one run
|
|
3
|
+
* chain dispatches on, and how the choice is worded so a merged branch built
|
|
4
|
+
* on a different model stays attributable months later.
|
|
5
|
+
*
|
|
6
|
+
* Pure and synchronous: the daemon reads the run rows from the store, hands
|
|
7
|
+
* them here, and does what the returned choice says. Nothing in this module
|
|
8
|
+
* reaches a store or a provider — the same split as `failure-class.ts`, kept
|
|
9
|
+
* here (and NOT there) because `failure-class.ts` owns the *verdict* while
|
|
10
|
+
* this owns the *response to a run of provider verdicts*.
|
|
11
|
+
*
|
|
12
|
+
* The trigger is deliberately counted from the store, never from the
|
|
13
|
+
* harness's `modelFallbackMessage`: that message means the harness already
|
|
14
|
+
* downgraded internally and is evidence about a run, not a decision; keying
|
|
15
|
+
* on it would fire the feature when nothing needed switching and stay silent
|
|
16
|
+
* when a provider returns 402 cleanly.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { FailureClass, RunRecord } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Failure classes that move a chain onto its next model — and ONLY these: a
|
|
23
|
+
* cap kill means the slice was too big, and switching models in response
|
|
24
|
+
* would hide a decomposition problem behind a provider one (#286, #490).
|
|
25
|
+
*/
|
|
26
|
+
export const FAILOVER_CLASSES: readonly FailureClass[] = [
|
|
27
|
+
"provider-transient",
|
|
28
|
+
"provider-credit",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The default for a project's `modelFallbackThreshold` when it is absent or
|
|
32
|
+
* unusable. Two consecutive provider-class failures, then the next attempt
|
|
33
|
+
* moves to the next model. */
|
|
34
|
+
export const DEFAULT_MODEL_FALLBACK_THRESHOLD = 2;
|
|
35
|
+
|
|
36
|
+
/** True for the classes {@link FAILOVER_CLASSES} names, so a non-provider
|
|
37
|
+
* failure can never push a chain sideways. */
|
|
38
|
+
export function isProviderFailureClass(cls: string | undefined): cls is FailureClass {
|
|
39
|
+
return cls !== undefined && (FAILOVER_CLASSES as readonly string[]).includes(cls);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Consecutive provider-class failures at the head of one run chain. */
|
|
43
|
+
export interface ProviderFailureFacts {
|
|
44
|
+
/** How many terminal runs at the head of the chain failed as a provider. */
|
|
45
|
+
streak: number;
|
|
46
|
+
/** Those runs' classes, newest first — the set is either one class or a mix
|
|
47
|
+
* of `provider-transient` and `provider-credit`. */
|
|
48
|
+
classes: FailureClass[];
|
|
49
|
+
/**
|
|
50
|
+
* The model the most recent failure dispatched on, when its row recorded
|
|
51
|
+
* one. Undefined when the rows predate the column or the project has no
|
|
52
|
+
* chain — the log then falls back to the configured primary, or to "harness
|
|
53
|
+
* default" for a project with no `workerModel`.
|
|
54
|
+
*/
|
|
55
|
+
previousModel: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Counts the consecutive provider-class failures at the head of a run chain.
|
|
60
|
+
* `runs` is in store order (oldest first, per `Store.runsForIssue`); the walk
|
|
61
|
+
* starts at the newest row and stops at the first row that is not a
|
|
62
|
+
* provider-class failure — a success, a non-provider failure, an unclassified
|
|
63
|
+
* row, a live row — so "sticky per chain, never global" falls out: a fresh
|
|
64
|
+
* issue has no rows and starts on the primary, and one issue's bad luck can
|
|
65
|
+
* never move another issue's chain.
|
|
66
|
+
*/
|
|
67
|
+
export function providerFailureFacts(runs: readonly RunRecord[]): ProviderFailureFacts {
|
|
68
|
+
const facts: ProviderFailureFacts = { streak: 0, classes: [], previousModel: undefined };
|
|
69
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
70
|
+
const run = runs[i]!;
|
|
71
|
+
if (!isProviderFailureClass(run.failureClass)) break;
|
|
72
|
+
facts.streak += 1;
|
|
73
|
+
facts.classes.push(run.failureClass);
|
|
74
|
+
if (facts.previousModel === undefined && run.model !== undefined) {
|
|
75
|
+
facts.previousModel = run.model;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return facts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The model the next attempt should dispatch on, and whether that is a
|
|
82
|
+
* failover at all. */
|
|
83
|
+
export interface ModelChoice {
|
|
84
|
+
/** Model to dispatch on; `undefined` leaves the harness default, exactly as
|
|
85
|
+
* an unconfigured project dispatches today. */
|
|
86
|
+
model: string | undefined;
|
|
87
|
+
/** True when the choice came from the fallback chain — the failover has
|
|
88
|
+
* fired and the tick report must say so. */
|
|
89
|
+
fallback: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where one run chain's next attempt dispatches, given the chain configuration
|
|
94
|
+
* and the store-derived failure streak.
|
|
95
|
+
*
|
|
96
|
+
* With no chain (empty or absent `modelFallbacks`) the primary model is
|
|
97
|
+
* returned unchanged and `fallback` stays false — today's dispatch byte for
|
|
98
|
+
* byte. Once the streak reaches the threshold the chain advances one slot per
|
|
99
|
+
* extra failure, clamped to the last model: the chain is exhausted there, and
|
|
100
|
+
* the existing escalation path (the provider-transient strike cap) settles it,
|
|
101
|
+
* naming every model tried.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveDispatchModel(args: {
|
|
104
|
+
workerModel: string | undefined;
|
|
105
|
+
modelFallbacks: readonly string[] | undefined;
|
|
106
|
+
threshold: number;
|
|
107
|
+
streak: number;
|
|
108
|
+
}): ModelChoice {
|
|
109
|
+
const { workerModel, modelFallbacks, threshold, streak } = args;
|
|
110
|
+
if (modelFallbacks === undefined || modelFallbacks.length === 0) {
|
|
111
|
+
return { model: workerModel, fallback: false };
|
|
112
|
+
}
|
|
113
|
+
if (streak < threshold) return { model: workerModel, fallback: false };
|
|
114
|
+
const index = Math.min(streak - threshold, modelFallbacks.length - 1);
|
|
115
|
+
return { model: modelFallbacks[index] ?? workerModel, fallback: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The clause the tick report annexes to a failover dispatch:
|
|
120
|
+
* `on <fallback> after N provider-transient failures on <previous>`, the shape
|
|
121
|
+
* the issue asked for (`#123 attempt 3 on <fallback> after 2
|
|
122
|
+
* provider-transient failures on <primary>`). `undefined` when no failover
|
|
123
|
+
* fired, so the ordinary dispatch log line is untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function fallbackClause(
|
|
126
|
+
choice: ModelChoice,
|
|
127
|
+
facts: ProviderFailureFacts,
|
|
128
|
+
workerModel: string | undefined,
|
|
129
|
+
): string | undefined {
|
|
130
|
+
if (!choice.fallback) return undefined;
|
|
131
|
+
const classWord = new Set(facts.classes).size === 1 ? facts.classes[0]! : "provider";
|
|
132
|
+
const countWord = facts.streak === 1 ? "failure" : "failures";
|
|
133
|
+
const previous = facts.previousModel ?? workerModel ?? "harness default";
|
|
134
|
+
return `on ${choice.model} after ${facts.streak} ${classWord} ${countWord} on ${previous}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One recorded model dispatch in a chain: which model, on which attempt. */
|
|
138
|
+
export interface ModelTried {
|
|
139
|
+
model: string;
|
|
140
|
+
attempt: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every recorded model of a chain, in chain order — what the exhaustion
|
|
144
|
+
* escalation names when it says "every model tried". Rows without a recorded
|
|
145
|
+
* model (no chain configured, or written before the column existed) are
|
|
146
|
+
* absent: an unnamed primary is the harness default, which the escalation
|
|
147
|
+
* wording covers without pretending to know its name. */
|
|
148
|
+
export function modelsTried(runs: readonly RunRecord[]): ModelTried[] {
|
|
149
|
+
const tried: ModelTried[] = [];
|
|
150
|
+
for (const run of runs) {
|
|
151
|
+
if (run.model === undefined) continue;
|
|
152
|
+
tried.push({ model: run.model, attempt: run.attempt });
|
|
153
|
+
}
|
|
154
|
+
return tried;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `"acme/a (attempts 1-2), acme/b (attempt 3)"` — consecutive attempts
|
|
159
|
+
* grouped under their model, in chain order.
|
|
160
|
+
*/
|
|
161
|
+
export function formatModelsTried(tried: readonly ModelTried[]): string {
|
|
162
|
+
const groups: { model: string; attempts: number[] }[] = [];
|
|
163
|
+
for (const { model, attempt } of tried) {
|
|
164
|
+
const last = groups[groups.length - 1];
|
|
165
|
+
if (last !== undefined && last.model === model) last.attempts.push(attempt);
|
|
166
|
+
else groups.push({ model, attempts: [attempt] });
|
|
167
|
+
}
|
|
168
|
+
return groups
|
|
169
|
+
.map(({ model, attempts }) => {
|
|
170
|
+
const span =
|
|
171
|
+
attempts.length === 1
|
|
172
|
+
? `attempt ${attempts[0]}`
|
|
173
|
+
: `attempts ${attempts[0]}-${attempts[attempts.length - 1]}`;
|
|
174
|
+
return `${model} (${span})`;
|
|
175
|
+
})
|
|
176
|
+
.join(", ");
|
|
177
|
+
}
|
package/src/notices.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** The deterministic primary id of a held notice derived from its escalation
|
|
4
|
+
* key. Sha256 of the key — 64 hex characters, a shape the digest handoff
|
|
5
|
+
* accepts verbatim — keeps the re-derived notice from a later poll idempotent
|
|
6
|
+
* against `ON CONFLICT(id) DO NOTHING` while remaining a real ledger row id. */
|
|
7
|
+
export function heldNoticeId(key: string): string {
|
|
8
|
+
return createHash("sha256").update(`held-notice\0${key}`).digest("hex");
|
|
9
|
+
}
|
package/src/omp.ts
CHANGED
|
@@ -19,14 +19,15 @@ import { tmpdir } from "node:os";
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
|
|
21
21
|
import { readOnlySession, worktreeConfinement } from "./confinement.ts";
|
|
22
|
-
import { releasePolicyTripwire, type ReleaseBlockContext } from "./release-policy.ts";
|
|
22
|
+
import { releasePolicyTripwire, type GateShape, type ReleaseBlockContext } from "./release-policy.ts";
|
|
23
23
|
import type {
|
|
24
24
|
HostToParent,
|
|
25
25
|
ParentToHost,
|
|
26
26
|
SessionHostSpec,
|
|
27
27
|
} from "./session-host.ts";
|
|
28
28
|
import { decodeFrames, encodeFrame } from "./session-host.ts";
|
|
29
|
-
import type {
|
|
29
|
+
import type { ResolvedGrants, SessionRole } from "./types.ts";
|
|
30
|
+
import { SESSION_ROLE_ENV } from "./types.ts";
|
|
30
31
|
import { conductorVerbs } from "./verbs/client.ts";
|
|
31
32
|
|
|
32
33
|
const OMP_PACKAGE = "@oh-my-pi/pi-coding-agent";
|
|
@@ -158,7 +159,7 @@ export async function createLocalSession(opts: {
|
|
|
158
159
|
/** Install the release/deploy tool-call gate with these per-shape grants. */
|
|
159
160
|
releaseGrants?: ResolvedGrants;
|
|
160
161
|
/** Durable audit callback invoked only when that gate rejects a call. */
|
|
161
|
-
onReleaseBlocked?: (shape:
|
|
162
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
162
163
|
/**
|
|
163
164
|
* The conductor verb socket this session's mutation tools call (#126).
|
|
164
165
|
*
|
|
@@ -414,7 +415,7 @@ export interface CreateSessionOptions {
|
|
|
414
415
|
resume?: boolean;
|
|
415
416
|
role: SessionRole;
|
|
416
417
|
releaseGrants?: ResolvedGrants;
|
|
417
|
-
onReleaseBlocked?: (shape:
|
|
418
|
+
onReleaseBlocked?: (shape: GateShape, context: ReleaseBlockContext) => void;
|
|
418
419
|
|
|
419
420
|
/**
|
|
420
421
|
* Where the control socket is bound. The daemon puts it beside the run's own
|
|
@@ -559,6 +560,11 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
559
560
|
const argv = [process.execPath, opts.hostModule ?? SESSION_HOST, JSON.stringify(spec)];
|
|
560
561
|
const child = Bun.spawn(argv, {
|
|
561
562
|
cwd: opts.cwd,
|
|
563
|
+
// Stamp the session's own role on the child, so a process the agent runs —
|
|
564
|
+
// `omp-conductor report` from its sandbox — can tell a worker session from
|
|
565
|
+
// the operator's shell. Direct CLI runs outside a spawned session inherit
|
|
566
|
+
// nothing and stay the orchestrator surface.
|
|
567
|
+
env: opts.role === undefined ? undefined : { ...process.env, [SESSION_ROLE_ENV]: opts.role },
|
|
562
568
|
stdin: "ignore",
|
|
563
569
|
// The harness writes progress to stdout; both streams are the daemon's log,
|
|
564
570
|
// never the protocol. The protocol has its own socket precisely so a chatty
|
|
@@ -617,6 +623,67 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
617
623
|
return stderrTail.trim();
|
|
618
624
|
};
|
|
619
625
|
|
|
626
|
+
/**
|
|
627
|
+
* The child's own start failure, if it got one out over the socket before it
|
|
628
|
+
* died — the socket counterpart of the pipe drain.
|
|
629
|
+
*
|
|
630
|
+
* A child that cannot start can connect and write `start-error` in the same
|
|
631
|
+
* event-loop turn its exit is dispatched in: the connect already completed
|
|
632
|
+
* in the kernel, the accept is still queued, and closing the listener in
|
|
633
|
+
* that window drops the frame with the reason. So every exit route waits
|
|
634
|
+
* this out BEFORE {@link cleanup} closes the server, then prefers the frame
|
|
635
|
+
* over a bare exit code. Bounded like the pipes: a dead child that never
|
|
636
|
+
* connected has nothing on the wire, and 2s only ever delays an
|
|
637
|
+
* already-failed startup.
|
|
638
|
+
*
|
|
639
|
+
* Memoized: the pre-connect catch and the exit handler both arrive here, and
|
|
640
|
+
* both must read the SAME socket — two independent readers would race on the
|
|
641
|
+
* first `destroy()` and one of them would lose the frame.
|
|
642
|
+
*/
|
|
643
|
+
let startErrorReading: Promise<string | undefined> | undefined;
|
|
644
|
+
const startErrorFromSocket = (): Promise<string | undefined> => {
|
|
645
|
+
startErrorReading ??= (async (): Promise<string | undefined> => {
|
|
646
|
+
const socket = await Promise.race([
|
|
647
|
+
attached.catch(() => undefined),
|
|
648
|
+
new Promise<undefined>((resolve) => {
|
|
649
|
+
setTimeout(() => resolve(undefined), DRAIN_GRACE_MS).unref?.();
|
|
650
|
+
}),
|
|
651
|
+
]);
|
|
652
|
+
if (socket === undefined) return undefined;
|
|
653
|
+
try {
|
|
654
|
+
return await new Promise<string | undefined>((resolve) => {
|
|
655
|
+
let buffer = "";
|
|
656
|
+
let settled = false;
|
|
657
|
+
const finish = (message: string | undefined): void => {
|
|
658
|
+
if (settled) return;
|
|
659
|
+
settled = true;
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
resolve(message);
|
|
662
|
+
};
|
|
663
|
+
const timer = setTimeout(() => finish(undefined), DRAIN_GRACE_MS);
|
|
664
|
+
timer.unref?.();
|
|
665
|
+
socket.on("data", (chunk: Buffer) => {
|
|
666
|
+
buffer += chunk.toString("utf8");
|
|
667
|
+
const { frames, rest } = decodeFrames(buffer);
|
|
668
|
+
buffer = rest;
|
|
669
|
+
for (const frame of frames) {
|
|
670
|
+
const message = frame as { t?: unknown; message?: unknown };
|
|
671
|
+
if (message.t === "start-error" && typeof message.message === "string") {
|
|
672
|
+
finish(message.message);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
socket.once("error", () => finish(undefined));
|
|
678
|
+
socket.once("close", () => finish(undefined));
|
|
679
|
+
});
|
|
680
|
+
} finally {
|
|
681
|
+
socket.destroy();
|
|
682
|
+
}
|
|
683
|
+
})();
|
|
684
|
+
return startErrorReading;
|
|
685
|
+
};
|
|
686
|
+
|
|
620
687
|
const cleanup = (): void => {
|
|
621
688
|
server.close();
|
|
622
689
|
rmSync(socketPath, { force: true });
|
|
@@ -649,15 +716,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
649
716
|
|
|
650
717
|
void child.exited.then(async (code) => {
|
|
651
718
|
onExit();
|
|
719
|
+
// A child that cannot start writes `start-error` over the socket, not the
|
|
720
|
+
// pipes — and its exit can be dispatched before the accept of a connection
|
|
721
|
+
// that already completed in the kernel. Let the socket settle before the
|
|
722
|
+
// listener closes, so the child's own words survive the ordering.
|
|
723
|
+
if (disposing) {
|
|
724
|
+
cleanup();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const startError = await startErrorFromSocket();
|
|
652
728
|
cleanup();
|
|
653
|
-
// A child that exits during teardown exited because we asked it to. Only an
|
|
654
|
-
// exit while the session is supposed to be live is a failure — and it has
|
|
655
|
-
// to reach whoever is awaiting a prompt, or the dispatcher waits forever
|
|
656
|
-
// for a turn from a process that is gone.
|
|
657
|
-
if (disposing) return;
|
|
658
729
|
// The tail first, so the message carries the child's own words.
|
|
659
730
|
await settledTail();
|
|
660
|
-
fail(
|
|
731
|
+
fail(
|
|
732
|
+
startError ?? `omp-conductor session child exited ${String(code)} before the session ended`,
|
|
733
|
+
);
|
|
661
734
|
});
|
|
662
735
|
|
|
663
736
|
let socket: Socket;
|
|
@@ -681,15 +754,20 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
681
754
|
]);
|
|
682
755
|
} catch (err) {
|
|
683
756
|
child.kill("SIGKILL");
|
|
757
|
+
// The child's exit can win the race above against the accept of a
|
|
758
|
+
// connection that completed in the kernel — then its `start-error` frame
|
|
759
|
+
// is still queued, and closing the listener now would throw it away.
|
|
760
|
+
// Read the socket first (bounded, like the pipes), and prefer its words
|
|
761
|
+
// over a bare exit code.
|
|
762
|
+
const startError = await startErrorFromSocket();
|
|
684
763
|
cleanup();
|
|
685
764
|
// Killed first, so the pipes are already closing, and only then read. This is
|
|
686
765
|
// the route a child that dies before connecting takes — a missing peer
|
|
687
766
|
// dependency, say — and reading `stderrTail` synchronously here raced the
|
|
688
767
|
// drain loops and reported a bare exit code instead of the reason.
|
|
689
768
|
const tail = await settledTail();
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
);
|
|
769
|
+
const reason = startError ?? (err instanceof Error ? err.message : String(err));
|
|
770
|
+
throw new Error(`${reason}${tail === "" ? "" : `\nchild output:\n${tail}`}`);
|
|
693
771
|
}
|
|
694
772
|
|
|
695
773
|
let buffer = "";
|
|
@@ -739,6 +817,8 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
739
817
|
tool: message.tool,
|
|
740
818
|
reason: message.reason,
|
|
741
819
|
args: message.args,
|
|
820
|
+
...(message.matched === undefined ? {} : { matched: message.matched }),
|
|
821
|
+
...(message.invocation === undefined ? {} : { invocation: message.invocation }),
|
|
742
822
|
});
|
|
743
823
|
break;
|
|
744
824
|
}
|