@basein/runner 0.2.2 → 0.2.4
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/dist/bin/bir.js +19 -0
- package/dist/control/client.d.ts +13 -1
- package/dist/control/client.js +7 -4
- package/dist/control/correlation.d.ts +2 -0
- package/dist/control/server.d.ts +8 -0
- package/dist/control/server.js +35 -2
- package/dist/proxy/session.d.ts +2 -0
- package/dist/proxy/session.js +8 -1
- package/dist/record/recorder.d.ts +11 -0
- package/dist/record/remote-recorder.js +4 -0
- package/dist/replay/controller.d.ts +6 -0
- package/dist/replay/controller.js +63 -23
- package/dist/replay/executor.d.ts +82 -11
- package/dist/replay/executor.js +232 -39
- package/dist/replay/targets.d.ts +52 -0
- package/dist/replay/targets.js +133 -0
- package/package.json +1 -1
package/dist/bin/bir.js
CHANGED
|
@@ -576,6 +576,17 @@ async function doctor(args) {
|
|
|
576
576
|
problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
|
|
577
577
|
`\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
|
|
578
578
|
}
|
|
579
|
+
// THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
|
|
580
|
+
// at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
|
|
581
|
+
// `bir` entry that starts with `npx` has to resolve and unpack the package
|
|
582
|
+
// before the tool exists at all — measured at ~13s, against a first tool call
|
|
583
|
+
// ~6s in. The turn does not fail; it just quietly runs the ordinary way, which
|
|
584
|
+
// is the failure this whole command exists to make visible.
|
|
585
|
+
const scenarioEntry = entries.find((e) => e.name === SCENARIO_SERVER_KEY);
|
|
586
|
+
if (scenarioEntry && !isRemote(scenarioEntry.config) && scenarioEntry.config.command === "npx") {
|
|
587
|
+
notes.push("the bir scenario server starts via npx, which can take longer than the first tool call " +
|
|
588
|
+
"of a turn — reinstall with `bir install --replay --global` to pin it to the installed copy");
|
|
589
|
+
}
|
|
579
590
|
const sidecar = readSidecar();
|
|
580
591
|
if (sidecar.controlPort && discovery && !discovery.url.endsWith(`:${sidecar.controlPort}`)) {
|
|
581
592
|
problems.push(`hooks were installed for port ${sidecar.controlPort} but the control server is on ${discovery.url}`);
|
|
@@ -796,6 +807,14 @@ async function replayCommand(args) {
|
|
|
796
807
|
}
|
|
797
808
|
const r = body;
|
|
798
809
|
out(`params ${JSON.stringify(r.params ?? {})}`);
|
|
810
|
+
// The difference between "this scenario works" and "this scenario works on
|
|
811
|
+
// last week's values". A dry replay fills an unnamed target from the
|
|
812
|
+
// recording; a real turn never does, so without this line a green trace here
|
|
813
|
+
// is no evidence at all that a session would steer.
|
|
814
|
+
if (r.targetsFromSamples?.length) {
|
|
815
|
+
out(` ↑ not named by the prompt: ${r.targetsFromSamples.join(", ")}`);
|
|
816
|
+
out(" (a live turn finds these in an earlier step, or does not run)");
|
|
817
|
+
}
|
|
799
818
|
(r.steps ?? []).forEach((s, i) => {
|
|
800
819
|
out(`step ${i} ${s.toolName} ${JSON.stringify(s.input)}`);
|
|
801
820
|
const emitted = Object.keys(s.emitted ?? {});
|
package/dist/control/client.d.ts
CHANGED
|
@@ -23,11 +23,23 @@ export interface ProxyWork {
|
|
|
23
23
|
arguments: unknown;
|
|
24
24
|
timeoutMs: number;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Who a proxy is, sent with every register, poll and step report. The control
|
|
28
|
+
* server uses it to run a session's replay steps on that session's own upstream
|
|
29
|
+
* rather than on whichever proxy polled first (see replay/executor.ts).
|
|
30
|
+
*/
|
|
31
|
+
export interface ProxyIdentity {
|
|
32
|
+
proxyId: string;
|
|
33
|
+
/** Process start, epoch ms. */
|
|
34
|
+
startedAt: number;
|
|
35
|
+
pid: number;
|
|
36
|
+
}
|
|
26
37
|
export declare class ControlClient {
|
|
27
38
|
private readonly url;
|
|
28
39
|
private readonly token;
|
|
29
40
|
private readonly timeoutMs;
|
|
30
|
-
|
|
41
|
+
private readonly identity?;
|
|
42
|
+
constructor(url: string, token: string, timeoutMs?: number, identity?: ProxyIdentity);
|
|
31
43
|
register(info: {
|
|
32
44
|
serverName: string;
|
|
33
45
|
pid: number;
|
package/dist/control/client.js
CHANGED
|
@@ -10,18 +10,21 @@ export class ControlClient {
|
|
|
10
10
|
url;
|
|
11
11
|
token;
|
|
12
12
|
timeoutMs;
|
|
13
|
-
|
|
13
|
+
identity;
|
|
14
|
+
constructor(url, token, timeoutMs = 5_000, identity) {
|
|
14
15
|
this.url = url.replace(/\/+$/, "");
|
|
15
16
|
this.token = token;
|
|
16
17
|
this.timeoutMs = timeoutMs;
|
|
18
|
+
this.identity = identity;
|
|
17
19
|
}
|
|
18
20
|
async register(info) {
|
|
19
|
-
const body = await this.post("/proxy/register", info);
|
|
21
|
+
const body = await this.post("/proxy/register", { ...this.identity, ...info });
|
|
20
22
|
return body;
|
|
21
23
|
}
|
|
22
24
|
/** Report one completed MCP call. Resolves false when the send was dropped. */
|
|
23
25
|
async report(step) {
|
|
24
|
-
|
|
26
|
+
const body = this.identity ? { ...step, proxyId: this.identity.proxyId } : step;
|
|
27
|
+
return (await this.post("/proxy/step", body)) !== undefined;
|
|
25
28
|
}
|
|
26
29
|
async health() {
|
|
27
30
|
return (await this.request("GET", "/health"));
|
|
@@ -36,7 +39,7 @@ export class ControlClient {
|
|
|
36
39
|
* with a small delay so a dead server is not busy-looped.
|
|
37
40
|
*/
|
|
38
41
|
async poll(serverName, holdMs) {
|
|
39
|
-
const body = (await this.request("POST", "/proxy/poll", { serverName, holdMs }, holdMs + 10_000));
|
|
42
|
+
const body = (await this.request("POST", "/proxy/poll", { ...this.identity, serverName, holdMs }, holdMs + 10_000));
|
|
40
43
|
return body?.work;
|
|
41
44
|
}
|
|
42
45
|
/** Hand back one dispatched call's result, or the reason it could not run. */
|
|
@@ -49,6 +49,8 @@ export interface ProxyStepReport {
|
|
|
49
49
|
/** Epoch ms. */
|
|
50
50
|
startedAt: number;
|
|
51
51
|
durationMs: number;
|
|
52
|
+
/** The reporting proxy, added by its control client. Absent from older proxies. */
|
|
53
|
+
proxyId?: string;
|
|
52
54
|
}
|
|
53
55
|
export declare function newCallId(): string;
|
|
54
56
|
/**
|
package/dist/control/server.d.ts
CHANGED
|
@@ -328,6 +328,14 @@ export declare class ControlServer {
|
|
|
328
328
|
* hooks produces and is a complete MCP step in its own right.
|
|
329
329
|
*/
|
|
330
330
|
private onProxyStep;
|
|
331
|
+
/**
|
|
332
|
+
* Tie the reporting proxy to the session whose hook minted the step's call id,
|
|
333
|
+
* so that session's replay steps run on this proxy's upstream and not on
|
|
334
|
+
* another session's (replay/executor.ts). Searches every session, not just the
|
|
335
|
+
* one {@link sessionForProxyStep} records into: with two sessions open, that
|
|
336
|
+
* guess is exactly what cannot be trusted.
|
|
337
|
+
*/
|
|
338
|
+
private learnProxySession;
|
|
331
339
|
/**
|
|
332
340
|
* Which session a proxy's step belongs to.
|
|
333
341
|
*
|
package/dist/control/server.js
CHANGED
|
@@ -443,6 +443,7 @@ export class ControlServer {
|
|
|
443
443
|
const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
|
|
444
444
|
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
445
445
|
});
|
|
446
|
+
state.sessionId = session.sessionId;
|
|
446
447
|
run.replay = state;
|
|
447
448
|
run.replays.push(state);
|
|
448
449
|
return this.replay.directiveFor(state);
|
|
@@ -1077,6 +1078,7 @@ export class ControlServer {
|
|
|
1077
1078
|
liveCall: { toolName, toolInput },
|
|
1078
1079
|
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
1079
1080
|
});
|
|
1081
|
+
state.sessionId = session.sessionId;
|
|
1080
1082
|
if (!state.plan) {
|
|
1081
1083
|
// A declined intent hit reports nothing: the turn's cost is not a
|
|
1082
1084
|
// measurement of that scenario's task, so it is no baseline sample either.
|
|
@@ -1353,6 +1355,8 @@ export class ControlServer {
|
|
|
1353
1355
|
async onSessionEnd(payload) {
|
|
1354
1356
|
const session = this.ensureSession(payload);
|
|
1355
1357
|
await this.finalizeRun(session);
|
|
1358
|
+
// Its proxies outlive it after a `/clear`, and serve the next session.
|
|
1359
|
+
this.replay.work.releaseSession(session.sessionId);
|
|
1356
1360
|
return {};
|
|
1357
1361
|
}
|
|
1358
1362
|
// ── proxy routes ─────────────────────────────────────────────────────────
|
|
@@ -1368,7 +1372,12 @@ export class ControlServer {
|
|
|
1368
1372
|
at: Date.now(),
|
|
1369
1373
|
version: proxyVersion,
|
|
1370
1374
|
});
|
|
1371
|
-
logLine("proxy.registered", {
|
|
1375
|
+
logLine("proxy.registered", {
|
|
1376
|
+
server: serverName,
|
|
1377
|
+
pid: body.pid,
|
|
1378
|
+
version: proxyVersion,
|
|
1379
|
+
proxy: typeof body.proxyId === "string" ? body.proxyId.slice(-8) : undefined,
|
|
1380
|
+
});
|
|
1372
1381
|
// A half-upgraded machine is the update failure that looks like success:
|
|
1373
1382
|
// `npm i -g` replaced the package, but a config still points at an older
|
|
1374
1383
|
// copy, or this hooks process predates the upgrade and was never
|
|
@@ -1413,7 +1422,13 @@ export class ControlServer {
|
|
|
1413
1422
|
if (!this.replay.enabled)
|
|
1414
1423
|
return {};
|
|
1415
1424
|
const hold = Number(body.holdMs);
|
|
1416
|
-
const
|
|
1425
|
+
const startedAt = Number(body.startedAt);
|
|
1426
|
+
const pid = Number(body.pid);
|
|
1427
|
+
const work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal, {
|
|
1428
|
+
proxyId: typeof body.proxyId === "string" ? body.proxyId : undefined,
|
|
1429
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : undefined,
|
|
1430
|
+
pid: Number.isFinite(pid) ? pid : undefined,
|
|
1431
|
+
});
|
|
1417
1432
|
return work ? { work } : {};
|
|
1418
1433
|
}
|
|
1419
1434
|
/** `POST /proxy/result` — the answer to one dispatched `tools/call`. */
|
|
@@ -1478,6 +1493,7 @@ export class ControlServer {
|
|
|
1478
1493
|
* hooks produces and is a complete MCP step in its own right.
|
|
1479
1494
|
*/
|
|
1480
1495
|
onProxyStep(report) {
|
|
1496
|
+
this.learnProxySession(report);
|
|
1481
1497
|
const session = this.sessionForProxyStep();
|
|
1482
1498
|
const run = this.ensureRun(session);
|
|
1483
1499
|
this.wrapped.add(report.serverName);
|
|
@@ -1534,6 +1550,23 @@ export class ControlServer {
|
|
|
1534
1550
|
});
|
|
1535
1551
|
return { stepIndex: pair.selected, merged: false };
|
|
1536
1552
|
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Tie the reporting proxy to the session whose hook minted the step's call id,
|
|
1555
|
+
* so that session's replay steps run on this proxy's upstream and not on
|
|
1556
|
+
* another session's (replay/executor.ts). Searches every session, not just the
|
|
1557
|
+
* one {@link sessionForProxyStep} records into: with two sessions open, that
|
|
1558
|
+
* guess is exactly what cannot be trusted.
|
|
1559
|
+
*/
|
|
1560
|
+
learnProxySession(report) {
|
|
1561
|
+
if (!report.proxyId || !report.callId)
|
|
1562
|
+
return;
|
|
1563
|
+
for (const session of this.sessions.values()) {
|
|
1564
|
+
if (session.run?.correlations.has(report.callId)) {
|
|
1565
|
+
this.replay.work.bindProxy(report.proxyId, session.sessionId);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1537
1570
|
/**
|
|
1538
1571
|
* Which session a proxy's step belongs to.
|
|
1539
1572
|
*
|
package/dist/proxy/session.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface ProxySessionOptions {
|
|
|
47
47
|
}
|
|
48
48
|
export declare class ProxySession {
|
|
49
49
|
readonly serverName: string;
|
|
50
|
+
/** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
|
|
51
|
+
readonly proxyId: string;
|
|
50
52
|
private readonly opts;
|
|
51
53
|
private tierValue;
|
|
52
54
|
private control?;
|
package/dist/proxy/session.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* flushed into whichever owner wins. Nothing is lost to the race, and nothing
|
|
22
22
|
* blocks: relaying never waits on this.
|
|
23
23
|
*/
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
24
25
|
import { hostname } from "node:os";
|
|
25
26
|
import { ControlClient } from "../control/client.js";
|
|
26
27
|
import { resolveControl } from "../control/discovery.js";
|
|
@@ -38,6 +39,8 @@ import { packageVersion } from "../util/version.js";
|
|
|
38
39
|
export const DISCOVERY_WINDOW_MS = 5_000;
|
|
39
40
|
export class ProxySession {
|
|
40
41
|
serverName;
|
|
42
|
+
/** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
|
|
43
|
+
proxyId = "birproxy_" + randomUUID();
|
|
41
44
|
opts;
|
|
42
45
|
tierValue = "pending";
|
|
43
46
|
control;
|
|
@@ -100,7 +103,11 @@ export class ProxySession {
|
|
|
100
103
|
await this.becomeStandalone(`no control server within ${window}ms`);
|
|
101
104
|
return;
|
|
102
105
|
}
|
|
103
|
-
const client = new ControlClient(found.url, found.token
|
|
106
|
+
const client = new ControlClient(found.url, found.token, undefined, {
|
|
107
|
+
proxyId: this.proxyId,
|
|
108
|
+
startedAt: this.startedAt,
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
});
|
|
104
111
|
const registered = await client.register({
|
|
105
112
|
serverName: this.serverName,
|
|
106
113
|
pid: process.pid,
|
|
@@ -263,6 +263,17 @@ export interface ExecutionReport {
|
|
|
263
263
|
* two baselines of one turn. The segments' own rows are untouched.
|
|
264
264
|
*/
|
|
265
265
|
sharedWith?: string[];
|
|
266
|
+
/**
|
|
267
|
+
* Which gate of the ladder declined, on a `not_steered` report.
|
|
268
|
+
*
|
|
269
|
+
* The one thing the recording page could never learn. "Replay on, scenario
|
|
270
|
+
* ready, prompt matched, nothing steered" has exactly one explanation and it
|
|
271
|
+
* used to live only in a stderr line on the machine that decided it — so the
|
|
272
|
+
* owner of a scenario that never runs had nothing to look at. It travels as
|
|
273
|
+
* the gate's own code rather than the sentence beside it: the prose is for a
|
|
274
|
+
* person reading the audit log, this is for the console.
|
|
275
|
+
*/
|
|
276
|
+
declined?: string;
|
|
266
277
|
}
|
|
267
278
|
/** Optional capability: reporting needs a service, and a NullRecorder has none. */
|
|
268
279
|
export interface ScenarioReporter {
|
|
@@ -212,6 +212,9 @@ export class RemoteRecorder {
|
|
|
212
212
|
fallbackKind: report.fallbackKind,
|
|
213
213
|
baselineEligible: report.baselineEligible,
|
|
214
214
|
sharedWith: report.sharedWith,
|
|
215
|
+
// Why nothing ran, when nothing ran. Additive: an older service drops
|
|
216
|
+
// the field and books the cost exactly as it always did.
|
|
217
|
+
declined: report.declined,
|
|
215
218
|
});
|
|
216
219
|
const r = (body ?? {});
|
|
217
220
|
const failed = report.steps?.filter((s) => s.status === "failed").length ?? 0;
|
|
@@ -228,6 +231,7 @@ export class RemoteRecorder {
|
|
|
228
231
|
stepsFailed: failed || undefined,
|
|
229
232
|
baselineEligible: report.baselineEligible === false ? false : undefined,
|
|
230
233
|
sharedWith: report.sharedWith?.length,
|
|
234
|
+
declined: report.declined,
|
|
231
235
|
});
|
|
232
236
|
});
|
|
233
237
|
}
|
|
@@ -158,6 +158,12 @@ export interface ReplayState {
|
|
|
158
158
|
retired: boolean;
|
|
159
159
|
/** What armed it: the prompt, or a ReAct iteration's intent (fallbk.md). */
|
|
160
160
|
armedBy: "prompt" | "intent";
|
|
161
|
+
/**
|
|
162
|
+
* The Claude Code session this plan serves, set by the control server. Its
|
|
163
|
+
* steps run on that session's own proxies (executor.ts). Absent for `bir
|
|
164
|
+
* replay`, which has no session and takes the newest proxy.
|
|
165
|
+
*/
|
|
166
|
+
sessionId?: string;
|
|
161
167
|
/**
|
|
162
168
|
* Which kind of row was handed out (segmented.md R-OUT-7). A `segment` is a
|
|
163
169
|
* named sub-task of a recording rather than a whole task, and it is judged
|
|
@@ -25,6 +25,7 @@ import { ScenarioReplayPlan } from "./plan.js";
|
|
|
25
25
|
import { PRICING_VERSION } from "./pricing.js";
|
|
26
26
|
import { SourceRunOutputs } from "./source-run.js";
|
|
27
27
|
import { clampToFrameStart, flattenChain, } from "./flatten.js";
|
|
28
|
+
import { blockingTargets } from "./targets.js";
|
|
28
29
|
import { toolResultError } from "./tool-error.js";
|
|
29
30
|
import { OUTCOME_RANK, hasTargets, isReadyScenario, } from "./types.js";
|
|
30
31
|
/** The first-party tool a `direct` plan is delivered through (§6.3). */
|
|
@@ -233,29 +234,60 @@ export class ReplayController {
|
|
|
233
234
|
// because it is the only one that spends a model call and waits. A scenario
|
|
234
235
|
// whose parameters are all settings arms at once, as before: there is
|
|
235
236
|
// nothing the turn has to supply.
|
|
237
|
+
//
|
|
238
|
+
// A target the turn did not name stops the plan only when *this* plan could
|
|
239
|
+
// act on its recorded value; a target the chain discovers for itself does
|
|
240
|
+
// not ({@link ./targets.ts}). The unnamed value stays null either way, so
|
|
241
|
+
// nothing here ever runs on last week's.
|
|
236
242
|
if (hasTargets(scenario.paramsObject)) {
|
|
243
|
+
// Of the targets this answer left unnamed, the ones this plan could act
|
|
244
|
+
// on the recorded value of. Empty means the chain finds them for itself.
|
|
245
|
+
const blocking = (missing) => blockingTargets(missing, scenario.paramsObject, planned);
|
|
237
246
|
let result;
|
|
238
247
|
try {
|
|
239
248
|
result = await this.withBudget(derivation, this.budgets.deriveMs, "derivation");
|
|
240
249
|
}
|
|
241
250
|
catch (err) {
|
|
242
|
-
// Out of budget, or the call threw.
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
251
|
+
// Out of budget, or the call threw. The turn has not said what this task
|
|
252
|
+
// is to act on, so every target is unnamed (R-PARAM-3).
|
|
253
|
+
const blocked = blocking(targetKeys(scenario.paramsObject));
|
|
254
|
+
if (blocked.length > 0) {
|
|
255
|
+
return decline(`target ${blocked[0]} not found — ${errText(err)}`, "missing_target");
|
|
256
|
+
}
|
|
257
|
+
logLine("replay.targets_unread", {
|
|
258
|
+
scenario: scenario.id,
|
|
259
|
+
why: "the derivation did not answer, and this chain takes no target from the caller",
|
|
260
|
+
error: errText(err),
|
|
261
|
+
});
|
|
247
262
|
}
|
|
248
|
-
if (!result.derived) {
|
|
263
|
+
if (result && !result.derived) {
|
|
249
264
|
// Nobody read the turn — no key here and no service to ask, or the
|
|
250
265
|
// service declined. Settings can still take their recorded values, but a
|
|
251
266
|
// target is a guess nobody is allowed to make (R-PARAM-5). The reason
|
|
252
267
|
// travels into the line, because "it did not run" without one is how
|
|
253
268
|
// this stayed invisible before.
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
//
|
|
270
|
+
// Every target is judged, not `missing`: an answer that derived nothing
|
|
271
|
+
// carries the recorded sample for *all* of them, so a chain that reads
|
|
272
|
+
// one would run on last week's value rather than on nothing.
|
|
273
|
+
const blocked = blocking(targetKeys(scenario.paramsObject));
|
|
274
|
+
if (blocked.length > 0) {
|
|
275
|
+
return decline(`${result.reason ?? "no_derive_key"}: target ${blocked[0]}`, "no_derive_key");
|
|
276
|
+
}
|
|
256
277
|
}
|
|
257
|
-
if (result.missing.length > 0) {
|
|
258
|
-
|
|
278
|
+
else if (result && result.missing.length > 0) {
|
|
279
|
+
const blocked = blocking(result.missing);
|
|
280
|
+
if (blocked.length > 0) {
|
|
281
|
+
return decline(`target ${blocked[0]} not found`, "missing_target");
|
|
282
|
+
}
|
|
283
|
+
// Worth a line of its own: this is the difference between a scenario
|
|
284
|
+
// that never runs and one that does, and it is the first thing to look
|
|
285
|
+
// at when a replay acts on the wrong thing.
|
|
286
|
+
logLine("replay.targets_found_by_chain", {
|
|
287
|
+
scenario: scenario.id,
|
|
288
|
+
unnamed: result.missing.join(","),
|
|
289
|
+
why: "the chain computes these from its own steps; none is read from the caller",
|
|
290
|
+
});
|
|
259
291
|
}
|
|
260
292
|
}
|
|
261
293
|
// A plan armed by intent runs inside a task the agent is already doing, so
|
|
@@ -584,7 +616,7 @@ export class ReplayController {
|
|
|
584
616
|
const deadline = Date.now() + this.budgets.planMs;
|
|
585
617
|
let result;
|
|
586
618
|
try {
|
|
587
|
-
result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
|
|
619
|
+
result = await plan.runToCompletion(this.executeStep(state), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, this.observeStep(state), deadline, MAX_REPLAY_REASON, { stopOnFailure: true });
|
|
588
620
|
}
|
|
589
621
|
catch (err) {
|
|
590
622
|
// The scenario's own logic failed. Retire and let the model do the work.
|
|
@@ -681,10 +713,14 @@ export class ReplayController {
|
|
|
681
713
|
const total = state.deriveCostUsd + d.sessionCostUsd + state.fallbackCostUsd;
|
|
682
714
|
const steps = this.stepResultsOf(state);
|
|
683
715
|
const isBaseline = state.outcome === "not_steered" || state.outcome === "failed";
|
|
684
|
-
// A costless decline is noise on both sides — *unless*
|
|
685
|
-
//
|
|
686
|
-
// service accepts a costless report that
|
|
687
|
-
|
|
716
|
+
// A costless decline is noise on both sides — *unless* it says something the
|
|
717
|
+
// recording page cannot learn any other way: a step that actually broke, or
|
|
718
|
+
// the gate that declined. The service accepts a costless report that carries
|
|
719
|
+
// either (its errorshandling.md).
|
|
720
|
+
if (isBaseline &&
|
|
721
|
+
total <= 0 &&
|
|
722
|
+
!state.declined &&
|
|
723
|
+
!steps?.some((s) => s.status === "failed")) {
|
|
688
724
|
return undefined;
|
|
689
725
|
}
|
|
690
726
|
const blame = this.blameStep(steps);
|
|
@@ -709,6 +745,10 @@ export class ReplayController {
|
|
|
709
745
|
errorStage: blame?.stage,
|
|
710
746
|
errorStepIndex: blame?.stepIndex,
|
|
711
747
|
errorToolName: blame?.toolName,
|
|
748
|
+
// Which gate said no. Only ever set on a decline, and the decline is
|
|
749
|
+
// always `not_steered`, so a steered report carries it byte-identically
|
|
750
|
+
// to before.
|
|
751
|
+
declined: state.declined,
|
|
712
752
|
// Where the plan handed the task to the model (fallbk.md). Only on
|
|
713
753
|
// `fell_back`: a hand-over before any step ran is `failed`, and a parked
|
|
714
754
|
// step found after a divergence is still a divergence.
|
|
@@ -849,7 +889,7 @@ export class ReplayController {
|
|
|
849
889
|
}
|
|
850
890
|
const trace = [];
|
|
851
891
|
try {
|
|
852
|
-
const result = await plan.runToCompletion(this.executeStep(), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
|
|
892
|
+
const result = await plan.runToCompletion(this.executeStep(state), state.sourceRun ? (entry) => state.sourceRun.outputFor(entry) : undefined, (info) => {
|
|
853
893
|
this.observeStep(state)(info);
|
|
854
894
|
trace.push({
|
|
855
895
|
step: info.step.stepIndex,
|
|
@@ -983,7 +1023,7 @@ export class ReplayController {
|
|
|
983
1023
|
this.retire(state, undefined);
|
|
984
1024
|
let composed;
|
|
985
1025
|
try {
|
|
986
|
-
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
|
|
1026
|
+
composed = await plan.composeBundle(MAX_REPLAY_REASON, this.executeStep(state), state.sourceRun ? (step) => state.sourceRun.outputFor(step) : undefined, this.observeStep(state),
|
|
987
1027
|
// A parked step ends the bundle short of the task (fallbk.md D3).
|
|
988
1028
|
{ handover: plan.stopsEarly() });
|
|
989
1029
|
}
|
|
@@ -1135,13 +1175,13 @@ export class ReplayController {
|
|
|
1135
1175
|
* under which a recorded output may stand in. A tool that ran and failed
|
|
1136
1176
|
* resolves with its failure as the response, exactly as it would in a session.
|
|
1137
1177
|
*/
|
|
1138
|
-
executeStep() {
|
|
1178
|
+
executeStep(state) {
|
|
1139
1179
|
return async (step, input) => {
|
|
1140
1180
|
const mcp = parseQualifiedName(step.toolName ?? "");
|
|
1141
1181
|
if (!mcp) {
|
|
1142
1182
|
throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
|
|
1143
1183
|
}
|
|
1144
|
-
const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs);
|
|
1184
|
+
const result = await this.work.call(mcp.serverName, mcp.toolName, input, this.budgets.stepMs, { sessionId: state.sessionId });
|
|
1145
1185
|
// Serialize exactly as the proxy records it, or `toolOutputLogic` — which
|
|
1146
1186
|
// was authored against that shape — silently derives nothing (§7.2).
|
|
1147
1187
|
return serializeCapped(redact(result));
|
|
@@ -1292,11 +1332,11 @@ export class ReplayController {
|
|
|
1292
1332
|
});
|
|
1293
1333
|
}
|
|
1294
1334
|
}
|
|
1295
|
-
/**
|
|
1296
|
-
function
|
|
1335
|
+
/** Every target of a schema. A parameter with no kind is one (R-PARAM-3). */
|
|
1336
|
+
function targetKeys(schema) {
|
|
1297
1337
|
if (!schema)
|
|
1298
|
-
return
|
|
1299
|
-
return Object.keys(schema).
|
|
1338
|
+
return [];
|
|
1339
|
+
return Object.keys(schema).filter((k) => schema[k]?.kind !== "setting");
|
|
1300
1340
|
}
|
|
1301
1341
|
/**
|
|
1302
1342
|
* Position of the first step whose failure count is above `maxStepFailures`
|
|
@@ -18,6 +18,36 @@
|
|
|
18
18
|
* A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
|
|
19
19
|
* is not an error: `call` rejects with `no_proxy`, and the caller falls back to
|
|
20
20
|
* the step's recorded output.
|
|
21
|
+
*
|
|
22
|
+
* WHICH PROXY. Several proxies can poll for the same server at once — one per
|
|
23
|
+
* Claude Code session in the project, and a session's proxy outlives a
|
|
24
|
+
* `bir-hooks` restart by simply re-polling. They are NOT interchangeable: each
|
|
25
|
+
* holds its own upstream, and another session's upstream may be in any state (a
|
|
26
|
+
* database connection a benchmark reset killed, a browser on another page).
|
|
27
|
+
* Handing a step to "whoever is parked first" therefore ran it on the oldest
|
|
28
|
+
* proxy in the project, which is precisely the one most likely to be stale. So
|
|
29
|
+
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
|
+
*
|
|
31
|
+
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
+
* proxy reported, or from an earlier step of this session it ran;
|
|
33
|
+
* 2. otherwise the newest unbound proxy (latest process start) — a session's
|
|
34
|
+
* proxies are spawned when it starts, so the newest is the best guess for
|
|
35
|
+
* a session that has not called the server yet;
|
|
36
|
+
* 3. otherwise NOBODY. A proxy bound to another session is never used for
|
|
37
|
+
* this one: the step resolves as a tool error, so the plan stops and hands
|
|
38
|
+
* over to the agent, who calls the tool on its own upstream. It does not
|
|
39
|
+
* reject — a rejection lets a recorded output stand in, or skips the step
|
|
40
|
+
* and runs the rest of the plan past it.
|
|
41
|
+
*
|
|
42
|
+
* A session's bindings are released when it ends (a `/clear` starts a new
|
|
43
|
+
* session on the same proxies). Legacy proxies, which send no id, share one
|
|
44
|
+
* record per server and are never bound: they stay a pool anyone may use.
|
|
45
|
+
*
|
|
46
|
+
* `bir replay` has no session, and takes the newest proxy, bound or not.
|
|
47
|
+
*
|
|
48
|
+
* The choice is made against proxies that are *present*, parked or not, and the
|
|
49
|
+
* work is then held for that proxy if it is mid-round-trip — never handed to a
|
|
50
|
+
* different one that happens to be parked.
|
|
21
51
|
*/
|
|
22
52
|
/** One dispatched tool call, as the proxy receives it. */
|
|
23
53
|
export interface Work {
|
|
@@ -27,30 +57,47 @@ export interface Work {
|
|
|
27
57
|
arguments: unknown;
|
|
28
58
|
timeoutMs: number;
|
|
29
59
|
}
|
|
60
|
+
/** Who a dispatched step is for — decides which proxy runs it. */
|
|
61
|
+
export interface WorkRoute {
|
|
62
|
+
/** The Claude Code session whose plan this step belongs to. */
|
|
63
|
+
sessionId?: string;
|
|
64
|
+
}
|
|
65
|
+
/** What a polling proxy says about itself. All optional: older proxies send none. */
|
|
66
|
+
export interface PollerIdentity {
|
|
67
|
+
/** Stable for the proxy process's lifetime. */
|
|
68
|
+
proxyId?: string;
|
|
69
|
+
/** When the proxy process started, epoch ms — "newest" is judged by this. */
|
|
70
|
+
startedAt?: number;
|
|
71
|
+
pid?: number;
|
|
72
|
+
}
|
|
30
73
|
/** Rejection reason when no proxy is polling for a server. */
|
|
31
74
|
export declare const NO_PROXY = "no_proxy";
|
|
32
75
|
export declare class ProxyWorkQueue {
|
|
33
|
-
/** Work
|
|
76
|
+
/** Work held for a proxy that was mid-round-trip when it was dispatched, per proxy id. */
|
|
34
77
|
private readonly pending;
|
|
35
78
|
/** Pollers currently parked, per server. */
|
|
36
79
|
private readonly waiters;
|
|
37
80
|
/** Work handed out and awaiting a result. */
|
|
38
81
|
private readonly inFlight;
|
|
39
|
-
/**
|
|
40
|
-
private readonly
|
|
82
|
+
/** Every proxy that has polled, by id. A proxy between polls is still present. */
|
|
83
|
+
private readonly proxies;
|
|
41
84
|
private closed;
|
|
42
85
|
/** Servers a proxy is currently serving — what `bir doctor` reports. */
|
|
43
86
|
pollingServers(): string[];
|
|
44
87
|
/** True when a proxy for `serverName` is available to take work. */
|
|
45
88
|
hasPoller(serverName: string): boolean;
|
|
46
|
-
/**
|
|
47
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Record that `proxyId` serves `sessionId`: it reported a call whose id the
|
|
91
|
+
* session's own hook minted. Proof, so it replaces any earlier binding — a
|
|
92
|
+
* `/clear` starts a new session on the same proxies.
|
|
93
|
+
*/
|
|
94
|
+
bindProxy(proxyId: string, sessionId: string): void;
|
|
48
95
|
/**
|
|
49
96
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
50
|
-
* deadline so the proxy re-polls. Work
|
|
51
|
-
* polls is handed over immediately.
|
|
97
|
+
* deadline so the proxy re-polls. Work held for this proxy while it was
|
|
98
|
+
* between polls is handed over immediately.
|
|
52
99
|
*/
|
|
53
|
-
waitForWork(serverName: string, pollDeadlineMs: number, signal?: AbortSignal): Promise<Work | undefined>;
|
|
100
|
+
waitForWork(serverName: string, pollDeadlineMs: number, signal?: AbortSignal, who?: PollerIdentity): Promise<Work | undefined>;
|
|
54
101
|
/** `POST /proxy/result`. Unknown ids are ignored — a late result after a timeout. */
|
|
55
102
|
complete(workId: string, result?: unknown, error?: string): boolean;
|
|
56
103
|
/**
|
|
@@ -61,16 +108,40 @@ export declare class ProxyWorkQueue {
|
|
|
61
108
|
* "could not be run here", which is exactly the condition under which the
|
|
62
109
|
* caller may substitute a recorded output.
|
|
63
110
|
*/
|
|
64
|
-
call(serverName: string, toolName: string, args: unknown, timeoutMs: number): Promise<unknown>;
|
|
111
|
+
call(serverName: string, toolName: string, args: unknown, timeoutMs: number, route?: WorkRoute): Promise<unknown>;
|
|
65
112
|
/** Remove queued work that timed out, so a later poll never gets stale work. */
|
|
66
113
|
private dropPending;
|
|
67
114
|
/** Fail everything in flight and release every poller. */
|
|
68
115
|
close(): void;
|
|
116
|
+
/** Note a poll: create the proxy's record on first sight, refresh it after. */
|
|
117
|
+
private touch;
|
|
118
|
+
/** Parked now, or polled recently enough to be mid-round-trip. */
|
|
119
|
+
private isLive;
|
|
120
|
+
/** Live proxies for a server, newest first. */
|
|
121
|
+
private present;
|
|
122
|
+
/**
|
|
123
|
+
* The proxy a step for `sessionId` should run on — see the file comment.
|
|
124
|
+
* Undefined when no proxy is live at all; `other_sessions` when every live
|
|
125
|
+
* one belongs to a different session.
|
|
126
|
+
*/
|
|
127
|
+
private pickProxy;
|
|
69
128
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
129
|
+
* Free every proxy bound to `sessionId`: the session ended. After a `/clear`
|
|
130
|
+
* the same proxies serve the next session, which must be able to pick them.
|
|
131
|
+
*/
|
|
132
|
+
releaseSession(sessionId: string): void;
|
|
133
|
+
/**
|
|
134
|
+
* A live parked poller of `proxyId`. Waiters whose request has already gone
|
|
135
|
+
* are discarded rather than handed work they can never run.
|
|
72
136
|
*/
|
|
73
137
|
private takeWaiter;
|
|
138
|
+
/**
|
|
139
|
+
* A proxy whose poll request closed has, almost always, exited. Forget it
|
|
140
|
+
* unless another of its polls is still parked, so it is never picked for a
|
|
141
|
+
* minute after it died. A proxy that merely lost one request re-registers on
|
|
142
|
+
* its next poll.
|
|
143
|
+
*/
|
|
144
|
+
private forgetIfGone;
|
|
74
145
|
private removeWaiter;
|
|
75
146
|
/** Drop a waiter's timer and abort listener. Idempotent. */
|
|
76
147
|
private detach;
|
package/dist/replay/executor.js
CHANGED
|
@@ -18,6 +18,36 @@
|
|
|
18
18
|
* A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
|
|
19
19
|
* is not an error: `call` rejects with `no_proxy`, and the caller falls back to
|
|
20
20
|
* the step's recorded output.
|
|
21
|
+
*
|
|
22
|
+
* WHICH PROXY. Several proxies can poll for the same server at once — one per
|
|
23
|
+
* Claude Code session in the project, and a session's proxy outlives a
|
|
24
|
+
* `bir-hooks` restart by simply re-polling. They are NOT interchangeable: each
|
|
25
|
+
* holds its own upstream, and another session's upstream may be in any state (a
|
|
26
|
+
* database connection a benchmark reset killed, a browser on another page).
|
|
27
|
+
* Handing a step to "whoever is parked first" therefore ran it on the oldest
|
|
28
|
+
* proxy in the project, which is precisely the one most likely to be stale. So
|
|
29
|
+
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
|
+
*
|
|
31
|
+
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
+
* proxy reported, or from an earlier step of this session it ran;
|
|
33
|
+
* 2. otherwise the newest unbound proxy (latest process start) — a session's
|
|
34
|
+
* proxies are spawned when it starts, so the newest is the best guess for
|
|
35
|
+
* a session that has not called the server yet;
|
|
36
|
+
* 3. otherwise NOBODY. A proxy bound to another session is never used for
|
|
37
|
+
* this one: the step resolves as a tool error, so the plan stops and hands
|
|
38
|
+
* over to the agent, who calls the tool on its own upstream. It does not
|
|
39
|
+
* reject — a rejection lets a recorded output stand in, or skips the step
|
|
40
|
+
* and runs the rest of the plan past it.
|
|
41
|
+
*
|
|
42
|
+
* A session's bindings are released when it ends (a `/clear` starts a new
|
|
43
|
+
* session on the same proxies). Legacy proxies, which send no id, share one
|
|
44
|
+
* record per server and are never bound: they stay a pool anyone may use.
|
|
45
|
+
*
|
|
46
|
+
* `bir replay` has no session, and takes the newest proxy, bound or not.
|
|
47
|
+
*
|
|
48
|
+
* The choice is made against proxies that are *present*, parked or not, and the
|
|
49
|
+
* work is then held for that proxy if it is mid-round-trip — never handed to a
|
|
50
|
+
* different one that happens to be parked.
|
|
21
51
|
*/
|
|
22
52
|
import { randomUUID } from "node:crypto";
|
|
23
53
|
import { logDetail } from "../util/log.js";
|
|
@@ -32,44 +62,70 @@ export const NO_PROXY = "no_proxy";
|
|
|
32
62
|
* plan lose every step after the first.
|
|
33
63
|
*/
|
|
34
64
|
const PRESENT_MS = 60_000;
|
|
65
|
+
/**
|
|
66
|
+
* The id a proxy that sends none polls under. One per server, so older proxies
|
|
67
|
+
* keep the old behaviour between themselves: any of them may take the work.
|
|
68
|
+
*/
|
|
69
|
+
function legacyId(serverName) {
|
|
70
|
+
return `legacy:${serverName}`;
|
|
71
|
+
}
|
|
35
72
|
export class ProxyWorkQueue {
|
|
36
|
-
/** Work
|
|
73
|
+
/** Work held for a proxy that was mid-round-trip when it was dispatched, per proxy id. */
|
|
37
74
|
pending = new Map();
|
|
38
75
|
/** Pollers currently parked, per server. */
|
|
39
76
|
waiters = new Map();
|
|
40
77
|
/** Work handed out and awaiting a result. */
|
|
41
78
|
inFlight = new Map();
|
|
42
|
-
/**
|
|
43
|
-
|
|
79
|
+
/** Every proxy that has polled, by id. A proxy between polls is still present. */
|
|
80
|
+
proxies = new Map();
|
|
44
81
|
closed = false;
|
|
45
82
|
/** Servers a proxy is currently serving — what `bir doctor` reports. */
|
|
46
83
|
pollingServers() {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
.
|
|
50
|
-
|
|
84
|
+
const names = new Set();
|
|
85
|
+
for (const info of this.proxies.values()) {
|
|
86
|
+
if (this.isLive(info))
|
|
87
|
+
names.add(info.serverName);
|
|
88
|
+
}
|
|
89
|
+
return [...names];
|
|
51
90
|
}
|
|
52
91
|
/** True when a proxy for `serverName` is available to take work. */
|
|
53
92
|
hasPoller(serverName) {
|
|
54
93
|
if ((this.waiters.get(serverName)?.length ?? 0) > 0)
|
|
55
94
|
return true;
|
|
56
|
-
return this.
|
|
95
|
+
return this.present(serverName).length > 0;
|
|
57
96
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Record that `proxyId` serves `sessionId`: it reported a call whose id the
|
|
99
|
+
* session's own hook minted. Proof, so it replaces any earlier binding — a
|
|
100
|
+
* `/clear` starts a new session on the same proxies.
|
|
101
|
+
*/
|
|
102
|
+
bindProxy(proxyId, sessionId) {
|
|
103
|
+
const info = this.proxies.get(proxyId);
|
|
104
|
+
if (!info)
|
|
105
|
+
return;
|
|
106
|
+
if (info.sessionId === sessionId && info.boundBy === "correlated")
|
|
107
|
+
return;
|
|
108
|
+
info.sessionId = sessionId;
|
|
109
|
+
info.boundBy = "correlated";
|
|
110
|
+
logDetail("replay.proxy_bound", {
|
|
111
|
+
server: info.serverName,
|
|
112
|
+
proxy: shortId(proxyId),
|
|
113
|
+
pid: info.pid,
|
|
114
|
+
sess: sessionId,
|
|
115
|
+
by: "correlated",
|
|
116
|
+
});
|
|
62
117
|
}
|
|
63
118
|
/**
|
|
64
119
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
65
|
-
* deadline so the proxy re-polls. Work
|
|
66
|
-
* polls is handed over immediately.
|
|
120
|
+
* deadline so the proxy re-polls. Work held for this proxy while it was
|
|
121
|
+
* between polls is handed over immediately.
|
|
67
122
|
*/
|
|
68
|
-
waitForWork(serverName, pollDeadlineMs, signal) {
|
|
123
|
+
waitForWork(serverName, pollDeadlineMs, signal, who = {}) {
|
|
69
124
|
if (this.closed)
|
|
70
125
|
return Promise.resolve(undefined);
|
|
71
|
-
|
|
72
|
-
|
|
126
|
+
const proxyId = who.proxyId || legacyId(serverName);
|
|
127
|
+
this.touch(proxyId, serverName, who);
|
|
128
|
+
const queued = this.pending.get(proxyId);
|
|
73
129
|
if (queued && queued.length > 0) {
|
|
74
130
|
return Promise.resolve(queued.shift());
|
|
75
131
|
}
|
|
@@ -79,6 +135,7 @@ export class ProxyWorkQueue {
|
|
|
79
135
|
const list = this.waiters.get(serverName) ?? [];
|
|
80
136
|
const waiter = {
|
|
81
137
|
serverName,
|
|
138
|
+
proxyId,
|
|
82
139
|
resolve,
|
|
83
140
|
signal,
|
|
84
141
|
timer: setTimeout(() => {
|
|
@@ -90,6 +147,7 @@ export class ProxyWorkQueue {
|
|
|
90
147
|
waiter.onAbort = () => {
|
|
91
148
|
logDetail("replay.poller_gone", { server: serverName, why: "its request closed" });
|
|
92
149
|
this.removeWaiter(waiter);
|
|
150
|
+
this.forgetIfGone(proxyId);
|
|
93
151
|
resolve(undefined);
|
|
94
152
|
};
|
|
95
153
|
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
@@ -120,18 +178,36 @@ export class ProxyWorkQueue {
|
|
|
120
178
|
* "could not be run here", which is exactly the condition under which the
|
|
121
179
|
* caller may substitute a recorded output.
|
|
122
180
|
*/
|
|
123
|
-
call(serverName, toolName, args, timeoutMs) {
|
|
181
|
+
call(serverName, toolName, args, timeoutMs, route = {}) {
|
|
124
182
|
if (this.closed)
|
|
125
183
|
return Promise.reject(new Error(NO_PROXY));
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
if (!waiter && !this.isPresent(serverName)) {
|
|
184
|
+
const picked = this.pickProxy(serverName, route.sessionId);
|
|
185
|
+
if (!picked) {
|
|
129
186
|
// No proxy has ever polled for this server, or one has been gone for a
|
|
130
187
|
// minute. Do NOT queue: the caller is inside a turn the user is waiting on,
|
|
131
188
|
// and work that sits until some proxy happens to appear would stall it past
|
|
132
189
|
// every budget. Fail fast, and let the recorded-output fallback decide.
|
|
133
190
|
return Promise.reject(new Error(NO_PROXY));
|
|
134
191
|
}
|
|
192
|
+
if (picked === "other_sessions") {
|
|
193
|
+
const others = this.present(serverName).length;
|
|
194
|
+
logDetail("replay.no_session_proxy", {
|
|
195
|
+
server: serverName,
|
|
196
|
+
tool: toolName,
|
|
197
|
+
sess: route.sessionId,
|
|
198
|
+
others,
|
|
199
|
+
why: "only other sessions' proxies are here — the step is not run on their upstream",
|
|
200
|
+
});
|
|
201
|
+
return Promise.resolve(noSessionProxy(serverName, others));
|
|
202
|
+
}
|
|
203
|
+
const { info, why } = picked;
|
|
204
|
+
if (route.sessionId && !info.sessionId && !isLegacy(info.proxyId)) {
|
|
205
|
+
// Keep the rest of this session's plan on the same upstream.
|
|
206
|
+
info.sessionId = route.sessionId;
|
|
207
|
+
info.boundBy = "dispatched";
|
|
208
|
+
}
|
|
209
|
+
const work = { workId: "birwork_" + randomUUID(), toolName, arguments: args, timeoutMs };
|
|
210
|
+
const waiter = this.takeWaiter(serverName, info.proxyId);
|
|
135
211
|
return new Promise((resolve, reject) => {
|
|
136
212
|
const entry = {
|
|
137
213
|
serverName,
|
|
@@ -139,7 +215,7 @@ export class ProxyWorkQueue {
|
|
|
139
215
|
reject,
|
|
140
216
|
timer: setTimeout(() => {
|
|
141
217
|
this.inFlight.delete(work.workId);
|
|
142
|
-
this.dropPending(
|
|
218
|
+
this.dropPending(info.proxyId, work.workId);
|
|
143
219
|
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
144
220
|
}, timeoutMs),
|
|
145
221
|
};
|
|
@@ -149,23 +225,27 @@ export class ProxyWorkQueue {
|
|
|
149
225
|
server: serverName,
|
|
150
226
|
tool: toolName,
|
|
151
227
|
work: work.workId,
|
|
228
|
+
proxy: shortId(info.proxyId),
|
|
229
|
+
pid: info.pid,
|
|
230
|
+
pick: why,
|
|
152
231
|
queued: waiter ? undefined : true,
|
|
153
232
|
});
|
|
154
233
|
if (waiter) {
|
|
155
234
|
waiter.resolve(work);
|
|
156
235
|
return;
|
|
157
236
|
}
|
|
158
|
-
// The proxy is mid-round-trip — POSTing the previous step's result,
|
|
159
|
-
// to poll again. Hold the work for
|
|
160
|
-
//
|
|
161
|
-
|
|
237
|
+
// The chosen proxy is mid-round-trip — POSTing the previous step's result,
|
|
238
|
+
// about to poll again. Hold the work for IT; another proxy that happens to
|
|
239
|
+
// be parked right now is not a substitute. The per-call timeout above is
|
|
240
|
+
// what bounds the wait if it never comes back.
|
|
241
|
+
const list = this.pending.get(info.proxyId) ?? [];
|
|
162
242
|
list.push(work);
|
|
163
|
-
this.pending.set(
|
|
243
|
+
this.pending.set(info.proxyId, list);
|
|
164
244
|
});
|
|
165
245
|
}
|
|
166
246
|
/** Remove queued work that timed out, so a later poll never gets stale work. */
|
|
167
|
-
dropPending(
|
|
168
|
-
const list = this.pending.get(
|
|
247
|
+
dropPending(proxyId, workId) {
|
|
248
|
+
const list = this.pending.get(proxyId);
|
|
169
249
|
if (!list)
|
|
170
250
|
return;
|
|
171
251
|
const i = list.findIndex((w) => w.workId === workId);
|
|
@@ -184,33 +264,121 @@ export class ProxyWorkQueue {
|
|
|
184
264
|
this.waiters.clear();
|
|
185
265
|
this.pending.clear();
|
|
186
266
|
// A closed queue has no proxies, whatever they were doing a moment ago.
|
|
187
|
-
this.
|
|
267
|
+
this.proxies.clear();
|
|
188
268
|
for (const [, entry] of this.inFlight) {
|
|
189
269
|
clearTimeout(entry.timer);
|
|
190
270
|
entry.reject(new Error("control server closed"));
|
|
191
271
|
}
|
|
192
272
|
this.inFlight.clear();
|
|
193
273
|
}
|
|
274
|
+
/** Note a poll: create the proxy's record on first sight, refresh it after. */
|
|
275
|
+
touch(proxyId, serverName, who) {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const info = this.proxies.get(proxyId);
|
|
278
|
+
if (info) {
|
|
279
|
+
info.lastSeen = now;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const started = Number(who.startedAt);
|
|
283
|
+
this.proxies.set(proxyId, {
|
|
284
|
+
proxyId,
|
|
285
|
+
serverName,
|
|
286
|
+
startedAt: Number.isFinite(started) && started > 0 ? started : now,
|
|
287
|
+
pid: who.pid,
|
|
288
|
+
lastSeen: now,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Parked now, or polled recently enough to be mid-round-trip. */
|
|
292
|
+
isLive(info) {
|
|
293
|
+
return Date.now() - info.lastSeen <= PRESENT_MS;
|
|
294
|
+
}
|
|
295
|
+
/** Live proxies for a server, newest first. */
|
|
296
|
+
present(serverName) {
|
|
297
|
+
return [...this.proxies.values()]
|
|
298
|
+
.filter((p) => p.serverName === serverName && this.isLive(p))
|
|
299
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The proxy a step for `sessionId` should run on — see the file comment.
|
|
303
|
+
* Undefined when no proxy is live at all; `other_sessions` when every live
|
|
304
|
+
* one belongs to a different session.
|
|
305
|
+
*/
|
|
306
|
+
pickProxy(serverName, sessionId) {
|
|
307
|
+
const live = this.present(serverName);
|
|
308
|
+
if (live.length === 0)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (!sessionId)
|
|
311
|
+
return { info: live[0], why: "newest" };
|
|
312
|
+
const mine = live.filter((p) => p.sessionId === sessionId);
|
|
313
|
+
const proven = mine.find((p) => p.boundBy === "correlated");
|
|
314
|
+
if (proven)
|
|
315
|
+
return { info: proven, why: "session" };
|
|
316
|
+
if (mine[0])
|
|
317
|
+
return { info: mine[0], why: "session" };
|
|
318
|
+
const unbound = live.find((p) => !p.sessionId);
|
|
319
|
+
if (unbound)
|
|
320
|
+
return { info: unbound, why: "newest" };
|
|
321
|
+
return "other_sessions";
|
|
322
|
+
}
|
|
194
323
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
324
|
+
* Free every proxy bound to `sessionId`: the session ended. After a `/clear`
|
|
325
|
+
* the same proxies serve the next session, which must be able to pick them.
|
|
197
326
|
*/
|
|
198
|
-
|
|
327
|
+
releaseSession(sessionId) {
|
|
328
|
+
for (const info of this.proxies.values()) {
|
|
329
|
+
if (info.sessionId !== sessionId)
|
|
330
|
+
continue;
|
|
331
|
+
info.sessionId = undefined;
|
|
332
|
+
info.boundBy = undefined;
|
|
333
|
+
logDetail("replay.proxy_released", {
|
|
334
|
+
server: info.serverName,
|
|
335
|
+
proxy: shortId(info.proxyId),
|
|
336
|
+
pid: info.pid,
|
|
337
|
+
sess: sessionId,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* A live parked poller of `proxyId`. Waiters whose request has already gone
|
|
343
|
+
* are discarded rather than handed work they can never run.
|
|
344
|
+
*/
|
|
345
|
+
takeWaiter(serverName, proxyId) {
|
|
199
346
|
const list = this.waiters.get(serverName);
|
|
200
347
|
if (!list)
|
|
201
348
|
return undefined;
|
|
202
|
-
for (
|
|
203
|
-
const waiter = list
|
|
204
|
-
if (!waiter)
|
|
205
|
-
return undefined;
|
|
206
|
-
this.detach(waiter);
|
|
349
|
+
for (let i = 0; i < list.length;) {
|
|
350
|
+
const waiter = list[i];
|
|
207
351
|
if (waiter.signal?.aborted) {
|
|
208
352
|
// Its proxy is gone. Release the promise and keep looking.
|
|
353
|
+
list.splice(i, 1);
|
|
354
|
+
this.detach(waiter);
|
|
209
355
|
waiter.resolve(undefined);
|
|
210
356
|
continue;
|
|
211
357
|
}
|
|
212
|
-
|
|
358
|
+
if (waiter.proxyId === proxyId) {
|
|
359
|
+
list.splice(i, 1);
|
|
360
|
+
this.detach(waiter);
|
|
361
|
+
return waiter;
|
|
362
|
+
}
|
|
363
|
+
i++;
|
|
213
364
|
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* A proxy whose poll request closed has, almost always, exited. Forget it
|
|
369
|
+
* unless another of its polls is still parked, so it is never picked for a
|
|
370
|
+
* minute after it died. A proxy that merely lost one request re-registers on
|
|
371
|
+
* its next poll.
|
|
372
|
+
*/
|
|
373
|
+
forgetIfGone(proxyId) {
|
|
374
|
+
for (const list of this.waiters.values()) {
|
|
375
|
+
if (list.some((w) => w.proxyId === proxyId && !w.signal?.aborted))
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if ((this.pending.get(proxyId)?.length ?? 0) > 0)
|
|
379
|
+
return;
|
|
380
|
+
this.proxies.delete(proxyId);
|
|
381
|
+
this.pending.delete(proxyId);
|
|
214
382
|
}
|
|
215
383
|
removeWaiter(waiter) {
|
|
216
384
|
this.detach(waiter);
|
|
@@ -230,4 +398,29 @@ export class ProxyWorkQueue {
|
|
|
230
398
|
}
|
|
231
399
|
}
|
|
232
400
|
}
|
|
401
|
+
function isLegacy(proxyId) {
|
|
402
|
+
return proxyId.startsWith("legacy:");
|
|
403
|
+
}
|
|
404
|
+
/** Enough of an id to tell proxies apart in a log line. */
|
|
405
|
+
function shortId(proxyId) {
|
|
406
|
+
return isLegacy(proxyId) ? proxyId : proxyId.slice(-8);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The step's result when only other sessions' proxies are here: an MCP tool
|
|
410
|
+
* error, so the plan stops on it and hands over (tool-error.ts), and the agent
|
|
411
|
+
* reads why.
|
|
412
|
+
*/
|
|
413
|
+
function noSessionProxy(serverName, others) {
|
|
414
|
+
return {
|
|
415
|
+
isError: true,
|
|
416
|
+
content: [
|
|
417
|
+
{
|
|
418
|
+
type: "text",
|
|
419
|
+
text: `Error: bir did not run this step. No ${serverName} proxy of this session is ` +
|
|
420
|
+
`connected; the ${others} that are belong to other sessions, and a step never ` +
|
|
421
|
+
`runs on another session's upstream. Call the tool yourself.`,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
233
426
|
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which missing targets actually stop a plan (segmented.md R-PARAM-3).
|
|
3
|
+
*
|
|
4
|
+
* The rule a target exists for is "never do the right work on the wrong thing":
|
|
5
|
+
* a value the turn did not name must never be filled in from the recording. The
|
|
6
|
+
* gate that enforces it used to read `missing.length > 0` and decline, which is
|
|
7
|
+
* the same thing only when every target is something the chain *takes from the
|
|
8
|
+
* caller*.
|
|
9
|
+
*
|
|
10
|
+
* It often is not. An analysis routinely lists a value that a later step
|
|
11
|
+
* computes from an earlier step's output and keeps the caller's copy as a bare
|
|
12
|
+
* fallback:
|
|
13
|
+
*
|
|
14
|
+
* step 0 return { fleet: parameters.fleet };
|
|
15
|
+
* step 1 return { id: (respParams.rankedDeviceIds || [])[0] ?? parameters.id };
|
|
16
|
+
*
|
|
17
|
+
* `id` is classified a target — it is what the second call acts on and it does
|
|
18
|
+
* change between requests — but no prompt ever names it, because the chain
|
|
19
|
+
* discovers it. Declining on it meant a scenario that matched, was ready, was
|
|
20
|
+
* enabled and would have run perfectly never ran at all, on every turn, with the
|
|
21
|
+
* reason only in a stderr line.
|
|
22
|
+
*
|
|
23
|
+
* So the question is not "did the turn name every target" but the narrower one
|
|
24
|
+
* the rule actually cares about: **could this plan act on the recorded value of
|
|
25
|
+
* a target the turn did not name?** It could, in exactly two ways:
|
|
26
|
+
*
|
|
27
|
+
* 1. some step reads `parameters.<key>` outright, rather than as the fallback
|
|
28
|
+
* of a value computed at run time; or
|
|
29
|
+
* 2. some step's logic carries the recorded sample as a literal, so the value
|
|
30
|
+
* is baked into the chain whether or not anybody reads the parameter.
|
|
31
|
+
*
|
|
32
|
+
* Neither true means the recorded value cannot reach a tool. The missing target
|
|
33
|
+
* stays `null` all the way through — {@link ./derive.ts} sets it, `paramsLogic`
|
|
34
|
+
* copies it, the `??` passes over it — so a chain that can compute the value
|
|
35
|
+
* runs on this turn's value, and one that cannot calls a tool with `null` and
|
|
36
|
+
* falls back to the model. What never happens, either way, is last week's fleet.
|
|
37
|
+
*
|
|
38
|
+
* Only frame 0 is judged here. A called segment runs on the parameters its
|
|
39
|
+
* call's `paramMapLogic` builds, not on these, so that body is scanned as a
|
|
40
|
+
* consumer and the segment's own steps are not (R-CALL-29).
|
|
41
|
+
*/
|
|
42
|
+
import type { FlatEntry } from "./flatten.js";
|
|
43
|
+
import type { ParamsSchema } from "./types.js";
|
|
44
|
+
/**
|
|
45
|
+
* The subset of `missing` this plan must decline on.
|
|
46
|
+
*
|
|
47
|
+
* Empty means every target the turn left unnamed is one the chain finds for
|
|
48
|
+
* itself. `missing` is returned unchanged whenever the schema or the plan is
|
|
49
|
+
* missing, so a caller that knows less than this one is never made to guess.
|
|
50
|
+
*/
|
|
51
|
+
export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[]): string[];
|
|
52
|
+
//# sourceMappingURL=targets.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which missing targets actually stop a plan (segmented.md R-PARAM-3).
|
|
3
|
+
*
|
|
4
|
+
* The rule a target exists for is "never do the right work on the wrong thing":
|
|
5
|
+
* a value the turn did not name must never be filled in from the recording. The
|
|
6
|
+
* gate that enforces it used to read `missing.length > 0` and decline, which is
|
|
7
|
+
* the same thing only when every target is something the chain *takes from the
|
|
8
|
+
* caller*.
|
|
9
|
+
*
|
|
10
|
+
* It often is not. An analysis routinely lists a value that a later step
|
|
11
|
+
* computes from an earlier step's output and keeps the caller's copy as a bare
|
|
12
|
+
* fallback:
|
|
13
|
+
*
|
|
14
|
+
* step 0 return { fleet: parameters.fleet };
|
|
15
|
+
* step 1 return { id: (respParams.rankedDeviceIds || [])[0] ?? parameters.id };
|
|
16
|
+
*
|
|
17
|
+
* `id` is classified a target — it is what the second call acts on and it does
|
|
18
|
+
* change between requests — but no prompt ever names it, because the chain
|
|
19
|
+
* discovers it. Declining on it meant a scenario that matched, was ready, was
|
|
20
|
+
* enabled and would have run perfectly never ran at all, on every turn, with the
|
|
21
|
+
* reason only in a stderr line.
|
|
22
|
+
*
|
|
23
|
+
* So the question is not "did the turn name every target" but the narrower one
|
|
24
|
+
* the rule actually cares about: **could this plan act on the recorded value of
|
|
25
|
+
* a target the turn did not name?** It could, in exactly two ways:
|
|
26
|
+
*
|
|
27
|
+
* 1. some step reads `parameters.<key>` outright, rather than as the fallback
|
|
28
|
+
* of a value computed at run time; or
|
|
29
|
+
* 2. some step's logic carries the recorded sample as a literal, so the value
|
|
30
|
+
* is baked into the chain whether or not anybody reads the parameter.
|
|
31
|
+
*
|
|
32
|
+
* Neither true means the recorded value cannot reach a tool. The missing target
|
|
33
|
+
* stays `null` all the way through — {@link ./derive.ts} sets it, `paramsLogic`
|
|
34
|
+
* copies it, the `??` passes over it — so a chain that can compute the value
|
|
35
|
+
* runs on this turn's value, and one that cannot calls a tool with `null` and
|
|
36
|
+
* falls back to the model. What never happens, either way, is last week's fleet.
|
|
37
|
+
*
|
|
38
|
+
* Only frame 0 is judged here. A called segment runs on the parameters its
|
|
39
|
+
* call's `paramMapLogic` builds, not on these, so that body is scanned as a
|
|
40
|
+
* consumer and the segment's own steps are not (R-CALL-29).
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* The subset of `missing` this plan must decline on.
|
|
44
|
+
*
|
|
45
|
+
* Empty means every target the turn left unnamed is one the chain finds for
|
|
46
|
+
* itself. `missing` is returned unchanged whenever the schema or the plan is
|
|
47
|
+
* missing, so a caller that knows less than this one is never made to guess.
|
|
48
|
+
*/
|
|
49
|
+
export function blockingTargets(missing, schema, planned) {
|
|
50
|
+
if (missing.length === 0)
|
|
51
|
+
return [];
|
|
52
|
+
if (!schema || planned.length === 0)
|
|
53
|
+
return [...missing];
|
|
54
|
+
const bodies = callerBodies(planned);
|
|
55
|
+
// Nothing to read the parameters: a plan with no logic at all cannot act on
|
|
56
|
+
// anything, but it is also not a shape worth reasoning about — keep it strict.
|
|
57
|
+
if (bodies.length === 0)
|
|
58
|
+
return [...missing];
|
|
59
|
+
return missing.filter((key) => bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue)));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every logic body that runs with the caller's own `parameters`.
|
|
63
|
+
*
|
|
64
|
+
* Both of a step's bodies, because `toolOutputLogic` is handed `parameters`
|
|
65
|
+
* too and a value baked in there travels just as far. Plus the `paramMapLogic`
|
|
66
|
+
* of each segment called directly from frame 0: it reads the caller's
|
|
67
|
+
* parameters to build the segment's.
|
|
68
|
+
*/
|
|
69
|
+
function callerBodies(planned) {
|
|
70
|
+
const bodies = [];
|
|
71
|
+
const seenFrames = new Set();
|
|
72
|
+
for (const entry of planned) {
|
|
73
|
+
if (entry.depth === 0) {
|
|
74
|
+
if (entry.step.toolInputLogic)
|
|
75
|
+
bodies.push(entry.step.toolInputLogic);
|
|
76
|
+
if (entry.step.toolOutputLogic)
|
|
77
|
+
bodies.push(entry.step.toolOutputLogic);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// The frame's own mapping, once, and only when its caller is frame 0.
|
|
81
|
+
const frame = entry.frame;
|
|
82
|
+
if (frame.depth !== 1 || seenFrames.has(frame.id))
|
|
83
|
+
continue;
|
|
84
|
+
seenFrames.add(frame.id);
|
|
85
|
+
if (frame.paramMapLogic)
|
|
86
|
+
bodies.push(frame.paramMapLogic);
|
|
87
|
+
}
|
|
88
|
+
return bodies;
|
|
89
|
+
}
|
|
90
|
+
/** `parameters.key`, `parameters["key"]`, `parameters['key']` — all three forms. */
|
|
91
|
+
function referencePattern(key) {
|
|
92
|
+
const k = escapeRegExp(key);
|
|
93
|
+
return new RegExp(`parameters\\s*(?:\\.\\s*${k}\\b|\\[\\s*["']${k}["']\\s*\\])`, "g");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether `body` reads `key` other than as a fallback.
|
|
97
|
+
*
|
|
98
|
+
* A reference is a fallback when what stands immediately before it is `??` or
|
|
99
|
+
* `||` — the shape an analysis writes when the chain computes the value and
|
|
100
|
+
* keeps the caller's as a last resort. Every other reference is a read: the
|
|
101
|
+
* step wants the caller's value and nothing else will do.
|
|
102
|
+
*/
|
|
103
|
+
function readsOutright(body, key) {
|
|
104
|
+
const pattern = referencePattern(key);
|
|
105
|
+
for (let m = pattern.exec(body); m; m = pattern.exec(body)) {
|
|
106
|
+
const before = body.slice(0, m.index).trimEnd();
|
|
107
|
+
if (!before.endsWith("??") && !before.endsWith("||"))
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether `body` carries the recorded sample as a literal.
|
|
114
|
+
*
|
|
115
|
+
* This is the case the parameter reference cannot see: `return { id: 'dev_4411' }`
|
|
116
|
+
* acts on last week's device without mentioning `parameters` at all. Read
|
|
117
|
+
* generously — a sample too short to search for (`1`, `on`) counts as carried,
|
|
118
|
+
* because a false "this plan is safe" is the one answer this file must not give.
|
|
119
|
+
*/
|
|
120
|
+
function carriesLiteral(body, sample) {
|
|
121
|
+
if (sample === null || sample === undefined)
|
|
122
|
+
return false;
|
|
123
|
+
const text = typeof sample === "string" ? sample : JSON.stringify(sample);
|
|
124
|
+
if (typeof text !== "string" || text.length === 0)
|
|
125
|
+
return false;
|
|
126
|
+
if (text.length < 3)
|
|
127
|
+
return true;
|
|
128
|
+
return body.includes(text);
|
|
129
|
+
}
|
|
130
|
+
function escapeRegExp(text) {
|
|
131
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=targets.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|