@basein/runner 0.2.12 → 0.3.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 +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2786 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scenario-liveness — the two things `bir-scenario` does besides answering MCP:
|
|
3
|
+
* it tells its project's recorder that a delivery tool is live
|
|
4
|
+
* (`POST /scenario/hello`), and it brings back a recorder that died
|
|
5
|
+
* mid-session (`bir-hooks revive`).
|
|
6
|
+
*
|
|
7
|
+
* WHY HELLO. A direct plan tells the model to call `mcp__bir__run_scenario`
|
|
8
|
+
* before any other tool. The control server used to announce one whenever the
|
|
9
|
+
* MCP config listed the `bir` server — but a configured server is not a
|
|
10
|
+
* connected one: a stale node path, a crash, the server disabled in `/mcp`, or
|
|
11
|
+
* a session run with `BIR_OFF=1` (which lists no tools) all leave the model
|
|
12
|
+
* told to call a tool it does not have. So this server says, on `initialize`,
|
|
13
|
+
* on `tools/list`, when the off switch flips, whenever its recorder changes,
|
|
14
|
+
* and every {@link HELLO_EVERY_MS} while it runs: this pid, this version, this
|
|
15
|
+
* many tools, off or not. The control server announces a direct plan only on
|
|
16
|
+
* the word of a live pid that lists a tool.
|
|
17
|
+
*
|
|
18
|
+
* WHY REVIVE. Every hook but SessionStart is an HTTP post to the recorder.
|
|
19
|
+
* With the recorder gone — killed by an antivirus, out of memory — Claude Code
|
|
20
|
+
* shows a hook error on every prompt and tool call until the next session
|
|
21
|
+
* start, `/clear` or compaction, because nothing else starts one mid-session.
|
|
22
|
+
* This server is the one long-lived process in every session of an installed
|
|
23
|
+
* project, wrapped servers or none, so it starts `bir-hooks revive --cwd <dir>`
|
|
24
|
+
* (which runs the ordinary ensure, under the ordinary start lock).
|
|
25
|
+
*
|
|
26
|
+
* NEITHER MAY COST THE SESSION ANYTHING.
|
|
27
|
+
* - A hello is fire-and-forget: nothing on the MCP path awaits it, it is
|
|
28
|
+
* bounded by {@link HELLO_TIMEOUT_MS}, one is in flight at a time, and a
|
|
29
|
+
* recorder that cannot take it (not up yet, busy, an older version with no
|
|
30
|
+
* such route) costs a retry later, never an error.
|
|
31
|
+
* - A revive is a detached child that is never waited for, and it is started
|
|
32
|
+
* only when the discovery file names a pid that is DEAD and the recorder's
|
|
33
|
+
* address REFUSES the connection. A recorder that is alive but slow is never
|
|
34
|
+
* replaced (that is how duplicates that cannot bind were made). A missing
|
|
35
|
+
* discovery file is never revived either: `bir down` removes it, and a
|
|
36
|
+
* recorder somebody stopped on purpose stays stopped. Only an installed
|
|
37
|
+
* project is revived, at most once per {@link REVIVE_EVERY_MS} per process
|
|
38
|
+
* (after a random wait of up to {@link REVIVE_JITTER_MS}, so the several
|
|
39
|
+
* processes of a session that see the same death do not all start one),
|
|
40
|
+
* less often each time a revive did not bring a recorder back, and not in
|
|
41
|
+
* the server's first {@link REVIVE_GRACE_MS}, while the SessionStart hook
|
|
42
|
+
* is starting the recorder itself.
|
|
43
|
+
* - Neither writes a byte to stdout (the host's JSON-RPC stream), and
|
|
44
|
+
* neither throws.
|
|
45
|
+
*/
|
|
46
|
+
import { spawn } from "node:child_process";
|
|
47
|
+
import { readFileSync, statSync } from "node:fs";
|
|
48
|
+
import { pidAlive } from "../control/discovery.js";
|
|
49
|
+
import { configDir, discoveryPath } from "../control/paths.js";
|
|
50
|
+
import { nodeFlagsToCarry, projectRecord, readSidecar } from "../config/generate.js";
|
|
51
|
+
import { errText, logDetail, logLine } from "../util/log.js";
|
|
52
|
+
import { packageVersion } from "../util/version.js";
|
|
53
|
+
/** A hello waits no longer than this for the recorder's answer. */
|
|
54
|
+
export const HELLO_TIMEOUT_MS = 1_000;
|
|
55
|
+
/** A hello is repeated this often while the server runs (the recorder trusts one for 30 min). */
|
|
56
|
+
export const HELLO_EVERY_MS = 5 * 60_000;
|
|
57
|
+
/**
|
|
58
|
+
* How often the discovery file is looked at: whether the recorder changed (a
|
|
59
|
+
* hello is owed to the new one) or died (a revive may be). One `stat`, and a
|
|
60
|
+
* parse only when the file changed.
|
|
61
|
+
*/
|
|
62
|
+
export const WATCH_EVERY_MS = 2_000;
|
|
63
|
+
/** At most one revive per this long per process; doubled each time one did not help. */
|
|
64
|
+
export const REVIVE_EVERY_MS = 30_000;
|
|
65
|
+
/** The longest a revive waits, at random, before it looks again and starts. */
|
|
66
|
+
export const REVIVE_JITTER_MS = 5_000;
|
|
67
|
+
/** The longest wait between revives that did not bring a recorder back. */
|
|
68
|
+
export const REVIVE_MAX_EVERY_MS = 5 * 60_000;
|
|
69
|
+
/**
|
|
70
|
+
* No revive in a server's first this-long: it starts with the session, and so
|
|
71
|
+
* does the SessionStart hook, whose own ensure (up to 11 s, ensure-hook.ts) is
|
|
72
|
+
* what starts the recorder then — typically over a dead record left from
|
|
73
|
+
* before a reboot. Reviving beside it would only race it.
|
|
74
|
+
*/
|
|
75
|
+
export const REVIVE_GRACE_MS = 12_000;
|
|
76
|
+
/** How long the "does anything listen there" probe waits before calling the recorder busy. */
|
|
77
|
+
export const REFUSED_PROBE_MS = 2_000;
|
|
78
|
+
/** The longest wait between hellos a recorder could not take. */
|
|
79
|
+
const HELLO_RETRY_MAX_MS = 60_000;
|
|
80
|
+
let recordCache;
|
|
81
|
+
/**
|
|
82
|
+
* This project's discovery record, whether or not its pid is alive — the
|
|
83
|
+
* shared reader drops a dead pid's record, and a dead pid is exactly what a
|
|
84
|
+
* revive needs to see. Parsed again only when the file changed. Never throws.
|
|
85
|
+
*/
|
|
86
|
+
export function readRecorderRecord(cwd) {
|
|
87
|
+
try {
|
|
88
|
+
const path = discoveryPath(cwd);
|
|
89
|
+
let stamp;
|
|
90
|
+
try {
|
|
91
|
+
const st = statSync(path);
|
|
92
|
+
// The inode too: the file is replaced by a rename, and a coarse clock can
|
|
93
|
+
// give the replacement the same time and, by chance, the same size.
|
|
94
|
+
stamp = `${st.ino}:${st.mtimeMs}:${st.size}`;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
recordCache = { path, stamp: "-", record: undefined };
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (recordCache && recordCache.path === path && recordCache.stamp === stamp)
|
|
101
|
+
return recordCache.record;
|
|
102
|
+
let record;
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
105
|
+
record =
|
|
106
|
+
typeof parsed.url === "string" && typeof parsed.token === "string" && typeof parsed.pid === "number"
|
|
107
|
+
? { url: parsed.url, token: parsed.token, pid: parsed.pid }
|
|
108
|
+
: undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
record = undefined; // being replaced this instant, or damaged: nothing to go on
|
|
112
|
+
}
|
|
113
|
+
recordCache = { path, stamp, record };
|
|
114
|
+
return record;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Whether `cwd` is a project `bir setup`/`bir install` put the runner in. Never throws. */
|
|
121
|
+
export function projectInstalled(cwd) {
|
|
122
|
+
try {
|
|
123
|
+
return projectRecord(readSidecar(), cwd) !== undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Start `bir-hooks revive --cwd <cwd>` from this package, detached, and forget
|
|
131
|
+
* it: it reads nothing, writes nothing we could see, and exits by itself.
|
|
132
|
+
*/
|
|
133
|
+
export async function launchRevive(cwd) {
|
|
134
|
+
// Loaded here, not at the top: a revive is rare, and the module graph behind
|
|
135
|
+
// the daemon is not needed to answer MCP.
|
|
136
|
+
const { hooksScriptPath } = await import("../control/daemon.js");
|
|
137
|
+
const child = spawn(process.execPath, [...nodeFlagsToCarry(), hooksScriptPath(), "revive", "--cwd", cwd], {
|
|
138
|
+
// Not the project: on Windows a process holds its current directory, and
|
|
139
|
+
// the project folder must stay free to rename or delete (daemon.ts).
|
|
140
|
+
cwd: configDir(),
|
|
141
|
+
detached: true,
|
|
142
|
+
stdio: "ignore",
|
|
143
|
+
// A detached child on Windows would get a console window of its own.
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
env: process.env,
|
|
146
|
+
});
|
|
147
|
+
child.on("error", (err) => logLine("scenario.revive_failed", { error: errText(err) }));
|
|
148
|
+
child.unref();
|
|
149
|
+
}
|
|
150
|
+
/** How a fetch to the recorder failed: busy (timed out), refused (nothing listens), or otherwise. */
|
|
151
|
+
function reachOf(err) {
|
|
152
|
+
const e = err;
|
|
153
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError")
|
|
154
|
+
return "timeout";
|
|
155
|
+
if (e?.cause?.name === "TimeoutError")
|
|
156
|
+
return "timeout";
|
|
157
|
+
if (e?.cause?.code === "ECONNREFUSED")
|
|
158
|
+
return "refused";
|
|
159
|
+
// A host name with two addresses fails as one AggregateError of both.
|
|
160
|
+
const all = e?.cause?.errors;
|
|
161
|
+
if (Array.isArray(all) && all.length > 0 && all.every((x) => x?.code === "ECONNREFUSED"))
|
|
162
|
+
return "refused";
|
|
163
|
+
return "error";
|
|
164
|
+
}
|
|
165
|
+
function trimUrl(url) {
|
|
166
|
+
return url.replace(/\/+$/, "");
|
|
167
|
+
}
|
|
168
|
+
const unrefSleep = (ms) => new Promise((resolve) => {
|
|
169
|
+
const t = setTimeout(resolve, ms);
|
|
170
|
+
t.unref?.();
|
|
171
|
+
});
|
|
172
|
+
/** See the module comment. One per `bir-scenario` process. */
|
|
173
|
+
export class ScenarioLiveness {
|
|
174
|
+
opts;
|
|
175
|
+
cwd;
|
|
176
|
+
now;
|
|
177
|
+
random;
|
|
178
|
+
fetchImpl;
|
|
179
|
+
record;
|
|
180
|
+
alive;
|
|
181
|
+
installed;
|
|
182
|
+
launch;
|
|
183
|
+
sleep;
|
|
184
|
+
env;
|
|
185
|
+
timer;
|
|
186
|
+
stopped = false;
|
|
187
|
+
/** No revive before this (see {@link REVIVE_GRACE_MS}). */
|
|
188
|
+
reviveFrom;
|
|
189
|
+
// ── hello ──
|
|
190
|
+
helloBusy = false;
|
|
191
|
+
/** A hello asked for while one was in flight: sent when it ends, with the state as it is then. */
|
|
192
|
+
helloOwed;
|
|
193
|
+
/** The recorder the last hello was answered by (`url#pid`). */
|
|
194
|
+
reached;
|
|
195
|
+
/** The recorder the last hello was sent to, answered or not. */
|
|
196
|
+
tried;
|
|
197
|
+
lastHelloAt = 0;
|
|
198
|
+
helloFailures = 0;
|
|
199
|
+
helloNotBefore = 0;
|
|
200
|
+
// ── revive ──
|
|
201
|
+
reviveBusy = false;
|
|
202
|
+
reviveNotBefore = 0;
|
|
203
|
+
/** Revives started since a recorder was last seen alive. */
|
|
204
|
+
revivesInARow = 0;
|
|
205
|
+
/** When this process last started one (0: never). */
|
|
206
|
+
lastReviveAt = 0;
|
|
207
|
+
/** Revives started by this process. */
|
|
208
|
+
revives = 0;
|
|
209
|
+
/** Hellos a recorder answered. */
|
|
210
|
+
hellos = 0;
|
|
211
|
+
constructor(opts) {
|
|
212
|
+
this.opts = opts;
|
|
213
|
+
this.cwd = opts.cwd;
|
|
214
|
+
this.now = opts.now ?? Date.now;
|
|
215
|
+
this.random = opts.random ?? Math.random;
|
|
216
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
217
|
+
this.record = opts.record ?? readRecorderRecord;
|
|
218
|
+
this.alive = opts.alive ?? pidAlive;
|
|
219
|
+
this.installed = opts.installed ?? projectInstalled;
|
|
220
|
+
this.launch = opts.launch ?? launchRevive;
|
|
221
|
+
this.sleep = opts.sleep ?? unrefSleep;
|
|
222
|
+
this.env = opts.env ?? process.env;
|
|
223
|
+
this.reviveFrom = this.now() + Math.max(0, opts.reviveGraceMs ?? REVIVE_GRACE_MS);
|
|
224
|
+
}
|
|
225
|
+
/** Watch the discovery file every {@link WATCH_EVERY_MS}, on a timer that never keeps the process alive. */
|
|
226
|
+
start(everyMs = WATCH_EVERY_MS) {
|
|
227
|
+
if (this.timer || this.stopped)
|
|
228
|
+
return;
|
|
229
|
+
this.timer = setInterval(() => void this.tick(), everyMs);
|
|
230
|
+
this.timer.unref();
|
|
231
|
+
}
|
|
232
|
+
/** The host is gone: nothing more is sent or started. */
|
|
233
|
+
stop() {
|
|
234
|
+
this.stopped = true;
|
|
235
|
+
if (this.timer)
|
|
236
|
+
clearInterval(this.timer);
|
|
237
|
+
this.timer = undefined;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Tell the recorder what this server lists, now — or, while a hello is in
|
|
241
|
+
* flight, as soon as it ends. Returns at once; never throws.
|
|
242
|
+
*/
|
|
243
|
+
hello(why) {
|
|
244
|
+
if (this.stopped)
|
|
245
|
+
return;
|
|
246
|
+
if (this.helloBusy) {
|
|
247
|
+
this.helloOwed = why;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.helloBusy = true;
|
|
251
|
+
// On the next turn of the loop: the answer this follows goes out first,
|
|
252
|
+
// and not even reading the discovery file happens before it.
|
|
253
|
+
setImmediate(() => void this.sendHello(why));
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Something on the MCP path found no recorder (`run_scenario` found no live
|
|
257
|
+
* discovery file, or its request was refused): look now rather than at the
|
|
258
|
+
* next tick. Returns at once; never throws.
|
|
259
|
+
*/
|
|
260
|
+
recorderGone(why) {
|
|
261
|
+
if (this.stopped)
|
|
262
|
+
return;
|
|
263
|
+
setImmediate(() => void this.checkRevive(why));
|
|
264
|
+
}
|
|
265
|
+
/** One look at the discovery file. Exposed for tests; never throws. */
|
|
266
|
+
async tick() {
|
|
267
|
+
if (this.stopped)
|
|
268
|
+
return;
|
|
269
|
+
try {
|
|
270
|
+
const rec = this.record(this.cwd);
|
|
271
|
+
if (rec && !this.alive(rec.pid)) {
|
|
272
|
+
await this.checkRevive("the recorder's pid is gone");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (rec)
|
|
276
|
+
this.recorderSeenAlive();
|
|
277
|
+
const target = this.target();
|
|
278
|
+
if (!target)
|
|
279
|
+
return;
|
|
280
|
+
const now = this.now();
|
|
281
|
+
if (target.identity !== this.tried) {
|
|
282
|
+
this.hello("recorder");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (now < this.helloNotBefore)
|
|
286
|
+
return;
|
|
287
|
+
if (target.identity !== this.reached || now - this.lastHelloAt >= HELLO_EVERY_MS) {
|
|
288
|
+
this.hello(target.identity !== this.reached ? "retry" : "periodic");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
logDetail("scenario.liveness_failed", { error: errText(err) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Where a hello goes, the way `run_scenario`'s request does: `BIR_CONTROL_URL`
|
|
297
|
+
* (with `BIR_CONTROL_TOKEN`, or the discovery file's token), else the
|
|
298
|
+
* discovery file — whose pid must be alive.
|
|
299
|
+
*/
|
|
300
|
+
target() {
|
|
301
|
+
const rec = this.record(this.cwd);
|
|
302
|
+
const live = rec && this.alive(rec.pid) ? rec : undefined;
|
|
303
|
+
const envUrl = this.env.BIR_CONTROL_URL?.trim();
|
|
304
|
+
if (envUrl) {
|
|
305
|
+
const token = this.env.BIR_CONTROL_TOKEN || live?.token;
|
|
306
|
+
if (!token)
|
|
307
|
+
return undefined;
|
|
308
|
+
return { url: trimUrl(envUrl), token, identity: `${trimUrl(envUrl)}#${live?.pid ?? 0}` };
|
|
309
|
+
}
|
|
310
|
+
if (!live)
|
|
311
|
+
return undefined;
|
|
312
|
+
return { url: trimUrl(live.url), token: live.token, identity: `${trimUrl(live.url)}#${live.pid}` };
|
|
313
|
+
}
|
|
314
|
+
state() {
|
|
315
|
+
try {
|
|
316
|
+
const s = this.opts.state();
|
|
317
|
+
return { tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0, off: s.off === true };
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return { tools: 0, off: false };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async sendHello(why) {
|
|
324
|
+
this.helloBusy = true;
|
|
325
|
+
try {
|
|
326
|
+
if (this.stopped)
|
|
327
|
+
return;
|
|
328
|
+
// No recorder yet (the host often starts this server before SessionStart
|
|
329
|
+
// starts the recorder): the watch says hello when one appears.
|
|
330
|
+
const target = this.target();
|
|
331
|
+
if (!target)
|
|
332
|
+
return;
|
|
333
|
+
const { tools, off } = this.state();
|
|
334
|
+
const body = { pid: process.pid, version: packageVersion(), tools, off };
|
|
335
|
+
this.tried = target.identity;
|
|
336
|
+
this.lastHelloAt = this.now();
|
|
337
|
+
let reach;
|
|
338
|
+
let status;
|
|
339
|
+
try {
|
|
340
|
+
const res = await this.fetchImpl(`${target.url}/scenario/hello`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${target.token}` },
|
|
343
|
+
body: JSON.stringify(body),
|
|
344
|
+
signal: AbortSignal.timeout(HELLO_TIMEOUT_MS),
|
|
345
|
+
});
|
|
346
|
+
status = res.status;
|
|
347
|
+
// The answer is `{ok:true}` and nothing here needs it; read it so the
|
|
348
|
+
// connection can be reused, within the same bound.
|
|
349
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
350
|
+
reach = "answered";
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
reach = reachOf(err);
|
|
354
|
+
}
|
|
355
|
+
if (reach === "answered") {
|
|
356
|
+
this.reached = target.identity;
|
|
357
|
+
this.hellos += 1;
|
|
358
|
+
this.helloFailures = 0;
|
|
359
|
+
this.helloNotBefore = 0;
|
|
360
|
+
// A recorder from before this route answers 404: it does not ask for
|
|
361
|
+
// hellos, so there is nothing to retry until it is replaced.
|
|
362
|
+
logDetail("scenario.hello", { why, tools, off, status });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.helloFailures += 1;
|
|
366
|
+
this.helloNotBefore =
|
|
367
|
+
this.now() + Math.min(WATCH_EVERY_MS * 2 ** Math.min(this.helloFailures - 1, 10), HELLO_RETRY_MAX_MS);
|
|
368
|
+
logDetail("scenario.hello_failed", { why, reach });
|
|
369
|
+
if (reach === "refused")
|
|
370
|
+
this.recorderGone("the recorder refused a hello");
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
logDetail("scenario.hello_failed", { why, error: errText(err) });
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
this.helloBusy = false;
|
|
377
|
+
const owed = this.helloOwed;
|
|
378
|
+
this.helloOwed = undefined;
|
|
379
|
+
if (owed !== undefined && !this.stopped)
|
|
380
|
+
this.hello(owed);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Whether anything listens at `url`: only a refused connection counts as nothing. */
|
|
384
|
+
async probe(url) {
|
|
385
|
+
try {
|
|
386
|
+
const res = await this.fetchImpl(`${trimUrl(url)}/health`, { signal: AbortSignal.timeout(REFUSED_PROBE_MS) });
|
|
387
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
388
|
+
return "answered";
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
return reachOf(err);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* A recorder is up: the revives before it helped (or were not needed), so the
|
|
396
|
+
* next death waits the plain {@link REVIVE_EVERY_MS} since the last one, not
|
|
397
|
+
* a doubled wait.
|
|
398
|
+
*/
|
|
399
|
+
recorderSeenAlive() {
|
|
400
|
+
if (this.revivesInARow === 0)
|
|
401
|
+
return;
|
|
402
|
+
this.revivesInARow = 0;
|
|
403
|
+
this.reviveNotBefore = Math.min(this.reviveNotBefore, this.lastReviveAt + REVIVE_EVERY_MS);
|
|
404
|
+
}
|
|
405
|
+
/** Start a revive if — and only if — the recorder is dead, refused and this project's. */
|
|
406
|
+
async checkRevive(why) {
|
|
407
|
+
if (this.stopped || this.reviveBusy)
|
|
408
|
+
return;
|
|
409
|
+
const now = this.now();
|
|
410
|
+
if (now < this.reviveNotBefore || now < this.reviveFrom)
|
|
411
|
+
return;
|
|
412
|
+
this.reviveBusy = true;
|
|
413
|
+
try {
|
|
414
|
+
// Every process of the session sees the same death at about the same
|
|
415
|
+
// moment. Each waits a moment of its own and looks again: whoever went
|
|
416
|
+
// first has usually brought a recorder back by then.
|
|
417
|
+
const wait = Math.floor(Math.max(0, Math.min(1, this.random())) * REVIVE_JITTER_MS);
|
|
418
|
+
if (wait > 0)
|
|
419
|
+
await this.sleep(wait);
|
|
420
|
+
if (this.stopped)
|
|
421
|
+
return;
|
|
422
|
+
const rec = this.record(this.cwd);
|
|
423
|
+
// No record: `bir down` removed it (stopped on purpose, so it stays
|
|
424
|
+
// stopped), or no recorder ever ran here. A live pid: busy, or back.
|
|
425
|
+
if (!rec)
|
|
426
|
+
return;
|
|
427
|
+
if (this.alive(rec.pid)) {
|
|
428
|
+
this.recorderSeenAlive();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const every = Math.min(REVIVE_EVERY_MS * 2 ** Math.min(this.revivesInARow, 10), REVIVE_MAX_EVERY_MS);
|
|
432
|
+
this.reviveNotBefore = this.now() + every;
|
|
433
|
+
if (!this.installed(this.cwd))
|
|
434
|
+
return;
|
|
435
|
+
const reach = await this.probe(rec.url);
|
|
436
|
+
// Something answers there (a recorder that has not rewritten the file
|
|
437
|
+
// yet), or it is slow to: not ours to replace.
|
|
438
|
+
if (reach !== "refused" || this.stopped)
|
|
439
|
+
return;
|
|
440
|
+
logLine("scenario.recorder_revive", {
|
|
441
|
+
pid: rec.pid,
|
|
442
|
+
url: rec.url,
|
|
443
|
+
why,
|
|
444
|
+
detail: "the project's recorder is gone mid-session; starting it again so its hooks answer",
|
|
445
|
+
});
|
|
446
|
+
this.revives += 1;
|
|
447
|
+
this.revivesInARow += 1;
|
|
448
|
+
this.lastReviveAt = this.now();
|
|
449
|
+
await this.launch(this.cwd);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
logLine("scenario.revive_failed", { error: errText(err) });
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
this.reviveBusy = false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
//# sourceMappingURL=scenario-liveness.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
|
|
3
|
+
* of a `bir` it spawned for a scenario tool.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of line arrive there, mixed: `bir`'s audit lines
|
|
6
|
+
* (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
|
|
7
|
+
* and a person look, and the sentences `bir` wrote for a person
|
|
8
|
+
* ("[bir] not signed in — run `bir login`"), which the model needs when the
|
|
9
|
+
* command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
|
|
10
|
+
* straight on, and of the rest only the LAST so many characters are kept.
|
|
11
|
+
*
|
|
12
|
+
* WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
|
|
13
|
+
* 8 000 characters kept, lost exactly the line that mattered: with
|
|
14
|
+
* `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
|
|
15
|
+
* the last thing it prints — never reached the model, which got "exited with
|
|
16
|
+
* code 1" and nothing to act on.
|
|
17
|
+
*
|
|
18
|
+
* Its own module so it can be tested: bir-scenario starts serving on import.
|
|
19
|
+
*/
|
|
20
|
+
/** `bir`'s own audit lines — for the log, not for the model. */
|
|
21
|
+
export declare const AUDIT_LINE: RegExp;
|
|
22
|
+
export interface StderrSorterOptions {
|
|
23
|
+
/** How much of the non-audit text is kept: the last this many characters. */
|
|
24
|
+
keepChars: number;
|
|
25
|
+
/** How much audit text is passed on, at most; the rest is dropped. */
|
|
26
|
+
forwardChars: number;
|
|
27
|
+
/** Where an audit line goes (one line, without its newline). Must not throw. */
|
|
28
|
+
forward: (line: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare class StderrSorter {
|
|
31
|
+
private readonly opts;
|
|
32
|
+
private partial;
|
|
33
|
+
/** Where the rest of a line cut for length goes, once its start was sorted. */
|
|
34
|
+
private continuing;
|
|
35
|
+
private kept;
|
|
36
|
+
private forwarded;
|
|
37
|
+
constructor(opts: StderrSorterOptions);
|
|
38
|
+
push(chunk: string): void;
|
|
39
|
+
/** Everything that was not an audit line (the last of it), once the stream ended. */
|
|
40
|
+
end(): string;
|
|
41
|
+
private line;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=scenario-stderr.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scenario-stderr — how the `bir` MCP server (bir-scenario) reads the stderr
|
|
3
|
+
* of a `bir` it spawned for a scenario tool.
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of line arrive there, mixed: `bir`'s audit lines
|
|
6
|
+
* (`[bir] <iso time> <event> …`), which belong in the log where `bir doctor`
|
|
7
|
+
* and a person look, and the sentences `bir` wrote for a person
|
|
8
|
+
* ("[bir] not signed in — run `bir login`"), which the model needs when the
|
|
9
|
+
* command failed. They are sorted line by line AS THEY ARRIVE: audit lines go
|
|
10
|
+
* straight on, and of the rest only the LAST so many characters are kept.
|
|
11
|
+
*
|
|
12
|
+
* WHY AS THEY ARRIVE, AND WHY THE LAST. Sorting after the exit, with the first
|
|
13
|
+
* 8 000 characters kept, lost exactly the line that mattered: with
|
|
14
|
+
* `BIR_VERBOSE=1` the audit lines filled the cap, and `bir`'s closing reason —
|
|
15
|
+
* the last thing it prints — never reached the model, which got "exited with
|
|
16
|
+
* code 1" and nothing to act on.
|
|
17
|
+
*
|
|
18
|
+
* Its own module so it can be tested: bir-scenario starts serving on import.
|
|
19
|
+
*/
|
|
20
|
+
/** `bir`'s own audit lines — for the log, not for the model. */
|
|
21
|
+
export const AUDIT_LINE = /^\[bir\] \d{4}-\d\d-\d\dT/;
|
|
22
|
+
export class StderrSorter {
|
|
23
|
+
opts;
|
|
24
|
+
partial = "";
|
|
25
|
+
/** Where the rest of a line cut for length goes, once its start was sorted. */
|
|
26
|
+
continuing;
|
|
27
|
+
kept = "";
|
|
28
|
+
forwarded = 0;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.opts = opts;
|
|
31
|
+
}
|
|
32
|
+
push(chunk) {
|
|
33
|
+
const lines = `${this.partial}${chunk}`.split("\n");
|
|
34
|
+
this.partial = lines.pop() ?? "";
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
this.line(line);
|
|
37
|
+
this.continuing = undefined;
|
|
38
|
+
}
|
|
39
|
+
// A "line" with no end in sight is sorted in pieces rather than held whole.
|
|
40
|
+
if (this.partial.length > this.opts.keepChars) {
|
|
41
|
+
this.line(this.partial);
|
|
42
|
+
this.partial = "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Everything that was not an audit line (the last of it), once the stream ended. */
|
|
46
|
+
end() {
|
|
47
|
+
if (this.partial !== "")
|
|
48
|
+
this.line(this.partial);
|
|
49
|
+
this.partial = "";
|
|
50
|
+
this.continuing = undefined;
|
|
51
|
+
return this.kept;
|
|
52
|
+
}
|
|
53
|
+
line(raw) {
|
|
54
|
+
const line = raw.replace(/\r$/, "");
|
|
55
|
+
const kind = this.continuing ?? (AUDIT_LINE.test(line) ? "audit" : "kept");
|
|
56
|
+
this.continuing = kind;
|
|
57
|
+
if (line.trim() === "")
|
|
58
|
+
return;
|
|
59
|
+
if (kind === "audit") {
|
|
60
|
+
if (this.forwarded >= this.opts.forwardChars)
|
|
61
|
+
return;
|
|
62
|
+
this.forwarded += line.length + 1;
|
|
63
|
+
try {
|
|
64
|
+
this.opts.forward(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// The log is gone; nothing to do about that here.
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.kept += `${line}\n`;
|
|
72
|
+
if (this.kept.length > this.opts.keepChars)
|
|
73
|
+
this.kept = this.kept.slice(-this.opts.keepChars);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=scenario-stderr.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* service-read — what `bir scenario show|edits` and `bir investigate` share
|
|
3
|
+
* about asking the service and printing its answer, so a slow service, a
|
|
4
|
+
* signed-out machine or a large scenario never turns into a stall or a flood.
|
|
5
|
+
*
|
|
6
|
+
* WHY A BOUND OF THEIR OWN. The `service()` these commands are handed settles
|
|
7
|
+
* the service address with a `/health` probe, may refresh the session, and
|
|
8
|
+
* then makes its request; a service (or a corporate proxy in front of it) that
|
|
9
|
+
* accepts the connection and never answers held every one of those for as long
|
|
10
|
+
* as Node's fetch waits — measured at 120 s through the `bir` MCP server, which
|
|
11
|
+
* is 120 s of a turn stalled on a debugging tool. A read is safe to abandon:
|
|
12
|
+
* nothing was asked to change. So each one gets {@link readTimeoutMs} — a
|
|
13
|
+
* command that reads twice shares it between them ({@link ReadClock}) — and
|
|
14
|
+
* then an answer in words. Writes (`check`, `edit`, `undo`, `calc`) are not
|
|
15
|
+
* bounded here — abandoning one would leave "was it saved?" unanswerable — and
|
|
16
|
+
* the MCP server bounds the whole command instead.
|
|
17
|
+
*
|
|
18
|
+
* WHY COMPACT JSON UNDER THE MCP SERVER. The `bir` MCP server runs these
|
|
19
|
+
* commands with `BIR_MCP=1` and hands their `--json` output to the model.
|
|
20
|
+
* Indentation is for a person at a terminal; in a model's context it is a
|
|
21
|
+
* quarter of the tokens and none of the meaning.
|
|
22
|
+
*/
|
|
23
|
+
/** Default bound on one command's reads of the service. `BIR_READ_TIMEOUT_MS` overrides it. */
|
|
24
|
+
export declare const DEFAULT_READ_TIMEOUT_MS = 15000;
|
|
25
|
+
/**
|
|
26
|
+
* The most any timer here is set for from the environment. Node turns a delay
|
|
27
|
+
* past 2^31 - 1 ms into 1 ms, so a mistyped `BIR_READ_TIMEOUT_MS=3000000000`
|
|
28
|
+
* used to make every read give up at once; ten minutes is already far longer
|
|
29
|
+
* than anybody waits for a debugging tool.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_ENV_TIMEOUT_MS = 600000;
|
|
32
|
+
/**
|
|
33
|
+
* A number of milliseconds from the environment: positive and at most
|
|
34
|
+
* {@link MAX_ENV_TIMEOUT_MS}, or `fallback`. Out of range is a mistake, and
|
|
35
|
+
* the default is the value known to work.
|
|
36
|
+
*/
|
|
37
|
+
export declare function envTimeoutMs(name: string, fallback: number): number;
|
|
38
|
+
export declare function readTimeoutMs(): number;
|
|
39
|
+
/** True when the `bir` MCP server ran this command for a model (it sets `BIR_MCP=1`). */
|
|
40
|
+
export declare function underMcp(): boolean;
|
|
41
|
+
/** `--json` output: indented for a person, compact for a model. */
|
|
42
|
+
export declare function jsonText(value: unknown): string;
|
|
43
|
+
/**
|
|
44
|
+
* `pending`, or `{ error }` when it takes longer than `ms`. The request itself
|
|
45
|
+
* is not cancelled — `bir` exits as soon as the command returns, which is what
|
|
46
|
+
* ends it — and a late rejection is observed here, so it can never surface as
|
|
47
|
+
* an unhandled one. `statedMs` is the bound the answer names, when `ms` is
|
|
48
|
+
* only what was left of it ({@link ReadClock}).
|
|
49
|
+
*/
|
|
50
|
+
export declare function readWithin<T>(pending: Promise<T | {
|
|
51
|
+
error: string;
|
|
52
|
+
}>, ms?: number, statedMs?: number): Promise<T | {
|
|
53
|
+
error: string;
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* One command's reads of the service, sharing one bound. A command that reads
|
|
57
|
+
* twice — `scenario edits` on a `run_` id finds the scenario, then its
|
|
58
|
+
* history; `investigate` tries the matched run after a "no" — used to give
|
|
59
|
+
* each read the whole bound, so a service that answered the first slowly and
|
|
60
|
+
* held the second took twice as long as the `bir` MCP server allows a
|
|
61
|
+
* reading tool, and was killed there with none of the answer the command
|
|
62
|
+
* already had (the journal half of `investigate`, say). Sharing the bound
|
|
63
|
+
* keeps the whole command inside it, and the timeout still names the bound,
|
|
64
|
+
* not the scrap of it the last read had left.
|
|
65
|
+
*/
|
|
66
|
+
export declare class ReadClock {
|
|
67
|
+
readonly totalMs: number;
|
|
68
|
+
private readonly deadline;
|
|
69
|
+
constructor(totalMs?: number);
|
|
70
|
+
/** What is left of the bound, in ms (0 once it is spent). */
|
|
71
|
+
remaining(): number;
|
|
72
|
+
/** {@link readWithin}, with what is left of the command's bound. */
|
|
73
|
+
within<T>(pending: Promise<T | {
|
|
74
|
+
error: string;
|
|
75
|
+
}>): Promise<T | {
|
|
76
|
+
error: string;
|
|
77
|
+
}>;
|
|
78
|
+
}
|
|
79
|
+
/** Whether a `{ error }` means the service could not be reached at all, as opposed to answering. */
|
|
80
|
+
export declare function unreachable(error: string): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* A failure to reach the service, in words a person (or a model) can act on.
|
|
83
|
+
* The transport's own words — "fetch failed", "This operation was aborted" —
|
|
84
|
+
* say nothing about what to do, so they are replaced; the sentences `bir`
|
|
85
|
+
* itself wrote ("not signed in — run `bir login`") are already the answer.
|
|
86
|
+
*/
|
|
87
|
+
export declare function serviceProblem(error: string): string;
|
|
88
|
+
//# sourceMappingURL=service-read.d.ts.map
|