@basein/runner 0.2.12 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/replay/derive.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export interface DeriveOptions extends DeriveContext {
|
|
|
65
65
|
/** Used when there is no `apiKey`. Absent too → recorded samples. */
|
|
66
66
|
service?: DeriveService;
|
|
67
67
|
model?: string;
|
|
68
|
+
/** Ends the request. Absent → the service timeout (`BIR_SERVICE_TIMEOUT_MS`). */
|
|
68
69
|
signal?: AbortSignal;
|
|
69
70
|
/** Injected by tests. Defaults to global `fetch`. */
|
|
70
71
|
fetchImpl?: typeof fetch;
|
package/dist/replay/derive.js
CHANGED
|
@@ -30,10 +30,50 @@
|
|
|
30
30
|
* parameters from the same prompt — otherwise a scenario would behave one way
|
|
31
31
|
* under RRepeat and another under BaseInstRunner.
|
|
32
32
|
*/
|
|
33
|
+
import { redactText } from "../record/redact.js";
|
|
34
|
+
import { isUnhealthyStatus, serviceHealth, timeoutSignal } from "../util/service-health.js";
|
|
33
35
|
import { calculateCostUsd } from "./pricing.js";
|
|
34
36
|
export const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
|
|
35
37
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
36
38
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
39
|
+
/**
|
|
40
|
+
* The turn's context as it may leave this machine.
|
|
41
|
+
*
|
|
42
|
+
* Tool results are whatever the agent read — a config file, an `.env`, a page
|
|
43
|
+
* of logs — and they travel to the service (or to the model API) inside the
|
|
44
|
+
* extraction prompt. The transcript reader redacts them already; this is the
|
|
45
|
+
* same pass again at the point they leave, so no future caller of `derive` can
|
|
46
|
+
* forget it. `redactText` is idempotent.
|
|
47
|
+
*/
|
|
48
|
+
function outgoingContext(ctx) {
|
|
49
|
+
return {
|
|
50
|
+
liveCall: ctx.liveCall
|
|
51
|
+
? { toolName: ctx.liveCall.toolName, toolInput: redactText(ctx.liveCall.toolInput) }
|
|
52
|
+
: undefined,
|
|
53
|
+
recentResults: ctx.recentResults?.map((r) => redactText(r)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The prompt as it may leave this machine — the user's words, or on an intent
|
|
58
|
+
* arm the model's own reasoning, which often quotes a file it just read. The
|
|
59
|
+
* recorder redacts the same text before it sends it anywhere; derivation used
|
|
60
|
+
* to be the one copy that went out whole, to the service or to the model API.
|
|
61
|
+
* A redacted secret is never a parameter value a scenario could use, and
|
|
62
|
+
* `redactText` leaves ordinary prose (`region=EMEA`) as it is.
|
|
63
|
+
*/
|
|
64
|
+
function outgoingPrompt(prompt) {
|
|
65
|
+
return redactText(prompt);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Report a service answer to the breaker with the time its request started,
|
|
69
|
+
* so an answer to a request sent *before* a hot path gave up on the service
|
|
70
|
+
* does not end the cool-down that give-up began (util/service-health.ts).
|
|
71
|
+
* A breaker that takes no start time reads the call as a plain `ok()`.
|
|
72
|
+
*/
|
|
73
|
+
function serviceOk(startedAt) {
|
|
74
|
+
const ok = serviceHealth.ok.bind(serviceHealth);
|
|
75
|
+
ok(startedAt);
|
|
76
|
+
}
|
|
37
77
|
/** Every parameter at its recorded sample value. The zero-cost fallback. */
|
|
38
78
|
function sampleValues(schema) {
|
|
39
79
|
const out = {};
|
|
@@ -102,19 +142,19 @@ export function buildExtractionPrompt(prompt, intent, schema, ctx = {}) {
|
|
|
102
142
|
.map((r) => `\`\`\`\n${r}\n\`\`\``)
|
|
103
143
|
.join("\n")}\n`
|
|
104
144
|
: "";
|
|
105
|
-
return `You are extracting parameter values from a user prompt to execute a pre-defined scenario.
|
|
106
|
-
|
|
107
|
-
Scenario intent: ${intent}
|
|
108
|
-
${live}
|
|
109
|
-
Parameters needed (with sample values from past runs):
|
|
110
|
-
${descriptions}
|
|
111
|
-
|
|
112
|
-
User prompt:
|
|
113
|
-
${prompt}
|
|
114
|
-
${recent}
|
|
115
|
-
Extract the parameter values. A setting you cannot find takes its sample value. A target you cannot find in the prompt, the reasoning, the call or the results is null — never invent it and never copy the sample.
|
|
116
|
-
|
|
117
|
-
Respond ONLY with a valid JSON object mapping each parameter name to its value. No markdown fences, no prose.
|
|
145
|
+
return `You are extracting parameter values from a user prompt to execute a pre-defined scenario.
|
|
146
|
+
|
|
147
|
+
Scenario intent: ${intent}
|
|
148
|
+
${live}
|
|
149
|
+
Parameters needed (with sample values from past runs):
|
|
150
|
+
${descriptions}
|
|
151
|
+
|
|
152
|
+
User prompt:
|
|
153
|
+
${prompt}
|
|
154
|
+
${recent}
|
|
155
|
+
Extract the parameter values. A setting you cannot find takes its sample value. A target you cannot find in the prompt, the reasoning, the call or the results is null — never invent it and never copy the sample.
|
|
156
|
+
|
|
157
|
+
Respond ONLY with a valid JSON object mapping each parameter name to its value. No markdown fences, no prose.
|
|
118
158
|
Example: { "directory": "src/utils", "pattern": "*.ts" }`;
|
|
119
159
|
}
|
|
120
160
|
/**
|
|
@@ -161,14 +201,11 @@ export async function deriveParameters(opts) {
|
|
|
161
201
|
messages: [
|
|
162
202
|
{
|
|
163
203
|
role: "user",
|
|
164
|
-
content: buildExtractionPrompt(opts.prompt, opts.intent, schema, {
|
|
165
|
-
liveCall: opts.liveCall,
|
|
166
|
-
recentResults: opts.recentResults,
|
|
167
|
-
}),
|
|
204
|
+
content: buildExtractionPrompt(outgoingPrompt(opts.prompt), opts.intent, schema, outgoingContext({ liveCall: opts.liveCall, recentResults: opts.recentResults })),
|
|
168
205
|
},
|
|
169
206
|
],
|
|
170
207
|
}),
|
|
171
|
-
signal: opts.signal,
|
|
208
|
+
signal: opts.signal ?? timeoutSignal(),
|
|
172
209
|
});
|
|
173
210
|
if (!res.ok) {
|
|
174
211
|
const detail = await res.text().catch(() => "");
|
|
@@ -262,6 +299,12 @@ async function deriveViaService(opts, service, schema, keys) {
|
|
|
262
299
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
263
300
|
const url = `${service.baseUrl.replace(/\/+$/, "")}/scenarios/${service.scenarioId}/derive`;
|
|
264
301
|
let body;
|
|
302
|
+
// Every answer and every failure is reported to the process-wide breaker
|
|
303
|
+
// (util/service-health.ts): a derivation that timed out is the same news
|
|
304
|
+
// about the service as a step upload that did, and the next hook that would
|
|
305
|
+
// wait on it should know.
|
|
306
|
+
const context = outgoingContext({ liveCall: opts.liveCall, recentResults: opts.recentResults });
|
|
307
|
+
const startedAt = Date.now();
|
|
265
308
|
try {
|
|
266
309
|
const res = await doFetch(url, {
|
|
267
310
|
method: "POST",
|
|
@@ -270,17 +313,31 @@ async function deriveViaService(opts, service, schema, keys) {
|
|
|
270
313
|
authorization: `Bearer ${service.token}`,
|
|
271
314
|
},
|
|
272
315
|
body: JSON.stringify({
|
|
273
|
-
prompt: opts.prompt,
|
|
274
|
-
liveCall:
|
|
275
|
-
recentResults:
|
|
316
|
+
prompt: outgoingPrompt(opts.prompt),
|
|
317
|
+
liveCall: context.liveCall,
|
|
318
|
+
recentResults: context.recentResults,
|
|
276
319
|
}),
|
|
277
|
-
signal: opts.signal,
|
|
320
|
+
signal: opts.signal ?? timeoutSignal(),
|
|
278
321
|
});
|
|
279
|
-
if (!res.ok)
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
if (isUnhealthyStatus(res.status))
|
|
324
|
+
serviceHealth.fail(`derive: HTTP ${res.status}`);
|
|
325
|
+
else
|
|
326
|
+
serviceOk(startedAt);
|
|
280
327
|
return samples(`service HTTP ${res.status}`);
|
|
281
|
-
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
body = (await res.json());
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// A 200 that is not the API's JSON — a captive portal, a proxy's page.
|
|
334
|
+
serviceHealth.fail("derive: the answer was not JSON");
|
|
335
|
+
return samples(err instanceof Error ? err.message : String(err));
|
|
336
|
+
}
|
|
337
|
+
serviceOk(startedAt);
|
|
282
338
|
}
|
|
283
339
|
catch (err) {
|
|
340
|
+
serviceHealth.fail(`derive: ${err instanceof Error ? err.message : String(err)}`);
|
|
284
341
|
return samples(err instanceof Error ? err.message : String(err));
|
|
285
342
|
}
|
|
286
343
|
if (!body || typeof body !== "object")
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* already dominated by the upstream's own round trip.
|
|
17
17
|
*
|
|
18
18
|
* A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
|
|
19
|
-
* is not an error: `call` rejects with `no_proxy`, and the
|
|
20
|
-
* the
|
|
19
|
+
* is not an error: `call` rejects with `no_proxy`, and the plan stops at that
|
|
20
|
+
* step and hands the rest to the model (plan.ts, `DriveOptions`).
|
|
21
21
|
*
|
|
22
22
|
* WHICH PROXY. Several proxies can poll for the same server at once — one per
|
|
23
23
|
* Claude Code session in the project, and a session's proxy outlives a
|
|
@@ -30,14 +30,21 @@
|
|
|
30
30
|
*
|
|
31
31
|
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
32
|
* proxy reported, or from an earlier step of this session it ran;
|
|
33
|
-
* 2. otherwise
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
|
+
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
|
+
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
+
* its own. With two sessions open, the one that has not called the server
|
|
37
|
+
* yet cannot be told apart from the other: "the newest" was a guess, and
|
|
38
|
+
* when the older session asked it ran the step on the newer one's
|
|
39
|
+
* upstream — another browser page, another database session — and
|
|
40
|
+
* answered wrongly;
|
|
41
|
+
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
|
+
* session is never used for this one: the call rejects with a
|
|
43
|
+
* {@link NoSessionProxyError} — nothing was sent, exactly like
|
|
44
|
+
* {@link NO_PROXY} — so the step is recorded as not run (never as a
|
|
45
|
+
* failure of the scenario: two sessions open is the environment, not the
|
|
46
|
+
* scenario being wrong), the plan stops, and the agent gets the hand-over
|
|
47
|
+
* and calls the tool on its own upstream, with the reason in front of it.
|
|
41
48
|
*
|
|
42
49
|
* A session's bindings are released when it ends (a `/clear` starts a new
|
|
43
50
|
* session on the same proxies). Legacy proxies, which send no id, share one
|
|
@@ -72,6 +79,20 @@ export interface PollerIdentity {
|
|
|
72
79
|
}
|
|
73
80
|
/** Rejection reason when no proxy is polling for a server. */
|
|
74
81
|
export declare const NO_PROXY = "no_proxy";
|
|
82
|
+
/**
|
|
83
|
+
* Rejection when proxies poll for the server but none can be shown to be the
|
|
84
|
+
* asking session's: every one is bound to another session, or the unbound ones
|
|
85
|
+
* may be another open session's. Like {@link NO_PROXY}, nothing was sent; the
|
|
86
|
+
* message is the explanation the model reads in the hand-over.
|
|
87
|
+
*/
|
|
88
|
+
export declare class NoSessionProxyError extends Error {
|
|
89
|
+
readonly code = "no_session_proxy";
|
|
90
|
+
constructor(message: string);
|
|
91
|
+
}
|
|
92
|
+
/** Rejection reason at shutdown for work no proxy had taken yet: never sent. */
|
|
93
|
+
export declare const QUEUE_CLOSED = "control server closed";
|
|
94
|
+
/** Rejection reason at shutdown for work a proxy had already taken: it may have run. */
|
|
95
|
+
export declare const QUEUE_CLOSED_IN_FLIGHT = "control server closed while the call was in flight";
|
|
75
96
|
export declare class ProxyWorkQueue {
|
|
76
97
|
/** Work held for a proxy that was mid-round-trip when it was dispatched, per proxy id. */
|
|
77
98
|
private readonly pending;
|
|
@@ -103,10 +124,13 @@ export declare class ProxyWorkQueue {
|
|
|
103
124
|
/**
|
|
104
125
|
* Dispatch one call and await its result.
|
|
105
126
|
*
|
|
106
|
-
* Rejects with {@link NO_PROXY} when nothing is polling for `serverName`,
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
127
|
+
* Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, with
|
|
128
|
+
* a {@link NoSessionProxyError} when proxies poll but none can be shown to be
|
|
129
|
+
* `route.sessionId`'s (nothing sent either), and with `timeout` when the
|
|
130
|
+
* proxy takes the work but never answers. The two
|
|
131
|
+
* are not the same news: `no_proxy` means nothing was sent, while a timeout
|
|
132
|
+
* means the call may well have done its work upstream — the controller tells
|
|
133
|
+
* the model which (controller.ts, `classifyWorkError`).
|
|
110
134
|
*/
|
|
111
135
|
call(serverName: string, toolName: string, args: unknown, timeoutMs: number, route?: WorkRoute): Promise<unknown>;
|
|
112
136
|
/** Remove queued work that timed out, so a later poll never gets stale work. */
|
|
@@ -121,10 +145,21 @@ export declare class ProxyWorkQueue {
|
|
|
121
145
|
private present;
|
|
122
146
|
/**
|
|
123
147
|
* 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
|
|
125
|
-
* one belongs to a different session
|
|
148
|
+
* Undefined when no proxy is live at all; `other_sessions` when none can be
|
|
149
|
+
* shown to be this session's: every live one belongs to a different session,
|
|
150
|
+
* or the unbound ones might, because another session is live too.
|
|
126
151
|
*/
|
|
127
152
|
private pickProxy;
|
|
153
|
+
/**
|
|
154
|
+
* Whether a session other than `sessionId` is evidently live.
|
|
155
|
+
*
|
|
156
|
+
* Two signs, both from proxies that are polling right now: one is bound to
|
|
157
|
+
* another session, or one server has two proxies polling. Claude Code starts
|
|
158
|
+
* one proxy per server per session, so a second proxy for the same server is
|
|
159
|
+
* a second session, whether or not it has called anything yet. A pool of
|
|
160
|
+
* legacy proxies shares one record and says nothing either way.
|
|
161
|
+
*/
|
|
162
|
+
private otherSessionLive;
|
|
128
163
|
/**
|
|
129
164
|
* Free every proxy bound to `sessionId`: the session ended. After a `/clear`
|
|
130
165
|
* the same proxies serve the next session, which must be able to pick them.
|
package/dist/replay/executor.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* already dominated by the upstream's own round trip.
|
|
17
17
|
*
|
|
18
18
|
* A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
|
|
19
|
-
* is not an error: `call` rejects with `no_proxy`, and the
|
|
20
|
-
* the
|
|
19
|
+
* is not an error: `call` rejects with `no_proxy`, and the plan stops at that
|
|
20
|
+
* step and hands the rest to the model (plan.ts, `DriveOptions`).
|
|
21
21
|
*
|
|
22
22
|
* WHICH PROXY. Several proxies can poll for the same server at once — one per
|
|
23
23
|
* Claude Code session in the project, and a session's proxy outlives a
|
|
@@ -30,14 +30,21 @@
|
|
|
30
30
|
*
|
|
31
31
|
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
32
|
* proxy reported, or from an earlier step of this session it ran;
|
|
33
|
-
* 2. otherwise
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* 2. otherwise an unbound proxy, but ONLY while no other session is live
|
|
34
|
+
* (none bound elsewhere, and no server with two proxies polling — each
|
|
35
|
+
* session starts its own). A lone session's unbound proxy can only be
|
|
36
|
+
* its own. With two sessions open, the one that has not called the server
|
|
37
|
+
* yet cannot be told apart from the other: "the newest" was a guess, and
|
|
38
|
+
* when the older session asked it ran the step on the newer one's
|
|
39
|
+
* upstream — another browser page, another database session — and
|
|
40
|
+
* answered wrongly;
|
|
41
|
+
* 3. otherwise NOBODY. A proxy bound, or possibly belonging, to another
|
|
42
|
+
* session is never used for this one: the call rejects with a
|
|
43
|
+
* {@link NoSessionProxyError} — nothing was sent, exactly like
|
|
44
|
+
* {@link NO_PROXY} — so the step is recorded as not run (never as a
|
|
45
|
+
* failure of the scenario: two sessions open is the environment, not the
|
|
46
|
+
* scenario being wrong), the plan stops, and the agent gets the hand-over
|
|
47
|
+
* and calls the tool on its own upstream, with the reason in front of it.
|
|
41
48
|
*
|
|
42
49
|
* A session's bindings are released when it ends (a `/clear` starts a new
|
|
43
50
|
* session on the same proxies). Legacy proxies, which send no id, share one
|
|
@@ -53,6 +60,23 @@ import { randomUUID } from "node:crypto";
|
|
|
53
60
|
import { logDetail } from "../util/log.js";
|
|
54
61
|
/** Rejection reason when no proxy is polling for a server. */
|
|
55
62
|
export const NO_PROXY = "no_proxy";
|
|
63
|
+
/**
|
|
64
|
+
* Rejection when proxies poll for the server but none can be shown to be the
|
|
65
|
+
* asking session's: every one is bound to another session, or the unbound ones
|
|
66
|
+
* may be another open session's. Like {@link NO_PROXY}, nothing was sent; the
|
|
67
|
+
* message is the explanation the model reads in the hand-over.
|
|
68
|
+
*/
|
|
69
|
+
export class NoSessionProxyError extends Error {
|
|
70
|
+
code = "no_session_proxy";
|
|
71
|
+
constructor(message) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "NoSessionProxyError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Rejection reason at shutdown for work no proxy had taken yet: never sent. */
|
|
77
|
+
export const QUEUE_CLOSED = "control server closed";
|
|
78
|
+
/** Rejection reason at shutdown for work a proxy had already taken: it may have run. */
|
|
79
|
+
export const QUEUE_CLOSED_IN_FLIGHT = "control server closed while the call was in flight";
|
|
56
80
|
/**
|
|
57
81
|
* How long after its last poll a proxy is still considered present.
|
|
58
82
|
*
|
|
@@ -173,10 +197,13 @@ export class ProxyWorkQueue {
|
|
|
173
197
|
/**
|
|
174
198
|
* Dispatch one call and await its result.
|
|
175
199
|
*
|
|
176
|
-
* Rejects with {@link NO_PROXY} when nothing is polling for `serverName`,
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
200
|
+
* Rejects with {@link NO_PROXY} when nothing is polling for `serverName`, with
|
|
201
|
+
* a {@link NoSessionProxyError} when proxies poll but none can be shown to be
|
|
202
|
+
* `route.sessionId`'s (nothing sent either), and with `timeout` when the
|
|
203
|
+
* proxy takes the work but never answers. The two
|
|
204
|
+
* are not the same news: `no_proxy` means nothing was sent, while a timeout
|
|
205
|
+
* means the call may well have done its work upstream — the controller tells
|
|
206
|
+
* the model which (controller.ts, `classifyWorkError`).
|
|
180
207
|
*/
|
|
181
208
|
call(serverName, toolName, args, timeoutMs, route = {}) {
|
|
182
209
|
if (this.closed)
|
|
@@ -186,19 +213,22 @@ export class ProxyWorkQueue {
|
|
|
186
213
|
// No proxy has ever polled for this server, or one has been gone for a
|
|
187
214
|
// minute. Do NOT queue: the caller is inside a turn the user is waiting on,
|
|
188
215
|
// and work that sits until some proxy happens to appear would stall it past
|
|
189
|
-
// every budget. Fail fast
|
|
216
|
+
// every budget. Fail fast: the plan stops here and says so.
|
|
190
217
|
return Promise.reject(new Error(NO_PROXY));
|
|
191
218
|
}
|
|
192
219
|
if (picked === "other_sessions") {
|
|
193
|
-
const
|
|
220
|
+
const live = this.present(serverName);
|
|
221
|
+
const unbound = live.some((p) => !p.sessionId);
|
|
194
222
|
logDetail("replay.no_session_proxy", {
|
|
195
223
|
server: serverName,
|
|
196
224
|
tool: toolName,
|
|
197
225
|
sess: route.sessionId,
|
|
198
|
-
others,
|
|
199
|
-
why:
|
|
226
|
+
others: live.length,
|
|
227
|
+
why: unbound
|
|
228
|
+
? "this session has no proxy of its own for the server yet, and another session is live — an unbound proxy may be theirs, so none is guessed"
|
|
229
|
+
: "only other sessions' proxies are here — the step is not run on their upstream",
|
|
200
230
|
});
|
|
201
|
-
return Promise.
|
|
231
|
+
return Promise.reject(noSessionProxy(serverName, live.length, unbound));
|
|
202
232
|
}
|
|
203
233
|
const { info, why } = picked;
|
|
204
234
|
if (route.sessionId && !info.sessionId && !isLegacy(info.proxyId)) {
|
|
@@ -262,12 +292,22 @@ export class ProxyWorkQueue {
|
|
|
262
292
|
}
|
|
263
293
|
}
|
|
264
294
|
this.waiters.clear();
|
|
295
|
+
// Work still held for a proxy that never came back for it was never sent:
|
|
296
|
+
// that, and only that, may be said to have not run. Work already handed to
|
|
297
|
+
// a proxy may be running on its upstream right now, and is said to be
|
|
298
|
+
// exactly that (controller.ts, `classifyWorkError`) — "nothing happened, do
|
|
299
|
+
// it yourself" about a write in flight is how it gets done twice.
|
|
300
|
+
const unsent = new Set();
|
|
301
|
+
for (const list of this.pending.values()) {
|
|
302
|
+
for (const work of list)
|
|
303
|
+
unsent.add(work.workId);
|
|
304
|
+
}
|
|
265
305
|
this.pending.clear();
|
|
266
306
|
// A closed queue has no proxies, whatever they were doing a moment ago.
|
|
267
307
|
this.proxies.clear();
|
|
268
|
-
for (const [, entry] of this.inFlight) {
|
|
308
|
+
for (const [workId, entry] of this.inFlight) {
|
|
269
309
|
clearTimeout(entry.timer);
|
|
270
|
-
entry.reject(new Error(
|
|
310
|
+
entry.reject(new Error(unsent.has(workId) ? QUEUE_CLOSED : QUEUE_CLOSED_IN_FLIGHT));
|
|
271
311
|
}
|
|
272
312
|
this.inFlight.clear();
|
|
273
313
|
}
|
|
@@ -300,8 +340,9 @@ export class ProxyWorkQueue {
|
|
|
300
340
|
}
|
|
301
341
|
/**
|
|
302
342
|
* 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
|
|
304
|
-
* one belongs to a different session
|
|
343
|
+
* Undefined when no proxy is live at all; `other_sessions` when none can be
|
|
344
|
+
* shown to be this session's: every live one belongs to a different session,
|
|
345
|
+
* or the unbound ones might, because another session is live too.
|
|
305
346
|
*/
|
|
306
347
|
pickProxy(serverName, sessionId) {
|
|
307
348
|
const live = this.present(serverName);
|
|
@@ -316,9 +357,40 @@ export class ProxyWorkQueue {
|
|
|
316
357
|
if (mine[0])
|
|
317
358
|
return { info: mine[0], why: "session" };
|
|
318
359
|
const unbound = live.find((p) => !p.sessionId);
|
|
319
|
-
if (unbound)
|
|
320
|
-
return
|
|
321
|
-
|
|
360
|
+
if (!unbound)
|
|
361
|
+
return "other_sessions";
|
|
362
|
+
// Nothing ties an unbound proxy to this session. With another session
|
|
363
|
+
// live, it may well be that session's — a browser on its page, a database
|
|
364
|
+
// session in its state — and a wrong guess returns a wrong answer here,
|
|
365
|
+
// where not running the step only costs the saving.
|
|
366
|
+
if (this.otherSessionLive(sessionId))
|
|
367
|
+
return "other_sessions";
|
|
368
|
+
return { info: unbound, why: "only" };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Whether a session other than `sessionId` is evidently live.
|
|
372
|
+
*
|
|
373
|
+
* Two signs, both from proxies that are polling right now: one is bound to
|
|
374
|
+
* another session, or one server has two proxies polling. Claude Code starts
|
|
375
|
+
* one proxy per server per session, so a second proxy for the same server is
|
|
376
|
+
* a second session, whether or not it has called anything yet. A pool of
|
|
377
|
+
* legacy proxies shares one record and says nothing either way.
|
|
378
|
+
*/
|
|
379
|
+
otherSessionLive(sessionId) {
|
|
380
|
+
const perServer = new Map();
|
|
381
|
+
for (const info of this.proxies.values()) {
|
|
382
|
+
if (!this.isLive(info))
|
|
383
|
+
continue;
|
|
384
|
+
if (info.sessionId && info.sessionId !== sessionId)
|
|
385
|
+
return true;
|
|
386
|
+
if (isLegacy(info.proxyId))
|
|
387
|
+
continue;
|
|
388
|
+
const n = (perServer.get(info.serverName) ?? 0) + 1;
|
|
389
|
+
if (n > 1)
|
|
390
|
+
return true;
|
|
391
|
+
perServer.set(info.serverName, n);
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
322
394
|
}
|
|
323
395
|
/**
|
|
324
396
|
* Free every proxy bound to `sessionId`: the session ended. After a `/clear`
|
|
@@ -406,21 +478,22 @@ function shortId(proxyId) {
|
|
|
406
478
|
return isLegacy(proxyId) ? proxyId : proxyId.slice(-8);
|
|
407
479
|
}
|
|
408
480
|
/**
|
|
409
|
-
* The
|
|
410
|
-
*
|
|
411
|
-
*
|
|
481
|
+
* The rejection when no proxy can be shown to be this session's. A rejection,
|
|
482
|
+
* not a tool-error result: a result is a call that ran, and the plan would
|
|
483
|
+
* count it as an executed step that failed — blaming the scenario for two
|
|
484
|
+
* sessions being open, and telling the model both that the step ran and that
|
|
485
|
+
* nothing did. As a rejection the controller records it as not run
|
|
486
|
+
* (controller.ts, `classifyWorkError`) and the plan hands the task over.
|
|
487
|
+
* `unsure` — some of them are unbound, and other sessions are live — words it
|
|
488
|
+
* as the doubt it is.
|
|
412
489
|
*/
|
|
413
|
-
function noSessionProxy(serverName, others) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
`runs on another session's upstream. Call the tool yourself.`,
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
};
|
|
490
|
+
function noSessionProxy(serverName, others, unsure = false) {
|
|
491
|
+
const why = unsure
|
|
492
|
+
? `no ${serverName} connection is known to be this session's yet, and other sessions ` +
|
|
493
|
+
`are open, so the ${others} connected may belong to them; a step never runs on ` +
|
|
494
|
+
`another session's upstream`
|
|
495
|
+
: `no ${serverName} proxy of this session is connected; the ${others} that are belong ` +
|
|
496
|
+
`to other sessions, and a step never runs on another session's upstream`;
|
|
497
|
+
return new NoSessionProxyError(why);
|
|
425
498
|
}
|
|
426
499
|
//# sourceMappingURL=executor.js.map
|
|
@@ -18,7 +18,34 @@ export type HandoverDelivery =
|
|
|
18
18
|
/** Steer mode: the steps that ran are the model's own tool calls, just above. */
|
|
19
19
|
"above"
|
|
20
20
|
/** Direct mode: the steps that ran are in the bundle that follows the note. */
|
|
21
|
-
| "below"
|
|
21
|
+
| "below"
|
|
22
|
+
/**
|
|
23
|
+
* Neither: the note travels alone — a run whose caller went away, or one
|
|
24
|
+
* whose tool answer is an error. The steps that ran are real, and the model
|
|
25
|
+
* is told so, but it has no results to read.
|
|
26
|
+
*/
|
|
27
|
+
| "none";
|
|
28
|
+
/**
|
|
29
|
+
* Why the stopped step did not do its work, when it is not simply "it failed".
|
|
30
|
+
*
|
|
31
|
+
* The wording matters more than it looks. A step that timed out may well have
|
|
32
|
+
* finished on the upstream — the proxy stops waiting, the database does not —
|
|
33
|
+
* and a model told only "failed" reasonably runs it again: a second INSERT, a
|
|
34
|
+
* second deploy. So each reason says what the model may and may not assume.
|
|
35
|
+
*/
|
|
36
|
+
export type HandoverStopReason =
|
|
37
|
+
/** Nothing was sent: no proxy for its server, a built-in, the allowlist. */
|
|
38
|
+
"not_run"
|
|
39
|
+
/** Sent and never answered (a timeout, a dropped connection). */
|
|
40
|
+
| "uncertain"
|
|
41
|
+
/** Sent, and the upstream answered with an error. */
|
|
42
|
+
| "failed"
|
|
43
|
+
/** The plan's time budget ran out before it. */
|
|
44
|
+
| "budget"
|
|
45
|
+
/** The run was cancelled before it. */
|
|
46
|
+
| "cancelled"
|
|
47
|
+
/** Another of the plan's calls was still in flight (steer mode runs one at a time). */
|
|
48
|
+
| "concurrent";
|
|
22
49
|
export interface HandoverNoteInput {
|
|
23
50
|
intent: string;
|
|
24
51
|
kind: FallbackKind;
|
|
@@ -29,8 +56,34 @@ export interface HandoverNoteInput {
|
|
|
29
56
|
failureCount?: number;
|
|
30
57
|
/** Why it stopped (step_failed). */
|
|
31
58
|
error?: string;
|
|
59
|
+
/** For `step_failed`: what kind of stop it was. Absent reads as a failure. */
|
|
60
|
+
stopReason?: HandoverStopReason;
|
|
32
61
|
/** Steps that ran under the scenario before the stop. */
|
|
33
62
|
executed: number;
|
|
63
|
+
/**
|
|
64
|
+
* Of {@link executed}, how many are the model's own tool calls above — the
|
|
65
|
+
* steps a steered session ran before a divergence took over. Only read when
|
|
66
|
+
* `delivery` is not `above` (where every step is), and only when it is
|
|
67
|
+
* fewer than `executed`; otherwise the delivery's own wording is right.
|
|
68
|
+
*/
|
|
69
|
+
executedAbove?: number;
|
|
70
|
+
/**
|
|
71
|
+
* A pinned step the host was asked to run and has not reported back on —
|
|
72
|
+
* neither counted in {@link executed} nor said to be undone. It may be
|
|
73
|
+
* running still, or it may never run at all: the user's own deny rule or
|
|
74
|
+
* PreToolUse hook can block a call the plan approved, and then no report
|
|
75
|
+
* ever comes. `stepIndex` is its position in the chain.
|
|
76
|
+
*/
|
|
77
|
+
inFlight?: {
|
|
78
|
+
stepIndex: number;
|
|
79
|
+
toolName?: string;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The stopped step's own tool ran and its result is real; what failed is the
|
|
83
|
+
* scenario reading that result (its output logic). It is counted in
|
|
84
|
+
* `executed` and absent from `remainingTools`, and must not be run again.
|
|
85
|
+
*/
|
|
86
|
+
ranThisStep?: boolean;
|
|
34
87
|
totalSteps: number;
|
|
35
88
|
/** Tool names of the planned steps from the stopped one to the end. */
|
|
36
89
|
remainingTools: string[];
|
package/dist/replay/handover.js
CHANGED
|
@@ -36,13 +36,35 @@ export function buildHandoverNote(n) {
|
|
|
36
36
|
if (intent)
|
|
37
37
|
lines.push(`Intent: ${intent}`);
|
|
38
38
|
if (n.executed > 0) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Part of the work may be the model's own calls above (a steered session
|
|
40
|
+
// that diverged), and the rest the bundle below or side effects only.
|
|
41
|
+
const above = Math.max(0, Math.min(n.executedAbove ?? 0, n.executed));
|
|
42
|
+
const delivery = above >= n.executed ? "above" : n.delivery;
|
|
43
|
+
const ran = `Steps 1–${n.executed} of ${n.totalSteps} ran under the scenario`;
|
|
44
|
+
lines.push(delivery === "above"
|
|
45
|
+
? `${ran}; their calls and results are the tool calls above. Do not repeat them.`
|
|
46
|
+
: above > 0
|
|
47
|
+
? delivery === "none"
|
|
48
|
+
? `${ran}: steps 1–${above} are the tool calls above, and the side effects of the rest are in place. Do not repeat them.`
|
|
49
|
+
: `${ran}: steps 1–${above} are the tool calls above, and the rest are below. Do not repeat them.`
|
|
50
|
+
: delivery === "none"
|
|
51
|
+
? `${ran}, and their side effects are in place. Do not repeat them.`
|
|
52
|
+
: `${ran}; their calls and results are below. Do not repeat them.`);
|
|
53
|
+
}
|
|
54
|
+
else if (n.stopReason === "uncertain" || n.inFlight) {
|
|
55
|
+
// The stopped step was sent: "no step ran" would contradict the line that
|
|
56
|
+
// says it may have completed.
|
|
57
|
+
lines.push("No step of the scenario is known to have finished; the task is yours.");
|
|
42
58
|
}
|
|
43
59
|
else {
|
|
44
60
|
lines.push("No step of the scenario ran; the whole task is yours.");
|
|
45
61
|
}
|
|
62
|
+
if (n.inFlight) {
|
|
63
|
+
// Never "ran", never "did not run": the host has not said which.
|
|
64
|
+
const inTool = n.inFlight.toolName ? ` (${n.inFlight.toolName})` : "";
|
|
65
|
+
lines.push(`Step ${n.inFlight.stepIndex + 1} of ${n.totalSteps}${inTool} was started and has not reported back: ` +
|
|
66
|
+
"it may not have run, or may still complete. Check whether its work happened before you repeat it.");
|
|
67
|
+
}
|
|
46
68
|
if (n.kind === "unusable_call") {
|
|
47
69
|
// A call is a stretch of work, not a tool: the agent is told what the
|
|
48
70
|
// sub-task was, not which tool did not run.
|
|
@@ -66,8 +88,7 @@ export function buildHandoverNote(n) {
|
|
|
66
88
|
lines.push(`Step ${human} of ${n.totalSteps}${tool} did not run: its input needs a judgement the scenario cannot compute. Make that choice yourself and carry on.`);
|
|
67
89
|
}
|
|
68
90
|
else {
|
|
69
|
-
|
|
70
|
-
lines.push(`Step ${human} of ${n.totalSteps}${tool} failed${error}`);
|
|
91
|
+
lines.push(stepFailedLine(n, human, tool));
|
|
71
92
|
}
|
|
72
93
|
if (n.remainingTools.length > 0) {
|
|
73
94
|
lines.push(`The remaining planned steps were: ${n.remainingTools.join(", ")}.`);
|
|
@@ -84,4 +105,33 @@ export function buildHandoverNote(n) {
|
|
|
84
105
|
}
|
|
85
106
|
return truncate(lines.join("\n"), MAX_HANDOVER_NOTE);
|
|
86
107
|
}
|
|
108
|
+
/** The line that names a stopped step and says what may be assumed about it. */
|
|
109
|
+
function stepFailedLine(n, human, tool) {
|
|
110
|
+
const at = `Step ${human} of ${n.totalSteps}${tool}`;
|
|
111
|
+
const detail = n.error ? truncate(n.error, MAX_ERROR_CHARS) : "";
|
|
112
|
+
if (n.ranThisStep) {
|
|
113
|
+
// Its tool ran and did its work; only the scenario's reading of the result
|
|
114
|
+
// broke. A model told "failed" here runs the write again.
|
|
115
|
+
return (`${at} ran — its result is real and its side effects are in place — but the scenario ` +
|
|
116
|
+
`could not read its output${detail ? ` (${detail})` : ""}, so it stops there. Do not run it again.`);
|
|
117
|
+
}
|
|
118
|
+
switch (n.stopReason) {
|
|
119
|
+
case "not_run":
|
|
120
|
+
return `${at} did not run${detail ? `: ${detail}` : ""}. Nothing of it happened; do it yourself.`;
|
|
121
|
+
case "uncertain":
|
|
122
|
+
// Never "failed": the upstream may have finished what the proxy stopped
|
|
123
|
+
// waiting for, and a model told "failed" runs a write a second time.
|
|
124
|
+
return (`${at} was sent but did not answer${detail ? ` (${detail})` : ""}. It may or may not ` +
|
|
125
|
+
"have completed: check whether its work happened before you run it again.");
|
|
126
|
+
case "budget":
|
|
127
|
+
return `${at} did not run: the scenario's time budget ran out before it.`;
|
|
128
|
+
case "cancelled":
|
|
129
|
+
return `${at} did not run: the scenario run was cancelled before it.`;
|
|
130
|
+
case "concurrent":
|
|
131
|
+
return (`${at} was not run by the scenario: another of its calls was still in flight, ` +
|
|
132
|
+
"and a scenario runs one call at a time.");
|
|
133
|
+
default:
|
|
134
|
+
return `${at} failed${detail ? `: ${detail}` : ""}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
87
137
|
//# sourceMappingURL=handover.js.map
|