@basein/runner 0.2.3 → 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.
@@ -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
- constructor(url: string, token: string, timeoutMs?: number);
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;
@@ -10,18 +10,21 @@ export class ControlClient {
10
10
  url;
11
11
  token;
12
12
  timeoutMs;
13
- constructor(url, token, timeoutMs = 5_000) {
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
- return (await this.post("/proxy/step", step)) !== undefined;
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
  /**
@@ -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
  *
@@ -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", { server: serverName, pid: body.pid, version: proxyVersion });
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 work = await this.replay.work.waitForWork(serverName, Number.isFinite(hold) && hold > 0 ? Math.min(hold, POLL_HOLD_MS) : POLL_HOLD_MS, signal);
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
  *
@@ -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?;
@@ -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,
@@ -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
@@ -616,7 +616,7 @@ export class ReplayController {
616
616
  const deadline = Date.now() + this.budgets.planMs;
617
617
  let result;
618
618
  try {
619
- 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 });
620
620
  }
621
621
  catch (err) {
622
622
  // The scenario's own logic failed. Retire and let the model do the work.
@@ -889,7 +889,7 @@ export class ReplayController {
889
889
  }
890
890
  const trace = [];
891
891
  try {
892
- 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) => {
893
893
  this.observeStep(state)(info);
894
894
  trace.push({
895
895
  step: info.step.stepIndex,
@@ -1023,7 +1023,7 @@ export class ReplayController {
1023
1023
  this.retire(state, undefined);
1024
1024
  let composed;
1025
1025
  try {
1026
- 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),
1027
1027
  // A parked step ends the bundle short of the task (fallbk.md D3).
1028
1028
  { handover: plan.stopsEarly() });
1029
1029
  }
@@ -1175,13 +1175,13 @@ export class ReplayController {
1175
1175
  * under which a recorded output may stand in. A tool that ran and failed
1176
1176
  * resolves with its failure as the response, exactly as it would in a session.
1177
1177
  */
1178
- executeStep() {
1178
+ executeStep(state) {
1179
1179
  return async (step, input) => {
1180
1180
  const mcp = parseQualifiedName(step.toolName ?? "");
1181
1181
  if (!mcp) {
1182
1182
  throw new Error(`${step.toolName} is not an MCP tool — it can only run in the session`);
1183
1183
  }
1184
- 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 });
1185
1185
  // Serialize exactly as the proxy records it, or `toolOutputLogic` — which
1186
1186
  // was authored against that shape — silently derives nothing (§7.2).
1187
1187
  return serializeCapped(redact(result));
@@ -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 dispatched while its proxy was mid-round-trip, per server. */
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
- /** serverName → when it last polled. A proxy between polls is still present. */
40
- private readonly lastSeen;
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
- /** Parked now, or polled recently enough to be mid-round-trip. */
47
- private isPresent;
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 queued while this proxy was between
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
- * The oldest *live* poller. Waiters whose request has already gone are
71
- * discarded rather than handed work they can never run.
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;
@@ -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 dispatched while its proxy was mid-round-trip, per server. */
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
- /** serverName → when it last polled. A proxy between polls is still present. */
43
- lastSeen = new Map();
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 now = Date.now();
48
- return [...this.lastSeen.entries()]
49
- .filter(([, at]) => now - at <= PRESENT_MS)
50
- .map(([name]) => name);
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.isPresent(serverName);
95
+ return this.present(serverName).length > 0;
57
96
  }
58
- /** Parked now, or polled recently enough to be mid-round-trip. */
59
- isPresent(serverName) {
60
- const at = this.lastSeen.get(serverName);
61
- return at !== undefined && Date.now() - at <= PRESENT_MS;
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 queued while this proxy was between
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
- this.lastSeen.set(serverName, Date.now());
72
- const queued = this.pending.get(serverName);
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 work = { workId: "birwork_" + randomUUID(), toolName, arguments: args, timeoutMs };
127
- const waiter = this.takeWaiter(serverName);
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(serverName, work.workId);
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, about
159
- // to poll again. Hold the work for it; the per-call timeout above is what
160
- // bounds the wait if it never comes back.
161
- const list = this.pending.get(serverName) ?? [];
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(serverName, list);
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(serverName, workId) {
168
- const list = this.pending.get(serverName);
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.lastSeen.clear();
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
- * The oldest *live* poller. Waiters whose request has already gone are
196
- * discarded rather than handed work they can never run.
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
- takeWaiter(serverName) {
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.shift();
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
- return waiter;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.2.3",
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",