@basein/runner 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -9,30 +9,56 @@
9
9
  * the run; this proxy only reports steps. Built-ins and MCP land in one
10
10
  * ordered stream, with the prompt and the final answer.
11
11
  *
12
- * **Tier 2 — Standalone.** No control server inside the discovery window. The
13
- * proxy owns a run of its own and records MCP calls only — no prompt, no final
14
- * answer, no built-in steps. That is not a degraded bug; it is the honest
15
- * ceiling of what an MCP proxy can observe, and the run says so in
16
- * `metadata.tier` so nothing downstream mistakes it for a complete trace.
12
+ * **Tier 2 — Standalone.** No control server inside the discovery window, in
13
+ * a project that asked to be recorded. The proxy owns a run of its own and
14
+ * records MCP calls only — no prompt, no final answer, no built-in steps.
15
+ * That is not a degraded bug; it is the honest ceiling of what an MCP proxy
16
+ * can observe, and the run says so in `metadata.tier` so nothing downstream
17
+ * mistakes it for a complete trace.
18
+ *
19
+ * **Passthrough.** No control server, in a project that never opted in. A
20
+ * user-scope server is wrapped once, by `bir install` in *one* project, but
21
+ * Claude Code starts it in *every* project — and Tier 2 there meant uploading
22
+ * that project's tool arguments and results to the service without anyone
23
+ * having asked. So a hooks-wired user-scope entry records only where its
24
+ * directory was set up, or a recorder is running for it; anywhere else it
25
+ * relays and records nothing. Every other entry — another client's config
26
+ * file, a project-scope entry, a hand-pasted `bir wrap` — keeps Tier 2 (see
27
+ * `optedIn`).
17
28
  *
18
29
  * THE DISCOVERY WINDOW EXISTS BECAUSE OF A RACE: the host may spawn MCP servers
19
30
  * before `SessionStart` fires, and the ordering between the two is not
20
31
  * guaranteed. So steps are **buffered in memory** while the search runs, and
21
32
  * flushed into whichever owner wins. Nothing is lost to the race, and nothing
22
33
  * blocks: relaying never waits on this.
34
+ *
35
+ * AND IT IS NOT A ONE-WAY DOOR. A recorder that starts late, or is restarted
36
+ * mid-session (an upgrade), is found again: while not bound, the proxy looks
37
+ * for it every {@link REBIND_INTERVAL_MS} and binds when it answers. Losing it
38
+ * costs one downgrade and at most one standalone run per proxy, however many
39
+ * reports were queued when it went. A recorder that *died* under a bound proxy
40
+ * — its pid gone, its port refusing — is started again from here, in the
41
+ * background (proxy/revive.ts): until it is back, every hook of the session
42
+ * fails in front of the user.
23
43
  */
24
44
  import { randomUUID } from "node:crypto";
25
45
  import { hostname } from "node:os";
46
+ import { readFile } from "node:fs/promises";
47
+ import { resolve as resolvePath } from "node:path";
26
48
  import { ControlClient } from "../control/client.js";
27
- import { resolveControl } from "../control/discovery.js";
49
+ import { readDiscovery } from "../control/discovery.js";
28
50
  import { qualifyToolName } from "../control/correlation.js";
29
- import { authenticate, resolveAuthUrl } from "../auth/client.js";
51
+ import { projectRecord } from "../config/generate.js";
52
+ import { installedPath, normalizePath } from "../control/paths.js";
53
+ import { authenticate, loadCredentials, normalizeAuthUrl, resolveAuthUrl } from "../auth/client.js";
30
54
  import { NullRecorder } from "../record/recorder.js";
31
55
  import { RemoteRecorder } from "../record/remote-recorder.js";
32
56
  import { StepQueue } from "../record/queue.js";
33
57
  import { StepIndexAllocator } from "../control/ordering.js";
34
58
  import { serializeCapped } from "../record/truncate.js";
35
59
  import { redact } from "../record/redact.js";
60
+ import { isRunnerOff } from "../util/off-switch.js";
61
+ import { RecorderReviver } from "./revive.js";
36
62
  import { logLine, logDetail, errText } from "../util/log.js";
37
63
  import { packageVersion } from "../util/version.js";
38
64
  /** How long a proxy waits for a control server before falling to Tier 2. */
@@ -44,6 +70,151 @@ import { packageVersion } from "../util/version.js";
44
70
  * dropped, and a host session is never waiting on this.
45
71
  */
46
72
  export const DISCOVERY_WINDOW_MS = 10_000;
73
+ /** While not bound, how often to look for a control server that came (back) up. */
74
+ export const REBIND_INTERVAL_MS = 15_000;
75
+ /**
76
+ * Step reports waiting to be sent. Tier 1 sends go to loopback and take
77
+ * milliseconds; Tier 2 sends wait on the service (one step at a time, see
78
+ * `dispatch`). Either way a backlog this deep means the far end is not
79
+ * keeping up, and the oldest is dropped — and the run flagged lossy — rather
80
+ * than growing inside the user's MCP server process tree.
81
+ */
82
+ export const STEP_BACKLOG = 250;
83
+ /** Steps held while the tier is still being negotiated. */
84
+ const DISCOVERY_BUFFER = 1000;
85
+ /**
86
+ * The replay work loop's ceiling between polls of a control server that is
87
+ * reachable but not holding them — replay is off there, or it is an older one
88
+ * that answers at once. One loopback request every 30 s is nothing, and it is
89
+ * what lets a proxy notice that replay was turned back on (a restart applies
90
+ * that) without being told.
91
+ */
92
+ const MAX_POLL_BACKOFF_MS = 30_000;
93
+ /**
94
+ * The ceiling while the control server cannot be reached at all — most often
95
+ * because it is restarting. Lower than the idle ceiling on purpose: a
96
+ * restarted recorder routes nothing to this proxy until it polls, so every
97
+ * second of backoff past its return is a replay step that falls back to a
98
+ * recorded output. A refused loopback connect every 5 s costs nothing.
99
+ */
100
+ const MAX_UNREACHABLE_BACKOFF_MS = 5_000;
101
+ /**
102
+ * Spread over which proxies that all hit the end of the discovery window at
103
+ * once (one per wrapped server) start their Tier 2 recorder, so they do not
104
+ * all refresh the same rotating token in the same instant.
105
+ */
106
+ const STANDALONE_JITTER_MS = 2_000;
107
+ /**
108
+ * All of {@link ProxySession.close}: negotiation, the step queue and the run's
109
+ * final flush. Under bir-proxy's hard 3 s shutdown budget, with room for the
110
+ * process to write its last bytes and exit.
111
+ */
112
+ const CLOSE_BUDGET_MS = 2_500;
113
+ /** The final flush is always given at least this, even when the queue used the budget up. */
114
+ const MIN_FINAL_FLUSH_MS = 250;
115
+ const sleep = (ms) => new Promise((resolve) => {
116
+ const t = setTimeout(resolve, ms);
117
+ t.unref?.();
118
+ });
119
+ /** Settles when `promise` does or after `ms`, whichever is first; never rejects. */
120
+ function settledWithin(promise, ms) {
121
+ return new Promise((resolve) => {
122
+ const timer = setTimeout(resolve, ms);
123
+ timer.unref?.();
124
+ promise.then(() => {
125
+ clearTimeout(timer);
126
+ resolve();
127
+ }, () => {
128
+ clearTimeout(timer);
129
+ resolve();
130
+ });
131
+ });
132
+ }
133
+ /** Loopback: never behind a TLS front that redirects, and not readable on the network. */
134
+ function isLoopbackHost(hostname) {
135
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
136
+ return host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\./.test(host);
137
+ }
138
+ /**
139
+ * The service address a Tier 2 recorder should use for `configured`, the way
140
+ * the recorder (control/recorder-keeper.ts) and `bir login` bind sessions:
141
+ *
142
+ * - A saved session issued by the https form of a configured `http://host`
143
+ * is the same service, already upgraded — use its address. No request.
144
+ * - Any other plain-http address off this machine is checked once for a
145
+ * same-host https redirect ({@link normalizeAuthUrl}, bounded): following
146
+ * one would strip the bearer token from every request. Not when signed
147
+ * out: there is no token to send.
148
+ * - Everything else is used as configured.
149
+ */
150
+ export async function normalizedServiceUrl(configured, fetchImpl = fetch) {
151
+ const base = configured.replace(/\/+$/, "");
152
+ let url;
153
+ try {
154
+ url = new URL(base);
155
+ }
156
+ catch {
157
+ return base;
158
+ }
159
+ if (url.protocol !== "http:")
160
+ return base;
161
+ const cached = loadCredentials();
162
+ const issuer = cached?.authUrl?.replace(/\/+$/, "");
163
+ if (issuer) {
164
+ try {
165
+ const to = new URL(issuer);
166
+ if (to.protocol === "https:" && to.host === url.host)
167
+ return issuer;
168
+ }
169
+ catch {
170
+ /* not a URL: not this service */
171
+ }
172
+ }
173
+ if (isLoopbackHost(url.hostname))
174
+ return base;
175
+ // Signed out: there is no token to protect, and authenticate() is about to
176
+ // say "no session" whatever the address — not worth a request per proxy.
177
+ if (!cached && process.env.BIR_AUTH_DISABLE !== "1")
178
+ return base;
179
+ return normalizeAuthUrl(base, fetchImpl);
180
+ }
181
+ /**
182
+ * Read the install sidecar's facts for `cwd` and `serverName`.
183
+ *
184
+ * Read asynchronously, and only when a decision needs it: the sidecar carries
185
+ * byte-exact backups of the files install edited — `~/.claude.json` among
186
+ * them, which can run to megabytes — and a proxy must not spend its start-up
187
+ * parsing that in front of the host's `initialize`. Unreadable → no facts,
188
+ * which every caller treats as "not installed, not written by an install".
189
+ */
190
+ export async function readInstallFacts(cwd, serverName) {
191
+ try {
192
+ const parsed = JSON.parse(await readFile(installedPath(), "utf8"));
193
+ const sidecar = { version: 1, ...parsed, servers: parsed.servers ?? {} };
194
+ const key = normalizePath(resolvePath(cwd));
195
+ let installedHere = projectRecord(sidecar, cwd) !== undefined;
196
+ const scopes = [];
197
+ for (const [recordKey, r] of Object.entries(sidecar.servers)) {
198
+ if (!r || typeof r !== "object")
199
+ continue;
200
+ if (!installedHere && typeof r.cwd === "string" && normalizePath(resolvePath(r.cwd)) === key) {
201
+ installedHere = true;
202
+ }
203
+ // Records are keyed `<cwd>::<serverName>` (generate.ts `sidecarKey`).
204
+ if (serverName !== undefined && recordKey.endsWith(`::${serverName}`)) {
205
+ scopes.push(typeof r.scope === "string" ? r.scope : "unknown");
206
+ }
207
+ }
208
+ return { installedHere, scopes };
209
+ }
210
+ catch {
211
+ return { installedHere: false, scopes: [] };
212
+ }
213
+ }
214
+ /** Whether `bir install` set this directory up for recording. */
215
+ export async function projectInstalledHere(cwd) {
216
+ return (await readInstallFacts(cwd)).installedHere;
217
+ }
47
218
  export class ProxySession {
48
219
  serverName;
49
220
  /** This proxy process, as the control server tells proxies apart (replay/executor.ts). */
@@ -51,11 +222,16 @@ export class ProxySession {
51
222
  opts;
52
223
  tierValue = "pending";
53
224
  control;
54
- /** Tier 2 only. */
225
+ /** Where `control` lives, for the log and to reuse a client across re-binds. */
226
+ controlKey;
227
+ /** The last control server this proxy was bound to; the work loop polls it. */
228
+ pollClient;
229
+ /** Tier 2 only. One per proxy, however often the tier changes. */
55
230
  recorder;
56
231
  runId;
57
232
  ordering = new StepIndexAllocator();
58
233
  queue = new StepQueue({
234
+ capacity: STEP_BACKLOG,
59
235
  onDrop: (dropped) => {
60
236
  if (!this.lossyValue)
61
237
  logLine("run.lossy", { why: "step queue overflowed", dropped });
@@ -67,13 +243,51 @@ export class ProxySession {
67
243
  buffer = [];
68
244
  lossyValue = false;
69
245
  negotiation;
246
+ downgrading;
70
247
  startedAt = Date.now();
71
248
  /** The replay work loop, when one was started. */
72
249
  workLoop;
250
+ /** What the control server last said about calculated replay. */
251
+ replayOn = false;
252
+ pollHoldMs = 25_000;
253
+ rebindTimer;
254
+ /** A discovery file for this cwd has been seen: a recorder was started here. */
255
+ sawDiscovery = false;
256
+ /**
257
+ * May a control server inject call ids here? Decided from the project at
258
+ * start-up (see {@link declaresCallId}), not from how negotiation is going.
259
+ */
260
+ expectsBinding;
261
+ /** `readInstallFacts(cwd, serverName)`, started once and shared by everything that asks. */
262
+ installFactsPromise;
73
263
  closing = false;
264
+ /**
265
+ * The work loop's current pause between polls. An instance field, not a
266
+ * local, so that {@link bind} can reset it: a proxy that finds its recorder
267
+ * again must poll it now, not after a backoff it grew while it was gone.
268
+ */
269
+ pollBackoff = 0;
270
+ /** Ends the work loop's current pause early (see {@link pause}). */
271
+ wakeLoop;
272
+ /** A re-registration in flight, so a burst of triggers makes one request. */
273
+ reregistering;
274
+ /**
275
+ * Brings back a recorder that died under this proxy. Made at the first
276
+ * bind: only a proxy that has had a recorder asks for one back.
277
+ */
278
+ reviver;
74
279
  constructor(opts) {
75
280
  this.opts = opts;
76
281
  this.serverName = opts.serverName;
282
+ this.expectsBinding = !opts.standalone && !opts.noCorrelation && this.likelyBound();
283
+ }
284
+ /** The install sidecar's facts for this directory and server. Read once, off the start-up path. */
285
+ installFacts() {
286
+ this.installFactsPromise ??= readInstallFacts(this.opts.cwd, this.serverName);
287
+ return this.installFactsPromise;
288
+ }
289
+ async isInstalled() {
290
+ return (await this.installFacts()).installedHere;
77
291
  }
78
292
  get tier() {
79
293
  return this.tierValue;
@@ -83,22 +297,94 @@ export class ProxySession {
83
297
  }
84
298
  /**
85
299
  * Whether the schema-relaxation half of correlation should be applied to
86
- * `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled:
87
- * relaxation is visible to the model on every call, so it is never paid for
88
- * unless it is actually buying a merge.
300
+ * `tools/list` (Phase 6). Only ever true in Tier 1 with correlation enabled.
89
301
  */
90
302
  get correlationActive() {
91
303
  return this.tierValue === "bound" && !this.opts.noCorrelation;
92
304
  }
305
+ /**
306
+ * Whether `tools/list` should declare the call-id key. True where a control
307
+ * server may inject one: bound, or still negotiating in a project that is set
308
+ * up for it — decided from the project, not from whether registration
309
+ * happened to finish before the host listed tools, so a warm and a cold
310
+ * start show the model the same schemas.
311
+ */
312
+ get declaresCallId() {
313
+ if (this.opts.noCorrelation || this.opts.standalone)
314
+ return false;
315
+ if (isRunnerOff(this.opts.cwd))
316
+ return false;
317
+ return this.tierValue === "bound" || (this.tierValue === "pending" && this.expectsBinding);
318
+ }
319
+ /** False when nothing this proxy sees will be recorded (passthrough, or the runner is off). */
320
+ get recording() {
321
+ return this.tierValue !== "passthrough" && !isRunnerOff(this.opts.cwd);
322
+ }
93
323
  /** Begin tier negotiation. Returns immediately; relaying never waits on it. */
94
324
  start() {
95
325
  if (this.negotiation)
96
326
  return;
327
+ // A hooks-wired entry with no recorder up yet: whether this directory is
328
+ // installed decides the schemas the model is shown, so learn it early —
329
+ // but after relaying has started, never in front of it.
330
+ if (!this.expectsBinding && !this.opts.standalone && !this.opts.noCorrelation && process.env.BIR_CONTROL_URL) {
331
+ void this.isInstalled().then((installed) => {
332
+ if (installed)
333
+ this.expectsBinding = true;
334
+ });
335
+ }
97
336
  this.negotiation = this.negotiate().catch((err) => {
98
337
  logLine("proxy.negotiation_failed", { server: this.serverName, error: errText(err) });
99
- this.tierValue = "standalone";
338
+ if (this.tierValue === "pending")
339
+ this.becomePassthrough("negotiation failed");
100
340
  });
101
341
  }
342
+ /** Cheap first guess, at construction, of whether this proxy will end up bound. */
343
+ likelyBound() {
344
+ if (process.env.BIR_CONTROL_URL && process.env.BIR_CONTROL_TOKEN)
345
+ return true;
346
+ return readDiscovery(this.opts.cwd) !== undefined;
347
+ }
348
+ /**
349
+ * May this proxy record on its own (Tier 2) when no control server answers?
350
+ *
351
+ * The one entry that must NOT is the one that leaks: a Claude Code
352
+ * **user-scope** server. `bir install` wraps it once, in one project, but
353
+ * Claude Code starts it in every project — and Tier 2 there meant uploading
354
+ * that project's tool arguments and results without anyone having asked.
355
+ * Everything else records as it always did:
356
+ *
357
+ * - An explicit SDK channel (`BIR_CONTROL_TOKEN`) or `--standalone`: yes.
358
+ * - An entry without `BIR_CONTROL_URL` (`install --no-hooks`, a `bir wrap`
359
+ * made before any install): Tier 2 is what it asked for.
360
+ * - This directory was installed, or a recorder has run for it: yes.
361
+ * - Otherwise it depends on where the entry came from, which the install
362
+ * sidecar knows by server name. Another client's file (`install
363
+ * --config`, whose host starts servers from any directory — Claude
364
+ * Desktop uses `/`), a project- or local-scope entry (which Claude Code
365
+ * only starts in its own project), or no install record at all (a `bir
366
+ * wrap` entry, which bakes `BIR_CONTROL_URL` too once any install has
367
+ * run): yes. Only when every record for this name is a user-scope one is
368
+ * this a global entry running somewhere nobody set up — and then no.
369
+ *
370
+ * Keying this on `BIR_CONTROL_URL` alone was wrong: `install --config` and
371
+ * `bir wrap` bake it as well, and those entries silently stopped recording
372
+ * whenever their host's directory differed from the install's.
373
+ */
374
+ async optedIn() {
375
+ if (this.opts.standalone || process.env.BIR_CONTROL_TOKEN)
376
+ return true;
377
+ if (!process.env.BIR_CONTROL_URL)
378
+ return true;
379
+ if (this.sawDiscovery)
380
+ return true;
381
+ const facts = await this.installFacts();
382
+ if (facts.installedHere)
383
+ return true;
384
+ if (facts.scopes.length === 0)
385
+ return true;
386
+ return facts.scopes.some((scope) => scope !== "user");
387
+ }
102
388
  async negotiate() {
103
389
  if (this.opts.standalone) {
104
390
  await this.becomeStandalone("--standalone was requested");
@@ -106,50 +392,182 @@ export class ProxySession {
106
392
  }
107
393
  const window = this.opts.discoveryWindowMs ?? DISCOVERY_WINDOW_MS;
108
394
  const deadline = Date.now() + window;
109
- const found = await resolveControl(this.opts.cwd, window);
110
- if (!found?.url) {
111
- await this.becomeStandalone(`no control server within ${window}ms`);
395
+ // A control server that is still coming up — the SessionStart hook is
396
+ // starting it while the host spawns us — answers nothing for a moment.
397
+ // Keep trying inside the window rather than settle on the first refusal.
398
+ for (;;) {
399
+ if (this.closing)
400
+ return;
401
+ if (await this.tryBind())
402
+ return;
403
+ if (Date.now() >= deadline)
404
+ break;
405
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
406
+ }
407
+ if (this.closing)
112
408
  return;
409
+ await this.settleUnbound(`no control server answered within ${window}ms`);
410
+ this.scheduleRebind();
411
+ }
412
+ /** Where this cwd's control server is, if anywhere. */
413
+ findControl() {
414
+ const envUrl = process.env.BIR_CONTROL_URL;
415
+ const envToken = process.env.BIR_CONTROL_TOKEN;
416
+ // Explicit wiring (the Agent SDK channel) wins.
417
+ if (envUrl && envToken)
418
+ return { url: envUrl.replace(/\/+$/, ""), token: envToken };
419
+ // Otherwise this directory's own recorder, wherever it listens. The URL an
420
+ // install baked into the entry is not consulted: for a user-scope server it
421
+ // names *another* project's recorder, whose token is not ours.
422
+ const found = readDiscovery(this.opts.cwd);
423
+ if (found) {
424
+ this.sawDiscovery = true;
425
+ return { url: found.url, token: found.token };
113
426
  }
114
- const client = new ControlClient(found.url, found.token, undefined, {
115
- proxyId: this.proxyId,
116
- startedAt: this.startedAt,
117
- pid: process.pid,
118
- });
119
- const info = {
427
+ return undefined;
428
+ }
429
+ /** Register with this cwd's control server, if one answers. True when bound. */
430
+ async tryBind() {
431
+ const target = this.findControl();
432
+ if (!target)
433
+ return false;
434
+ const key = `${target.url} ${target.token}`;
435
+ const client = this.control && this.controlKey === key
436
+ ? this.control
437
+ : new ControlClient(target.url, target.token, undefined, {
438
+ proxyId: this.proxyId,
439
+ startedAt: this.startedAt,
440
+ pid: process.pid,
441
+ });
442
+ const registered = await client.register({
120
443
  serverName: this.serverName,
121
444
  pid: process.pid,
122
445
  cwd: this.opts.cwd,
123
446
  version: packageVersion(),
124
- };
125
- // A control server that is still coming up — the SessionStart hook is
126
- // starting it while the host spawns us — answers nothing for a moment.
127
- // Keep trying inside the same window rather than settle for Tier 2 on the
128
- // first refusal.
129
- let registered = await client.register(info);
130
- while (!registered && Date.now() < deadline) {
131
- await new Promise((resolve) => {
132
- const t = setTimeout(resolve, 250);
133
- t.unref?.();
447
+ });
448
+ if (!registered || this.closing)
449
+ return false;
450
+ this.bind(client, key, target.url, registered);
451
+ return true;
452
+ }
453
+ bind(client, key, url, registered) {
454
+ const was = this.tierValue;
455
+ this.control = client;
456
+ this.controlKey = key;
457
+ this.pollClient = client;
458
+ this.tierValue = "bound";
459
+ if (!this.reviver && this.opts.revive !== false && !this.opts.standalone) {
460
+ this.reviver = new RecorderReviver({
461
+ ...(this.opts.revive || {}),
462
+ cwd: this.opts.cwd,
463
+ server: this.serverName,
134
464
  });
135
- registered = await client.register(info);
136
465
  }
137
- if (!registered) {
138
- await this.becomeStandalone("control server did not answer /proxy/register");
139
- return;
466
+ if (this.rebindTimer) {
467
+ clearTimeout(this.rebindTimer);
468
+ this.rebindTimer = undefined;
140
469
  }
141
- this.control = client;
142
- this.tierValue = "bound";
143
- logLine("tier.bound", {
470
+ const fields = {
144
471
  server: this.serverName,
145
- control: found.url,
472
+ control: url,
146
473
  sess: registered.sessionId,
147
474
  correlation: this.opts.noCorrelation ? "fingerprint" : "injected",
148
475
  replay: registered.replay ? "on" : undefined,
149
- });
476
+ };
477
+ // The normal start is routine, and so is telling a restarted recorder we
478
+ // are still here; coming back after losing it is worth a line.
479
+ if (was === "pending")
480
+ logDetail("tier.bound", fields);
481
+ else if (was === "bound")
482
+ logDetail("tier.reregistered", fields);
483
+ else
484
+ logLine("tier.rebound", { ...fields, was });
150
485
  this.flushBuffer();
151
- if (registered.replay)
152
- this.startWorkLoop(client, registered.pollHoldMs ?? 25_000);
486
+ this.replayOn = registered.replay === true;
487
+ this.pollHoldMs = registered.pollHoldMs ?? 25_000;
488
+ // A bound proxy always has a work loop — paced to one poll every
489
+ // {@link MAX_POLL_BACKOFF_MS} while replay is off here — because replay is
490
+ // switched on by restarting the recorder, and a restarted recorder on the
491
+ // same address never makes a bound proxy register again. A loop that only
492
+ // started (or only kept going) while replay was on never noticed.
493
+ //
494
+ // Whatever pause the loop grew against the server it had before ends now:
495
+ // this one is answering, and routes nothing to us until we poll it.
496
+ this.pollBackoff = this.replayOn ? 0 : this.maxIdleBackoff();
497
+ this.wakeLoop?.();
498
+ this.startWorkLoop();
499
+ }
500
+ maxIdleBackoff() {
501
+ return this.opts.maxPollBackoffMs ?? MAX_POLL_BACKOFF_MS;
502
+ }
503
+ /**
504
+ * Tell the control server we exist again — it answered after a spell of not
505
+ * answering, so it is probably a restarted one that has never heard of this
506
+ * proxy (its `/health` would not list it, and it would not know this server
507
+ * has a proxy that can strip a call id). One request at a time; best-effort.
508
+ */
509
+ reregister() {
510
+ if (this.reregistering || this.closing || this.tierValue !== "bound")
511
+ return;
512
+ this.reregistering = this.tryBind()
513
+ .catch(() => false)
514
+ .finally(() => {
515
+ this.reregistering = undefined;
516
+ });
517
+ }
518
+ /** Sleep `ms`, or less if {@link wakeLoop} is called first. True when woken early. */
519
+ pause(ms) {
520
+ return new Promise((resolve) => {
521
+ const done = (early) => {
522
+ clearTimeout(timer);
523
+ if (this.wakeLoop === wake)
524
+ this.wakeLoop = undefined;
525
+ resolve(early);
526
+ };
527
+ const wake = () => done(true);
528
+ const timer = setTimeout(() => done(false), ms);
529
+ timer.unref?.();
530
+ this.wakeLoop = wake;
531
+ });
532
+ }
533
+ /** No control server: record on our own if this project opted in, else stay out of the way. */
534
+ async settleUnbound(why) {
535
+ if (!(await this.optedIn())) {
536
+ this.becomePassthrough("this directory is not set up for recording, and no recorder is running for it");
537
+ return;
538
+ }
539
+ // Only the real recorder authenticates; an injected one (tests) has nothing to spread.
540
+ if (!this.opts.recorderFactory)
541
+ await sleep(Math.floor(Math.random() * STANDALONE_JITTER_MS));
542
+ if (this.closing)
543
+ return;
544
+ await this.becomeStandalone(why);
545
+ }
546
+ /** While not bound, look for a control server again now and then. */
547
+ scheduleRebind() {
548
+ if (this.rebindTimer || this.closing || this.opts.standalone)
549
+ return;
550
+ const interval = this.opts.rebindIntervalMs ?? REBIND_INTERVAL_MS;
551
+ this.rebindTimer = setTimeout(() => {
552
+ this.rebindTimer = undefined;
553
+ void (async () => {
554
+ if (this.closing || this.tierValue === "bound")
555
+ return;
556
+ try {
557
+ if (!isRunnerOff(this.opts.cwd) && (await this.tryBind()))
558
+ return;
559
+ }
560
+ catch (err) {
561
+ logDetail("proxy.rebind_failed", { server: this.serverName, error: errText(err) });
562
+ }
563
+ // Still no recorder, where there used to be one: if it died, ask for
564
+ // a new one (off the relay path; see proxy/revive.ts).
565
+ if (!this.closing)
566
+ this.reviver?.nudge("no recorder answered since it was lost");
567
+ this.scheduleRebind();
568
+ })();
569
+ }, interval);
570
+ this.rebindTimer.unref?.();
153
571
  }
154
572
  /**
155
573
  * Take calculated-scenario steps from the control server and run them on the
@@ -159,37 +577,101 @@ export class ProxySession {
159
577
  * allocates an id in the reserved `bir:` space and the transport consumes its
160
578
  * own response in `accept()` before the relay's handlers ever see it — so this
161
579
  * costs **zero tokens**, the model is not involved, and `RecordingInterceptor`
162
- * cannot double-record it. That interface comment says `request()` is "never
163
- * used by the relay"; it was built for `bir doctor` and tests, and this is its
164
- * second, load-bearing user.
580
+ * cannot double-record it.
581
+ *
582
+ * PACING. A long-poll that is held is free; one that is not must not become
583
+ * four requests a second for the rest of the session. So:
584
+ *
585
+ * - held (the server parked it until work or its deadline): poll again now;
586
+ * - unreachable (usually a recorder restarting): back off 250 ms → 5 s
587
+ * ({@link MAX_UNREACHABLE_BACKOFF_MS}), and when it answers again,
588
+ * register again so the new process knows about us;
589
+ * - answered at once, or `{ replay: false }`: back off to one poll every
590
+ * {@link MAX_POLL_BACKOFF_MS}. Never stop: replay is turned back on by a
591
+ * restart that leaves this proxy bound, and a loop that had stopped for
592
+ * good left every later scenario step falling back to recorded outputs
593
+ * until the host itself was restarted.
594
+ *
595
+ * {@link bind} resets the pace and ends a pause early, so a proxy that finds
596
+ * its recorder again polls it at once rather than after a stale backoff.
165
597
  */
166
- startWorkLoop(client, holdMs) {
598
+ startWorkLoop() {
167
599
  const upstream = this.opts.upstream;
168
600
  if (!upstream || this.workLoop)
169
601
  return;
170
602
  this.workLoop = (async () => {
171
- logLine("replay.worker", { server: this.serverName, why: "polling for scenario steps" });
172
- while (!this.closing) {
173
- let work;
603
+ logDetail("replay.worker", { server: this.serverName, why: "polling for scenario steps" });
604
+ let wasUnreachable = false;
605
+ while (!this.closing && !upstream.dead) {
606
+ // Woken early by bind(): re-read the pace it set rather than poll blindly.
607
+ if (this.pollBackoff > 0 && (await this.pause(this.pollBackoff)))
608
+ continue;
609
+ if (this.closing || upstream.dead)
610
+ break;
611
+ const client = this.pollClient;
612
+ if (!client)
613
+ break;
614
+ const holdMs = this.pollHoldMs;
615
+ const asked = Date.now();
616
+ let outcome;
174
617
  try {
175
- work = await client.poll(this.serverName, holdMs);
618
+ outcome = await client.pollWork(this.serverName, holdMs);
176
619
  }
177
620
  catch {
178
- work = undefined;
621
+ outcome = { reachable: false };
179
622
  }
180
623
  if (this.closing)
181
624
  break;
625
+ const work = outcome.work;
626
+ // Re-bound while this poll was out: bind() already set the pace for
627
+ // the new server, and this answer says nothing about it. Work handed
628
+ // out by the old one is still run, below — it is waiting for it.
629
+ const current = client === this.pollClient;
182
630
  if (!work) {
183
- // Either an empty hold (normal) or an unreachable server. A short
184
- // pause keeps the second case from becoming a busy loop.
185
- await new Promise((resolve) => {
186
- const t = setTimeout(resolve, 250);
187
- t.unref?.();
188
- });
631
+ if (!current)
632
+ continue;
633
+ if (!outcome.reachable) {
634
+ wasUnreachable = true;
635
+ this.reviver?.nudge("the recorder stopped answering");
636
+ this.pollBackoff = Math.min(MAX_UNREACHABLE_BACKOFF_MS, this.pollBackoff ? this.pollBackoff * 2 : 250);
637
+ continue;
638
+ }
639
+ if (wasUnreachable) {
640
+ wasUnreachable = false;
641
+ this.reregister();
642
+ }
643
+ if (outcome.replayOff) {
644
+ if (this.replayOn) {
645
+ logDetail("replay.worker_idle", { server: this.serverName, why: "replay is off; polling slowly" });
646
+ }
647
+ this.replayOn = false;
648
+ this.pollBackoff = this.maxIdleBackoff();
649
+ continue;
650
+ }
651
+ const held = Date.now() - asked >= Math.min(holdMs / 2, 5_000);
652
+ if (held) {
653
+ this.replayOn = true;
654
+ this.pollBackoff = 0;
655
+ }
656
+ else {
657
+ this.pollBackoff = Math.min(this.maxIdleBackoff(), this.pollBackoff ? this.pollBackoff * 2 : 250);
658
+ }
189
659
  continue;
190
660
  }
661
+ if (current) {
662
+ if (wasUnreachable) {
663
+ wasUnreachable = false;
664
+ this.reregister();
665
+ }
666
+ this.replayOn = true;
667
+ this.pollBackoff = 0;
668
+ }
669
+ // A little under the control server's own budget for the step, so that
670
+ // on a timeout the cancellation reaches the upstream *before* the server
671
+ // gives up on it and, possibly, substitutes a recorded output.
672
+ const budget = Math.max(1, work.timeoutMs - Math.min(1_000, Math.floor(work.timeoutMs / 10)));
191
673
  try {
192
- const result = await upstream.request("tools/call", { name: work.toolName, arguments: work.arguments }, AbortSignal.timeout(work.timeoutMs));
674
+ const result = await upstream.request("tools/call", { name: work.toolName, arguments: work.arguments }, AbortSignal.timeout(budget));
193
675
  await client.result(work.workId, result);
194
676
  }
195
677
  catch (err) {
@@ -200,7 +682,11 @@ export class ProxySession {
200
682
  await client.result(work.workId, undefined, errText(err));
201
683
  }
202
684
  }
203
- })().catch((err) => logLine("replay.worker_failed", { server: this.serverName, error: errText(err) }));
685
+ })()
686
+ .catch((err) => logLine("replay.worker_failed", { server: this.serverName, error: errText(err) }))
687
+ .finally(() => {
688
+ this.workLoop = undefined;
689
+ });
204
690
  }
205
691
  /**
206
692
  * Take ownership of a run ourselves.
@@ -211,45 +697,88 @@ export class ProxySession {
211
697
  * buffered nor recordable, and every `tools/call` in that window is lost.
212
698
  * `dispatch` re-buffers defensively too — belt and braces on a race whose
213
699
  * failure mode is silent data loss.
700
+ *
701
+ * One run per proxy: a proxy that went standalone, re-bound, and lost its
702
+ * control server again carries on in the run it already owns.
214
703
  */
215
704
  async becomeStandalone(why) {
216
- logLine("tier.standalone", {
217
- server: this.serverName,
218
- why,
219
- records: "MCP calls only — no prompt, no final answer, no built-in steps",
220
- });
221
- this.recorder = await this.buildRecorder();
222
- this.runId = this.recorder.startRun("", {
223
- recorder: "baseinstrunner",
224
- tier: "standalone",
225
- host: { app: process.env.BIR_HOST_APP ?? "unknown", version: process.env.BIR_HOST_VERSION },
226
- wrappedServers: [this.serverName],
227
- lossy: this.lossyValue,
228
- machine: hostname(),
229
- cwd: this.opts.cwd,
230
- });
705
+ // The first Tier 2 start, its run and (in close) its finish are routine —
706
+ // every proxy for a host without hooks does this on every start — so they
707
+ // are BIR_VERBOSE detail: Claude Code files each stderr line of an MCP
708
+ // server as a "Server stderr" error. Losing a recorder mid-session
709
+ // (tier.downgrade), an overflow (run.lossy) and failures stay on stderr.
710
+ if (!this.recorder || !this.runId) {
711
+ logDetail("tier.standalone", {
712
+ server: this.serverName,
713
+ why,
714
+ records: "MCP calls only — no prompt, no final answer, no built-in steps",
715
+ });
716
+ this.recorder = await this.buildRecorder();
717
+ this.runId = this.recorder.startRun("", {
718
+ recorder: "baseinstrunner",
719
+ tier: "standalone",
720
+ host: { app: process.env.BIR_HOST_APP ?? "unknown", version: process.env.BIR_HOST_VERSION },
721
+ wrappedServers: [this.serverName],
722
+ lossy: this.lossyValue,
723
+ machine: hostname(),
724
+ cwd: this.opts.cwd,
725
+ });
726
+ logDetail("run.start", { run: this.runId, tier: "standalone", server: this.serverName });
727
+ }
728
+ else {
729
+ logDetail("tier.standalone", { server: this.serverName, why, run: this.runId, resumed: true });
730
+ }
231
731
  this.tierValue = "standalone";
232
- logLine("run.start", { run: this.runId, tier: "standalone", server: this.serverName });
233
732
  this.flushBuffer();
234
733
  }
734
+ /**
735
+ * Record nothing; relay only. Held steps are released, not kept.
736
+ *
737
+ * Detail, not a stderr line: this is the designed outcome for a user-scope
738
+ * server in a project nobody set up, and a line there would appear as a
739
+ * "Server stderr" error in a project that never asked for the runner at all.
740
+ * `bir doctor`, run in that directory, is where "not set up here" is told.
741
+ */
742
+ becomePassthrough(why) {
743
+ this.tierValue = "passthrough";
744
+ this.buffer.length = 0;
745
+ logDetail("tier.passthrough", { server: this.serverName, why, records: "nothing" });
746
+ }
235
747
  async buildRecorder() {
236
748
  if (this.opts.recorderFactory) {
237
749
  return (await this.opts.recorderFactory()) ?? new NullRecorder();
238
750
  }
239
751
  // BIR_AUTH_URL, or the address 'bir setup' stored — the same lookup the
240
752
  // control server and the CLI make, so no two processes disagree about it.
241
- const baseUrl = resolveAuthUrl();
242
- if (!baseUrl) {
243
- logLine("recorder.disabled", { why: "no service address — run 'bir setup', or set BIR_AUTH_URL" });
753
+ // "Not set up" and "signed out" are states `bir doctor` reports, not
754
+ // failures of this session: detail here, so a signed-out machine does not
755
+ // add a stderr "error" to every wrapped server on every start.
756
+ const configured = resolveAuthUrl();
757
+ if (!configured) {
758
+ logDetail("recorder.disabled", { why: "no service address — run 'bir setup', or set BIR_AUTH_URL" });
244
759
  return new NullRecorder();
245
760
  }
761
+ // The address as the rest of the runner binds sessions to it: a plain-http
762
+ // service that redirects to https is used at its https address (a
763
+ // cross-origin redirect strips the bearer token), and a session saved
764
+ // under that address is this one's. Asking with the configured http form
765
+ // instead read as "issued by another service" (auth.url_mismatch), and
766
+ // the proxy recorded nothing for its whole life.
767
+ const baseUrl = await normalizedServiceUrl(configured);
768
+ if (this.closing)
769
+ return new NullRecorder();
246
770
  // A proxy runs inside the host's process tree with its stdio bound to the
247
771
  // JSON-RPC stream: there is nowhere to prompt, and blocking on one would
248
772
  // hang the host's server startup. `authenticate` never interacts — signing
249
773
  // in lives in `bir login` alone — so that is guaranteed here, not requested.
250
- const session = await authenticate({ authUrl: baseUrl });
774
+ //
775
+ // `keepStaleOnNoAnswer`: a service that is down or slow at the moment this
776
+ // proxy goes standalone must not disable recording for the proxy's whole
777
+ // life when the refresh token is fine. The recorder renews the session in
778
+ // the background once the service answers (a 401 there refreshes).
779
+ const session = await authenticate({ authUrl: baseUrl, keepStaleOnNoAnswer: true });
251
780
  if (!session) {
252
- logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
781
+ logDetail("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
253
782
  return new NullRecorder();
254
783
  }
255
784
  return new RemoteRecorder({ baseUrl, session });
@@ -259,6 +788,8 @@ export class ProxySession {
259
788
  * *after* the result is already on its way to the host, and it never awaits.
260
789
  */
261
790
  reportStep(step) {
791
+ if (!this.recording)
792
+ return;
262
793
  if (this.tierValue === "pending") {
263
794
  this.bufferStep(step);
264
795
  return;
@@ -267,7 +798,7 @@ export class ProxySession {
267
798
  }
268
799
  /** Hold a step until an owner exists. Bounded, and honest when it overflows. */
269
800
  bufferStep(step) {
270
- if (this.buffer.length >= 1000) {
801
+ if (this.buffer.length >= DISCOVERY_BUFFER) {
271
802
  this.buffer.shift();
272
803
  if (!this.lossyValue) {
273
804
  logLine("run.lossy", { why: "discovery buffer overflowed", server: this.serverName });
@@ -285,23 +816,37 @@ export class ProxySession {
285
816
  }
286
817
  }
287
818
  dispatch(step) {
819
+ if (this.tierValue === "passthrough")
820
+ return;
288
821
  if (this.tierValue === "bound" && this.control) {
289
822
  const control = this.control;
290
823
  this.queue.push(async () => {
291
- const ok = await control.report(step);
292
- if (!ok) {
293
- // §10: the control server died mid-session. Everything after this is
294
- // Tier 2, and the run is flagged lossy — the steps already sent live in
295
- // the bound run, the rest in ours.
296
- logLine("tier.downgrade", {
297
- server: this.serverName,
298
- why: "control server stopped answering",
299
- });
300
- this.control = undefined;
301
- this.lossyValue = true;
302
- await this.becomeStandalone("control server stopped answering");
824
+ // Queued under a control server this proxy has since left (or found
825
+ // again under another address): route it by the tier as it is *now*,
826
+ // rather than fail against the old one and downgrade a second time.
827
+ if (this.control !== control) {
303
828
  this.dispatch(step);
829
+ return;
830
+ }
831
+ let ok = await control.report(step);
832
+ if (!ok && !this.closing) {
833
+ // One refused report is not a dead recorder — a restart for an
834
+ // upgrade refuses connections for a second or two.
835
+ await sleep(1_000);
836
+ if (this.control !== control) {
837
+ this.dispatch(step);
838
+ return;
839
+ }
840
+ ok = await control.report(step);
841
+ // Refused, then taken: most likely a restarted recorder, which has
842
+ // never heard of this proxy. Introduce ourselves again.
843
+ if (ok)
844
+ this.reregister();
304
845
  }
846
+ if (ok)
847
+ return;
848
+ await this.downgrade(control);
849
+ this.dispatch(step);
305
850
  });
306
851
  return;
307
852
  }
@@ -320,24 +865,88 @@ export class ProxySession {
320
865
  const toolError = step.isError
321
866
  ? (step.errorMessage ?? serializeCapped(redact(step.result)))
322
867
  : undefined;
323
- this.queue.push(() => {
868
+ this.queue.push(async () => {
324
869
  recorder.recordToolSelected(runId, pair.selected, { toolName, toolInput });
325
870
  recorder.recordToolResponse(runId, pair.response, { toolName, toolOutput, toolError });
871
+ // Wait for the service to take them before the next step goes. The
872
+ // recorder's own per-run chain is unbounded; waiting here is what makes
873
+ // the step queue's bound apply to what is actually waiting on the
874
+ // network, so a slow or dead service costs a bounded backlog (oldest
875
+ // dropped, run flagged lossy) instead of a growing one.
876
+ //
877
+ // Not while closing: then every step is handed over at once, and the
878
+ // one bounded flush in close() waits for them all.
879
+ if (!this.closing)
880
+ await recorder.flush(runId);
881
+ });
882
+ }
883
+ /**
884
+ * The control server stopped answering. Exactly once per loss, however many
885
+ * reports were queued against it: later failures wait for this one and are
886
+ * re-routed, instead of each building a recorder and opening a run.
887
+ */
888
+ downgrade(control) {
889
+ if (this.control !== control)
890
+ return this.downgrading ?? Promise.resolve();
891
+ this.downgrading ??= (async () => {
892
+ // §10: the control server died mid-session. What follows goes to a run of
893
+ // our own (where this project records at all) until it is found again.
894
+ logLine("tier.downgrade", {
895
+ server: this.serverName,
896
+ why: "control server stopped answering",
897
+ });
898
+ this.control = undefined;
899
+ this.controlKey = undefined;
900
+ this.lossyValue = true;
901
+ this.reviver?.nudge("the recorder stopped taking step reports");
902
+ await this.settleUnbound("control server stopped answering");
903
+ this.scheduleRebind();
904
+ })().finally(() => {
905
+ this.downgrading = undefined;
326
906
  });
907
+ return this.downgrading;
327
908
  }
328
- /** Close the Tier 2 run, if we own one, and drain the queue. */
909
+ /**
910
+ * Close the Tier 2 run, if we own one, and drain the queue — within
911
+ * {@link CLOSE_BUDGET_MS} all told. bir-proxy gives shutdown a hard 3 s; a
912
+ * close that spent it waiting on a slow service never reached `finishRun`.
913
+ * Whatever has not been sent by then carries on in the background for as
914
+ * long as the process lives.
915
+ */
329
916
  async close() {
917
+ const deadline = Date.now() + CLOSE_BUDGET_MS;
918
+ const left = () => Math.max(0, deadline - Date.now());
330
919
  // Stops the work loop at its next turn. It is not awaited: it may be parked
331
920
  // in a 25 s long-poll, and shutdown does not wait on a poll that will be
332
921
  // answered by the control server closing anyway.
333
922
  this.closing = true;
334
- await this.negotiation?.catch(() => undefined);
923
+ this.reviver?.stop();
924
+ this.wakeLoop?.();
925
+ if (this.rebindTimer) {
926
+ clearTimeout(this.rebindTimer);
927
+ this.rebindTimer = undefined;
928
+ }
929
+ // The interceptor reports on a later tick (setImmediate), after the relay
930
+ // has written the answer — and at shutdown the relay has just answered or
931
+ // abandoned whatever was in flight, with those reports still scheduled.
932
+ // Let them land first, or the run is finished (or the process gone)
933
+ // before its last steps are queued.
934
+ await new Promise((resolve) => setImmediate(resolve));
935
+ // Negotiation stops at its next turn once `closing` is set, but a turn can
936
+ // be a sign-in refresh against a slow service: bounded, not awaited whole.
937
+ if (this.negotiation)
938
+ await settledWithin(this.negotiation, left());
335
939
  this.flushBuffer();
336
- await this.queue.flush();
940
+ // Steps waiting behind a slow service: each is handed to the recorder at
941
+ // once now (dispatch skips its per-step wait while closing), so this is
942
+ // one in-progress wait at most.
943
+ await settledWithin(this.queue.flush(), left());
337
944
  if (this.recorder && this.runId) {
338
945
  this.recorder.finishRun(this.runId, undefined, { durationMs: Date.now() - this.startedAt });
339
- await this.recorder.flush(this.runId).catch(() => undefined);
340
- logLine("run.finish", {
946
+ await this.recorder
947
+ .flush(this.runId, { timeoutMs: Math.max(MIN_FINAL_FLUSH_MS, left()) })
948
+ .catch(() => undefined);
949
+ logDetail("run.finish", {
341
950
  run: this.runId,
342
951
  tier: "standalone",
343
952
  steps: this.ordering.next,