@basein/runner 0.3.24 → 0.3.26
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 +27 -26
- package/dist/auth/client.d.ts +4 -1
- package/dist/auth/client.js +83 -14
- package/dist/bin/bir-hooks.js +4 -2
- package/dist/bin/bir.js +153 -37
- package/dist/bin/investigate.js +5 -0
- package/dist/bin/scenario-edit.d.ts +5 -0
- package/dist/bin/scenario-edit.js +17 -1
- package/dist/config/adapters/claude-code.d.ts +51 -7
- package/dist/config/adapters/claude-code.js +105 -18
- package/dist/config/generate.d.ts +61 -13
- package/dist/config/generate.js +108 -20
- package/dist/config/resolve.d.ts +5 -0
- package/dist/config/resolve.js +7 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +28 -0
- package/dist/control/daemon.js +62 -0
- package/dist/control/paths.d.ts +19 -1
- package/dist/control/paths.js +20 -2
- package/dist/control/pin-guard.js +8 -2
- package/dist/control/recorder-main.js +17 -5
- package/dist/control/server.d.ts +63 -9
- package/dist/control/server.js +350 -62
- package/dist/control/transcript.d.ts +64 -3
- package/dist/control/transcript.js +318 -37
- package/dist/jsonrpc/framing.d.ts +18 -3
- package/dist/jsonrpc/framing.js +57 -4
- package/dist/proxy/intercept.d.ts +4 -0
- package/dist/proxy/intercept.js +4 -0
- package/dist/proxy/relay.d.ts +13 -0
- package/dist/proxy/relay.js +12 -1
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +67 -10
- package/dist/record/recorder.d.ts +6 -1
- package/dist/record/redact.d.ts +18 -4
- package/dist/record/redact.js +538 -79
- package/dist/record/remote-recorder.d.ts +26 -5
- package/dist/record/remote-recorder.js +174 -18
- package/dist/record/truncate.js +3 -1
- package/dist/replay/bash-channel.d.ts +7 -1
- package/dist/replay/bash-channel.js +9 -3
- package/dist/replay/builtins.d.ts +14 -0
- package/dist/replay/builtins.js +460 -95
- package/dist/replay/bundle.d.ts +80 -0
- package/dist/replay/bundle.js +455 -24
- package/dist/replay/controller.d.ts +64 -1
- package/dist/replay/controller.js +238 -38
- package/dist/replay/derive.js +46 -4
- package/dist/replay/handover.d.ts +13 -0
- package/dist/replay/handover.js +18 -0
- package/dist/replay/plan.d.ts +15 -0
- package/dist/replay/plan.js +48 -30
- package/dist/replay/pricing.d.ts +61 -11
- package/dist/replay/pricing.js +135 -42
- package/dist/replay/safety.js +43 -5
- package/dist/upstream/http-client.d.ts +6 -3
- package/dist/upstream/http-client.js +54 -13
- package/dist/upstream/stdio-client.d.ts +7 -0
- package/dist/upstream/stdio-client.js +1 -1
- package/dist/util/atomic-write.d.ts +9 -1
- package/dist/util/atomic-write.js +9 -6
- package/dist/util/service-health.d.ts +7 -0
- package/dist/util/service-health.js +10 -3
- package/docs/calculatedReplay.md +32 -0
- package/package.json +1 -1
- package/production.md +2 -2
- package/scripts/install.ps1 +14 -2
package/dist/control/server.js
CHANGED
|
@@ -31,16 +31,16 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
|
|
|
31
31
|
import { StepIndexAllocator } from "./ordering.js";
|
|
32
32
|
import { pidAlive } from "./discovery.js";
|
|
33
33
|
import { pinCompatible } from "./pin-guard.js";
|
|
34
|
-
import { agentDayOf, contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
|
|
34
|
+
import { agentDayOf, answerBeforePrompt, contextForToolUse, intentForToolUse, markTranscriptUsage, promptCount, recentToolResults, sessionTranscriptPath, settledLastAssistantText, settledTextAfterToolUse, settledTurnEnd, thinkingForToolUse, usageBetween, usageSince, } from "./transcript.js";
|
|
35
35
|
import { isHousekeepingCall } from "../record/housekeeping.js";
|
|
36
36
|
import { asSent, userText } from "../record/prompt-text.js";
|
|
37
37
|
import { redact, redactText } from "../record/redact.js";
|
|
38
38
|
import { DEFAULT_MAX_STRING_BYTES, serializeCapped, truncateString, truncateStrings, } from "../record/truncate.js";
|
|
39
39
|
import { StepQueue } from "../record/queue.js";
|
|
40
40
|
import { NullRecorder, isAttentionReader, isIntentMatcher, isMatchAware, isRecorderHealthReporter, isRunCreationAware, isScenarioDisputer, isScenarioReporter, isServiceProber, isToolListReporter, } from "../record/recorder.js";
|
|
41
|
-
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, } from "../replay/controller.js";
|
|
41
|
+
import { ReplayController, POLL_HOLD_MS, REPORT_TOOL_NAME, sentACall, } from "../replay/controller.js";
|
|
42
42
|
import { localDay } from "../replay/derive.js";
|
|
43
|
-
import { calculateCostUsd, hasModelPricing } from "../replay/pricing.js";
|
|
43
|
+
import { calculateCostUsd, hasModelPricing, requestRates } from "../replay/pricing.js";
|
|
44
44
|
import { logDetail, logLine, logOnce, errText } from "../util/log.js";
|
|
45
45
|
import { journal } from "../util/journal.js";
|
|
46
46
|
import { packageVersion } from "../util/version.js";
|
|
@@ -106,6 +106,19 @@ const INTENT_ARM_PROBE_MS = 500;
|
|
|
106
106
|
const INTENT_ARM_DERIVE_MS = 1_500;
|
|
107
107
|
/** How long a fragment's `PreToolUse` waits to learn whether its run was created. */
|
|
108
108
|
const FRAGMENT_CREATE_MS = 300;
|
|
109
|
+
/**
|
|
110
|
+
* How long after its caller cancelled it a plan is not armed again (when some
|
|
111
|
+
* of its steps had run): long enough for the person to look at what the cancel
|
|
112
|
+
* left and ask again, short enough that a plan asked for tomorrow runs.
|
|
113
|
+
*/
|
|
114
|
+
const AFTER_CANCEL_MS = 10 * 60_000;
|
|
115
|
+
/**
|
|
116
|
+
* How long a turn's books wait, after `Stop`, for Claude Code to write the
|
|
117
|
+
* turn's last message — whose tokens are the turn's too (`sealRun`). The wait
|
|
118
|
+
* the answer's text always had. `BIR_COST_SETTLE_MS` changes it; 0 is no wait.
|
|
119
|
+
*/
|
|
120
|
+
const COST_SETTLE_MS = 2_000;
|
|
121
|
+
const COST_SETTLE_MAX_MS = 10_000;
|
|
109
122
|
/**
|
|
110
123
|
* How long the model's first call after a hand-over waits for the service to
|
|
111
124
|
* say whether the work recorded after an earlier hand-over at that step has a
|
|
@@ -619,6 +632,14 @@ function hostSinksOf(payload) {
|
|
|
619
632
|
* Claude's own names, the ones a transcript line carries, and they are mapped
|
|
620
633
|
* here as the transcript's are folded: a missing count is 0. Undefined when
|
|
621
634
|
* the payload carries no usable list — which is "no usage", never zero.
|
|
635
|
+
*
|
|
636
|
+
* A host sends one total per model, never one per request, and a model with a
|
|
637
|
+
* long-prompt price (Haiku 5.5: over 100,000 tokens, five times the rates) is
|
|
638
|
+
* priced by each request's own length. Under the threshold no request in the
|
|
639
|
+
* total was over it, and the short rates are right. Over it, which ones were
|
|
640
|
+
* cannot be told: the total is `rateUnknown`, and the run unmeasured — priced
|
|
641
|
+
* at the short rates, 150,000 tokens of one request came to a fifth of their
|
|
642
|
+
* cost, booked as measured.
|
|
622
643
|
*/
|
|
623
644
|
function hostUsageOf(payload) {
|
|
624
645
|
if (!Array.isArray(payload.usage))
|
|
@@ -629,12 +650,25 @@ function hostUsageOf(payload) {
|
|
|
629
650
|
if (!item || typeof item !== "object")
|
|
630
651
|
continue;
|
|
631
652
|
const u = item;
|
|
653
|
+
const written = count(u.cache_creation_input_tokens);
|
|
654
|
+
// Claude's own split of the cache writes, when the host passes it on: a
|
|
655
|
+
// 1-hour write bills at 2x input, a 5-minute one at 1.25x.
|
|
656
|
+
const split = u.cache_creation;
|
|
657
|
+
const written1h = Math.min(written, count(split?.ephemeral_1h_input_tokens));
|
|
658
|
+
const model = typeof u.model === "string" && u.model ? u.model.slice(0, 200) : "unknown";
|
|
659
|
+
const inputTokens = count(u.input_tokens);
|
|
660
|
+
const cacheReadTokens = count(u.cache_read_input_tokens);
|
|
661
|
+
// The whole total as if it were one request's prompt: over the model's
|
|
662
|
+
// long-prompt threshold, one of its requests may have been.
|
|
663
|
+
const longMaybe = requestRates(model, { promptTokens: inputTokens + cacheReadTokens + written }).longPrompt === true;
|
|
632
664
|
out.push({
|
|
633
|
-
model
|
|
634
|
-
inputTokens
|
|
665
|
+
model,
|
|
666
|
+
inputTokens,
|
|
635
667
|
outputTokens: count(u.output_tokens),
|
|
636
|
-
cacheReadTokens
|
|
637
|
-
cacheCreationTokens:
|
|
668
|
+
cacheReadTokens,
|
|
669
|
+
cacheCreationTokens: written,
|
|
670
|
+
...(written1h > 0 ? { cacheCreation1hTokens: written1h } : {}),
|
|
671
|
+
...(longMaybe ? { rateUnknown: true } : {}),
|
|
638
672
|
});
|
|
639
673
|
}
|
|
640
674
|
return out;
|
|
@@ -642,12 +676,19 @@ function hostUsageOf(payload) {
|
|
|
642
676
|
/**
|
|
643
677
|
* Price a turn's token usage. Measured only when tokens were used and every
|
|
644
678
|
* model that used them has a price: a model outside the table costs "$0",
|
|
645
|
-
* and a zero called measured is booked as a saving nobody made.
|
|
679
|
+
* and a zero called measured is booked as a saving nobody made. Nor is a
|
|
680
|
+
* total whose rates cannot be told ({@link HostUsage.rateUnknown}).
|
|
646
681
|
*/
|
|
647
682
|
function priceUsage(usage) {
|
|
648
683
|
const usd = usage.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
649
684
|
const unpriced = [...new Set(usage.filter((u) => !hasModelPricing(u.model)).map((u) => u.model))];
|
|
650
|
-
|
|
685
|
+
const rateUnknown = [...new Set(usage.filter((u) => u.rateUnknown).map((u) => u.model))];
|
|
686
|
+
return {
|
|
687
|
+
usd,
|
|
688
|
+
measured: usage.length > 0 && unpriced.length === 0 && rateUnknown.length === 0,
|
|
689
|
+
unpriced,
|
|
690
|
+
...(rateUnknown.length > 0 ? { rateUnknown } : {}),
|
|
691
|
+
};
|
|
651
692
|
}
|
|
652
693
|
/**
|
|
653
694
|
* What `/health` answers as `hostFields`: how much of another host's protocol
|
|
@@ -698,6 +739,15 @@ export class ControlServer {
|
|
|
698
739
|
attention = new Map();
|
|
699
740
|
/** Plans a `/scenario/run` is executing right now: one run per plan. */
|
|
700
741
|
runningPlans = new Set();
|
|
742
|
+
/**
|
|
743
|
+
* Plans whose direct run its caller cancelled, by scenario id, and when
|
|
744
|
+
* ({@link declineAfterCancel}): the same plan is not armed again for
|
|
745
|
+
* {@link AFTER_CANCEL_MS} when the run sent a call, so its steps do not run
|
|
746
|
+
* twice. Here, not on the session: after `/clear` — a new session — or in a
|
|
747
|
+
* second window the same request armed the plan again, and ran every step
|
|
748
|
+
* a second time.
|
|
749
|
+
*/
|
|
750
|
+
cancelledScenarios = new Map();
|
|
701
751
|
/**
|
|
702
752
|
* Steer plans whose last pin the guard declined (control/pin-guard.ts): the
|
|
703
753
|
* model made the step's call its own way, so the plan is a step behind the
|
|
@@ -1565,7 +1615,9 @@ export class ControlServer {
|
|
|
1565
1615
|
const stashed = this.claimStashedNote(payload.session_id);
|
|
1566
1616
|
// The guard answering first means no directive went out: a plan that
|
|
1567
1617
|
// arms after that must never steer a model that was not told of it.
|
|
1618
|
+
let answeredLate = false;
|
|
1568
1619
|
onLate(() => {
|
|
1620
|
+
answeredLate = true;
|
|
1569
1621
|
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
1570
1622
|
if (run) {
|
|
1571
1623
|
run.matchExpired = true;
|
|
@@ -1574,7 +1626,11 @@ export class ControlServer {
|
|
|
1574
1626
|
return stashed ? this.withContext({}, "UserPromptSubmit", stashed) : {};
|
|
1575
1627
|
});
|
|
1576
1628
|
const out = await this.onPrompt(payload);
|
|
1577
|
-
|
|
1629
|
+
// Or the note this prompt's own match brought here: the plan was held
|
|
1630
|
+
// back after a cancel in another session (`declineAfterCancel`). Not
|
|
1631
|
+
// once the deadline has answered — it then waits for the next hook.
|
|
1632
|
+
const note = stashed ?? (answeredLate ? undefined : this.claimStashedNote(payload.session_id));
|
|
1633
|
+
return note ? this.withContext(out, "UserPromptSubmit", note) : out;
|
|
1578
1634
|
}
|
|
1579
1635
|
case "/tool/pre":
|
|
1580
1636
|
return await this.onToolPre(payload, onLate);
|
|
@@ -1635,7 +1691,22 @@ export class ControlServer {
|
|
|
1635
1691
|
send(res, status, body) {
|
|
1636
1692
|
if (res.headersSent || res.writableEnded)
|
|
1637
1693
|
return;
|
|
1638
|
-
|
|
1694
|
+
// An answer that cannot be written as JSON (a BigInt or a cycle in a tool's
|
|
1695
|
+
// arguments that came back as `updatedInput`) threw here, past the route's
|
|
1696
|
+
// own handler, and the host's hook request was left with no answer at all
|
|
1697
|
+
// until its own timeout, 15 s later. A hook that answers nothing lets the
|
|
1698
|
+
// call go ahead as the model wrote it: said, and answered so at once.
|
|
1699
|
+
let text;
|
|
1700
|
+
try {
|
|
1701
|
+
text = JSON.stringify(body ?? {});
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
logLine("hook.unserialisable_answer", {
|
|
1705
|
+
error: errText(err),
|
|
1706
|
+
why: "the answer could not be written as JSON — answered with none, so the call goes ahead as written",
|
|
1707
|
+
});
|
|
1708
|
+
text = "{}";
|
|
1709
|
+
}
|
|
1639
1710
|
res.writeHead(status, {
|
|
1640
1711
|
"content-type": "application/json",
|
|
1641
1712
|
"content-length": Buffer.byteLength(text),
|
|
@@ -1699,8 +1770,10 @@ export class ControlServer {
|
|
|
1699
1770
|
this.sessions.set(sessionId, session);
|
|
1700
1771
|
}
|
|
1701
1772
|
session.lastSeenAt = Date.now();
|
|
1773
|
+
// Always the session's own transcript: a hook of a sub-agent that named
|
|
1774
|
+
// the sub-agent's file would have the turn's marks read from two files.
|
|
1702
1775
|
if (payload.transcript_path)
|
|
1703
|
-
session.transcriptPath = payload.transcript_path;
|
|
1776
|
+
session.transcriptPath = sessionTranscriptPath(payload.transcript_path);
|
|
1704
1777
|
if (payload.cwd)
|
|
1705
1778
|
session.cwd = payload.cwd;
|
|
1706
1779
|
// The first host a session names is the one it keeps. Any route may carry
|
|
@@ -1880,6 +1953,10 @@ export class ControlServer {
|
|
|
1880
1953
|
// every run of another host would then only record.
|
|
1881
1954
|
hostReplay: asked.replay === true ? true : undefined,
|
|
1882
1955
|
};
|
|
1956
|
+
// The turn before this one ends where this one starts (`RunState.endMark`).
|
|
1957
|
+
const before = session.run ?? session.previous;
|
|
1958
|
+
if (before && before.finished && before.endMark === undefined)
|
|
1959
|
+
before.endMark = run.usageMark;
|
|
1883
1960
|
session.run = run;
|
|
1884
1961
|
logLine("run.start", {
|
|
1885
1962
|
run: runId,
|
|
@@ -2113,6 +2190,7 @@ export class ControlServer {
|
|
|
2113
2190
|
if (!otherHost(session))
|
|
2114
2191
|
state.reportTool = () => this.reportToolServed();
|
|
2115
2192
|
this.declineWithoutDeliveryTool(state);
|
|
2193
|
+
this.declineAfterCancel(session, state);
|
|
2116
2194
|
// A steer plan with a shell or file-write step can only be followed if the
|
|
2117
2195
|
// directive shows its computed arguments (the pin guard holds those steps
|
|
2118
2196
|
// to their exact shape), so give its parameters a moment to settle —
|
|
@@ -2259,6 +2337,74 @@ export class ControlServer {
|
|
|
2259
2337
|
run.replays.push(state);
|
|
2260
2338
|
return undefined;
|
|
2261
2339
|
}
|
|
2340
|
+
/**
|
|
2341
|
+
* A plan whose caller cancelled it (Esc on `run_scenario`, or a timeout) a
|
|
2342
|
+
* few minutes ago, after at least one of its steps had run, is not armed
|
|
2343
|
+
* again when the same plan matches: the steps' side effects are in place
|
|
2344
|
+
* already, and the model is told so by the note kept for it
|
|
2345
|
+
* ({@link stashCancelledNote}). Armed, the plan would have run them a second
|
|
2346
|
+
* time — a row added twice, a mail sent twice — and the model would have had
|
|
2347
|
+
* the note and the instruction to run the plan in the same message.
|
|
2348
|
+
*
|
|
2349
|
+
* The mark is spent by the first match it is asked about, whichever way that
|
|
2350
|
+
* goes: it holds back one arming, never a stream of them, and never on a
|
|
2351
|
+
* plan that sent no call (there is nothing to repeat: a step left out, one
|
|
2352
|
+
* whose logic broke, one that could not be sent), nor one that is long
|
|
2353
|
+
* cancelled ({@link AFTER_CANCEL_MS}). A person who wants the plan again asks
|
|
2354
|
+
* again. Except while the cancelled run is still going — Esc does not stop a
|
|
2355
|
+
* step in flight, and the run ends after it: every match until then is held,
|
|
2356
|
+
* and the first one after spends the mark.
|
|
2357
|
+
*
|
|
2358
|
+
* The mark is the recorder's, not the session's: after `/clear` or in a
|
|
2359
|
+
* second window the same request arrives in another session. The note the
|
|
2360
|
+
* cancelled run left goes with the decline, to the session it is declined
|
|
2361
|
+
* in, when that note is still unread.
|
|
2362
|
+
*
|
|
2363
|
+
* Returns true when it declined.
|
|
2364
|
+
*/
|
|
2365
|
+
declineAfterCancel(session, state) {
|
|
2366
|
+
const id = state.scenarioId;
|
|
2367
|
+
if (!state.plan || typeof id !== "string")
|
|
2368
|
+
return false;
|
|
2369
|
+
const mark = this.cancelledScenarios.get(id);
|
|
2370
|
+
if (!mark)
|
|
2371
|
+
return false;
|
|
2372
|
+
const running = this.runningPlans.has(mark.state);
|
|
2373
|
+
if (!running)
|
|
2374
|
+
this.cancelledScenarios.delete(id);
|
|
2375
|
+
if (Date.now() - mark.at > AFTER_CANCEL_MS && !running)
|
|
2376
|
+
return false;
|
|
2377
|
+
if (!running && !sentACall(mark.state))
|
|
2378
|
+
return false;
|
|
2379
|
+
logLine("replay.decision", {
|
|
2380
|
+
verdict: "no-steer",
|
|
2381
|
+
code: "after_cancel",
|
|
2382
|
+
scenario: id,
|
|
2383
|
+
sess: session.sessionId,
|
|
2384
|
+
why: running
|
|
2385
|
+
? "the same plan was cancelled a moment ago and its run is still finishing the step it was on — not armed again, so that step does not run twice; the agent does the task"
|
|
2386
|
+
: "the same plan was cancelled a moment ago after some of its steps had run — not armed again, so those steps do not run twice; the agent does the task, told what ran",
|
|
2387
|
+
});
|
|
2388
|
+
// Its note, for the model that is about to do the task by hand.
|
|
2389
|
+
if (!session.stashedNote && this.replay.hasUndeliveredNote(mark.state))
|
|
2390
|
+
session.stashedNote = mark.state;
|
|
2391
|
+
state.plan = undefined;
|
|
2392
|
+
state.mode = "none";
|
|
2393
|
+
state.stepsPlanned = 0;
|
|
2394
|
+
state.pinned.clear();
|
|
2395
|
+
state.declined = "after_cancel";
|
|
2396
|
+
return true;
|
|
2397
|
+
}
|
|
2398
|
+
/** Mark `state`'s scenario held after its caller cancelled it ({@link declineAfterCancel}). */
|
|
2399
|
+
holdAfterCancel(scenarioId, state) {
|
|
2400
|
+
const now = Date.now();
|
|
2401
|
+
// Marks long past their time, and not running, are dropped as new ones come.
|
|
2402
|
+
for (const [id, mark] of this.cancelledScenarios) {
|
|
2403
|
+
if (now - mark.at > AFTER_CANCEL_MS && !this.runningPlans.has(mark.state))
|
|
2404
|
+
this.cancelledScenarios.delete(id);
|
|
2405
|
+
}
|
|
2406
|
+
this.cancelledScenarios.set(scenarioId, { state, at: now });
|
|
2407
|
+
}
|
|
2262
2408
|
/**
|
|
2263
2409
|
* A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
|
|
2264
2410
|
* with no `bir` server that is a tool the model does not have: it would say
|
|
@@ -2797,7 +2943,7 @@ export class ControlServer {
|
|
|
2797
2943
|
* `reason` says why the run is closed. It decides something only in a
|
|
2798
2944
|
* session of another host (see {@link CloseReason}).
|
|
2799
2945
|
*/
|
|
2800
|
-
sealRun(session, reason = "stop") {
|
|
2946
|
+
sealRun(session, reason = "stop", settle = false) {
|
|
2801
2947
|
const run = session.run;
|
|
2802
2948
|
if (!run || run.finished)
|
|
2803
2949
|
return;
|
|
@@ -2811,7 +2957,16 @@ export class ControlServer {
|
|
|
2811
2957
|
// handed over first, before the report is built, so the report says what
|
|
2812
2958
|
// happened: `fell_back` when a step ran, `failed` when none did, and not
|
|
2813
2959
|
// `not_steered`, as if no plan had started.
|
|
2814
|
-
|
|
2960
|
+
//
|
|
2961
|
+
// A turn of a Claude Code session closed by anything but its own `Stop`
|
|
2962
|
+
// ended before its work did too: an API error (`StopFailure`), the
|
|
2963
|
+
// person's next prompt on a turn still open (Esc), a session that went
|
|
2964
|
+
// quiet, ended or was switched off, a recorder that stopped. What it cost
|
|
2965
|
+
// is no measure of the task. Its plan's report is unmeasured, and the turn
|
|
2966
|
+
// is no sample of the usual cost: one $0.01 beside a $2.00 recording made
|
|
2967
|
+
// the usual cost about $1.00, and every later saving about $1.00 too small
|
|
2968
|
+
// (the service's imp.md B2). The recording itself is left as it was.
|
|
2969
|
+
let cutShort = early || (!other && reason !== "stop");
|
|
2815
2970
|
if (other) {
|
|
2816
2971
|
const live = this.livePlan(run);
|
|
2817
2972
|
if (live && this.replay.hostHandOver(live, "closed"))
|
|
@@ -2832,15 +2987,91 @@ export class ControlServer {
|
|
|
2832
2987
|
this.shelveUnannounced(unannounced);
|
|
2833
2988
|
// A `run_scenario` still waiting for this run's plan will never get it.
|
|
2834
2989
|
this.directCalls = this.directCalls.filter((c) => c.run !== run);
|
|
2835
|
-
|
|
2990
|
+
// The turn ends now, whenever its books are closed below.
|
|
2991
|
+
const sealedAtMs = Date.now();
|
|
2992
|
+
const durationMs = sealedAtMs - run.startedAtMs;
|
|
2993
|
+
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
2994
|
+
// and duration are the model's own work, not the steps the scenario did.
|
|
2995
|
+
// So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
|
|
2996
|
+
// hands over one number for the whole run, and it is the fragment's: the
|
|
2997
|
+
// plan's own steps made no model call, so all of it was spent after the
|
|
2998
|
+
// hand-over.
|
|
2999
|
+
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
3000
|
+
const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
|
|
3001
|
+
const ownWork = fragment ?? repeatAfterPlan;
|
|
3002
|
+
const recordedMs = ownWork?.startedAtMs ? sealedAtMs - ownWork.startedAtMs : durationMs;
|
|
3003
|
+
// How many prompts the transcript holds now: the turn's answer is what
|
|
3004
|
+
// stood before the next one (`turnAnswer`).
|
|
3005
|
+
const promptsAtSeal = other ? undefined : promptCount(session.transcriptPath);
|
|
3006
|
+
const closeBooks = () => this.closeBooks(session, run, {
|
|
3007
|
+
reason,
|
|
3008
|
+
early,
|
|
3009
|
+
cutShort,
|
|
3010
|
+
durationMs,
|
|
3011
|
+
recordedMs,
|
|
3012
|
+
sealedAtMs,
|
|
3013
|
+
ownWork,
|
|
3014
|
+
promptsAtSeal,
|
|
3015
|
+
});
|
|
3016
|
+
// THE COST IS READ ONCE THE TURN'S LAST MESSAGE IS WRITTEN. `Stop` comes
|
|
3017
|
+
// before Claude Code writes it, and it carries tokens of its own: read at
|
|
3018
|
+
// `Stop`, every turn was one model call short, and on a replay that call
|
|
3019
|
+
// is most of the turn (control/transcript.ts, `settledTurnEnd`). Only an
|
|
3020
|
+
// ordinary stop waits — an interrupted turn, a session that ended and a
|
|
3021
|
+
// recorder closing have no last message coming — and never on the hook's
|
|
3022
|
+
// path: the seal is done, and its drain waits for the books.
|
|
3023
|
+
const settleMs = settle && !other && run.usageMark !== undefined ? this.costSettleMs() : 0;
|
|
3024
|
+
if (settleMs > 0 && session.transcriptPath) {
|
|
3025
|
+
this.holdSeal(run, settledTurnEnd(session.transcriptPath, { timeoutMs: settleMs })
|
|
3026
|
+
.catch(() => false)
|
|
3027
|
+
.then(() => {
|
|
3028
|
+
try {
|
|
3029
|
+
closeBooks();
|
|
3030
|
+
}
|
|
3031
|
+
catch (err) {
|
|
3032
|
+
logLine("run.books_failed", { run: run.runId, error: errText(err) });
|
|
3033
|
+
}
|
|
3034
|
+
}));
|
|
3035
|
+
}
|
|
3036
|
+
else {
|
|
3037
|
+
closeBooks();
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
/** How long a turn's books wait for its last message: `BIR_COST_SETTLE_MS`, 0 for no wait. */
|
|
3041
|
+
costSettleMs() {
|
|
3042
|
+
if (this.opts.costSettleMs !== undefined)
|
|
3043
|
+
return Math.max(0, this.opts.costSettleMs);
|
|
3044
|
+
const raw = process.env.BIR_COST_SETTLE_MS;
|
|
3045
|
+
const n = raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw);
|
|
3046
|
+
return Number.isFinite(n) && n >= 0 ? Math.min(n, COST_SETTLE_MAX_MS) : COST_SETTLE_MS;
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* `work` joins what a drain of `run` waits for ({@link drainRun}). Never
|
|
3050
|
+
* rejects: a seal's waits are best effort, and say so in the log themselves.
|
|
3051
|
+
*/
|
|
3052
|
+
holdSeal(run, work) {
|
|
3053
|
+
const done = work.then(() => undefined, () => undefined);
|
|
3054
|
+
run.sealing = run.sealing ? Promise.all([run.sealing, done]).then(() => undefined) : done;
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* The second half of {@link sealRun}: what the turn cost, its execution
|
|
3058
|
+
* reports, and the finish of its recording. Run at the seal, or a moment
|
|
3059
|
+
* after it, once the turn's last message is in the transcript.
|
|
3060
|
+
*/
|
|
3061
|
+
closeBooks(session, run, sealed) {
|
|
3062
|
+
const { reason, early, cutShort, durationMs, recordedMs, sealedAtMs, ownWork, promptsAtSeal } = sealed;
|
|
3063
|
+
const other = otherHost(session);
|
|
3064
|
+
// A prompt that came while the books waited opened the session's next run:
|
|
3065
|
+
// this turn's tokens end at that run's mark, and what follows is its own.
|
|
3066
|
+
const until = run.endMark;
|
|
2836
3067
|
// What this turn cost the model. Computed once, used twice: it is this run's
|
|
2837
3068
|
// `originalCostUsd` when recorded, and the `sessionCostUsd` of a matched
|
|
2838
3069
|
// turn's execution report.
|
|
2839
|
-
const cost = this.runCost(session, run);
|
|
3070
|
+
const cost = this.runCost(session, run, until);
|
|
2840
3071
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
2841
3072
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
2842
3073
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
2843
|
-
this.reportExecution(session, run, durationMs, cost, cutShort, reason);
|
|
3074
|
+
this.reportExecution(session, run, durationMs, cost, cutShort, reason, { sealedAtMs, until });
|
|
2844
3075
|
// The plan's calls kept for a redo went into the repeat when it opened
|
|
2845
3076
|
// (AC2); nothing reads them after the turn, and they can run to megabytes
|
|
2846
3077
|
// on a sealed run the session keeps as its previous one.
|
|
@@ -2848,27 +3079,22 @@ export class ControlServer {
|
|
|
2848
3079
|
s.turnCalls = undefined;
|
|
2849
3080
|
s.turnCallChars = 0;
|
|
2850
3081
|
}
|
|
2851
|
-
|
|
2852
|
-
// and duration are the model's own work, not the steps the scenario did.
|
|
2853
|
-
// So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
|
|
2854
|
-
// hands over one number for the whole run, and it is the fragment's: the
|
|
2855
|
-
// plan's own steps made no model call, so all of it was spent after the
|
|
2856
|
-
// hand-over.
|
|
2857
|
-
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
2858
|
-
const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
|
|
2859
|
-
const ownWork = fragment ?? repeatAfterPlan;
|
|
2860
|
-
const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark) : cost;
|
|
2861
|
-
const recordedMs = ownWork?.startedAtMs ? Date.now() - ownWork.startedAtMs : durationMs;
|
|
3082
|
+
const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark, until) : cost;
|
|
2862
3083
|
if (run.recording) {
|
|
2863
3084
|
const transcriptPath = session.transcriptPath;
|
|
2864
3085
|
// The answer is the host's when it handed one over, and the
|
|
2865
3086
|
// transcript's otherwise — never both. A run that ended early has none:
|
|
2866
3087
|
// whatever text there is, it is not the answer to the prompt.
|
|
3088
|
+
// A prompt written since the seal opened the next turn: the answer is
|
|
3089
|
+
// what stood before it, never what that turn has written meanwhile.
|
|
3090
|
+
const beforeNext = promptsAtSeal === undefined ? undefined : answerBeforePrompt(transcriptPath, promptsAtSeal + 1);
|
|
2867
3091
|
const answerText = early
|
|
2868
3092
|
? Promise.resolve("")
|
|
2869
3093
|
: run.hostAnswer !== undefined
|
|
2870
3094
|
? Promise.resolve(run.hostAnswer)
|
|
2871
|
-
:
|
|
3095
|
+
: beforeNext !== undefined
|
|
3096
|
+
? Promise.resolve(beforeNext)
|
|
3097
|
+
: settledLastAssistantText(transcriptPath).catch(() => "");
|
|
2872
3098
|
// What the finish of another host's run says beside its answer and its
|
|
2873
3099
|
// cost: why it ended early, when it did, and whether it is whole. A
|
|
2874
3100
|
// run with either is never calculated and stops catching its prompt.
|
|
@@ -2883,7 +3109,6 @@ export class ControlServer {
|
|
|
2883
3109
|
// first step of the user's next turn by that much. Kept on the run, so a
|
|
2884
3110
|
// drain of this run waits for this seal and for nobody else's — beside a
|
|
2885
3111
|
// plan's report that waits for the answer too (AC11), never in its place.
|
|
2886
|
-
const before = run.sealing;
|
|
2887
3112
|
const finishing = answerText
|
|
2888
3113
|
.then((answer) => {
|
|
2889
3114
|
run.finishQueued = true;
|
|
@@ -2913,7 +3138,7 @@ export class ControlServer {
|
|
|
2913
3138
|
});
|
|
2914
3139
|
});
|
|
2915
3140
|
});
|
|
2916
|
-
|
|
3141
|
+
this.holdSeal(run, finishing);
|
|
2917
3142
|
}
|
|
2918
3143
|
logLine("run.finish", {
|
|
2919
3144
|
run: run.runId,
|
|
@@ -2949,7 +3174,7 @@ export class ControlServer {
|
|
|
2949
3174
|
* No watermark means the delta is unknowable, so this reports `measured:
|
|
2950
3175
|
* false` and zero rather than a number that is confidently wrong.
|
|
2951
3176
|
*/
|
|
2952
|
-
runCost(session, run) {
|
|
3177
|
+
runCost(session, run, until) {
|
|
2953
3178
|
// Another host has no transcript: its numbers are the ones it handed
|
|
2954
3179
|
// over with its stop, and they are looked at before any watermark — a
|
|
2955
3180
|
// run of such a host never has one.
|
|
@@ -2963,7 +3188,7 @@ export class ControlServer {
|
|
|
2963
3188
|
});
|
|
2964
3189
|
return { usd: 0, measured: false };
|
|
2965
3190
|
}
|
|
2966
|
-
const cost = this.costSince(session, run.usageMark);
|
|
3191
|
+
const cost = this.costSince(session, run.usageMark, until);
|
|
2967
3192
|
if (cost.unpriced?.length) {
|
|
2968
3193
|
logLine("run.unmeasured", {
|
|
2969
3194
|
run: run.runId,
|
|
@@ -2993,22 +3218,37 @@ export class ControlServer {
|
|
|
2993
3218
|
run: run.runId,
|
|
2994
3219
|
why: cost.unpriced?.length
|
|
2995
3220
|
? "the runner has no price for a model of this run"
|
|
2996
|
-
:
|
|
2997
|
-
|
|
3221
|
+
: cost.rateUnknown?.length
|
|
3222
|
+
? "the host sent one total for a model that prices a long prompt at other rates, and the total is over that length — which of its requests were long cannot be told"
|
|
3223
|
+
: "the host's usage holds no tokens — the run made no model call",
|
|
3224
|
+
models: cost.unpriced?.length
|
|
3225
|
+
? cost.unpriced.join(",")
|
|
3226
|
+
: cost.rateUnknown?.length
|
|
3227
|
+
? cost.rateUnknown.join(",")
|
|
3228
|
+
: undefined,
|
|
2998
3229
|
});
|
|
2999
3230
|
return { ...cost, measured: false };
|
|
3000
3231
|
}
|
|
3001
|
-
/**
|
|
3002
|
-
|
|
3232
|
+
/**
|
|
3233
|
+
* Transcript cost since `mark`; unmeasured (and zero) without one, or with a
|
|
3234
|
+
* model that has no price. Up to `until` — the mark of the session's next
|
|
3235
|
+
* run — when the turn's books are closed after that run has opened, and up
|
|
3236
|
+
* to now otherwise.
|
|
3237
|
+
*/
|
|
3238
|
+
costSince(session, mark, until) {
|
|
3003
3239
|
if (mark === undefined)
|
|
3004
3240
|
return { usd: 0, measured: false };
|
|
3005
|
-
return priceUsage(usageSince(session.transcriptPath, mark));
|
|
3241
|
+
return priceUsage(until ? usageBetween(mark, until) : usageSince(session.transcriptPath, mark));
|
|
3006
3242
|
}
|
|
3007
3243
|
reportExecution(session, run, durationMs, cost,
|
|
3008
3244
|
/** The run was closed before its task ended for the caller (another host only). */
|
|
3009
3245
|
cutShort = false,
|
|
3010
3246
|
/** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
|
|
3011
|
-
sealedBy = "stop"
|
|
3247
|
+
sealedBy = "stop",
|
|
3248
|
+
/** When the turn was sealed, and where its tokens end ({@link closeBooks}). */
|
|
3249
|
+
at = {}) {
|
|
3250
|
+
const sealedAtMs = at.sealedAtMs ?? Date.now();
|
|
3251
|
+
const until = at.until;
|
|
3012
3252
|
if (!isScenarioReporter(this.recorder))
|
|
3013
3253
|
return;
|
|
3014
3254
|
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
@@ -3071,27 +3311,34 @@ export class ControlServer {
|
|
|
3071
3311
|
// did afterwards, and its saving would read as a loss.
|
|
3072
3312
|
let end = nextMark;
|
|
3073
3313
|
if (state.armedBy === "intent") {
|
|
3074
|
-
windowCost = this.costSince(session, state.usageMark);
|
|
3075
|
-
windowMs = (state.retiredAt ?? next?.armedAt ??
|
|
3314
|
+
windowCost = this.costSince(session, state.usageMark, until);
|
|
3315
|
+
windowMs = (state.retiredAt ?? next?.armedAt ?? sealedAtMs) - state.armedAt;
|
|
3076
3316
|
end = state.retiredMark ?? nextMark;
|
|
3077
3317
|
}
|
|
3078
3318
|
else if (next) {
|
|
3079
3319
|
windowMs = next.armedAt - run.startedAtMs;
|
|
3080
3320
|
}
|
|
3081
3321
|
if (end !== undefined) {
|
|
3082
|
-
const later = this.costSince(session, end);
|
|
3322
|
+
const later = this.costSince(session, end, until);
|
|
3083
3323
|
windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
|
|
3084
3324
|
}
|
|
3085
3325
|
const report = this.replay.buildReport(state, {
|
|
3086
3326
|
sessionCostUsd: windowCost.usd,
|
|
3087
3327
|
// A direct run the user cancelled cut its turn short: its cost is no
|
|
3088
|
-
// baseline, and no saving is measured against it.
|
|
3089
|
-
|
|
3328
|
+
// baseline, and no saving is measured against it. The parent's report
|
|
3329
|
+
// bills the whole turn, its continuations' part included, so a
|
|
3330
|
+
// continuation the user cancelled makes the parent's unmeasured too.
|
|
3331
|
+
measured: windowCost.measured &&
|
|
3332
|
+
!this.cancelledPlans.has(state) &&
|
|
3333
|
+
!chain.some((continuation) => this.cancelledPlans.has(continuation)) &&
|
|
3334
|
+
!unsplit,
|
|
3090
3335
|
durationMs: Math.max(0, windowMs),
|
|
3091
3336
|
prompt: run.input || undefined,
|
|
3092
3337
|
siblings: states,
|
|
3093
3338
|
runId: run.runId,
|
|
3094
|
-
|
|
3339
|
+
// Not for a plan whose own window had closed before the turn was cut
|
|
3340
|
+
// short (an intent plan that ran to its end): what it cost is known.
|
|
3341
|
+
cutShort: (cutShort && end === undefined) || undefined,
|
|
3095
3342
|
redoneBy: state === prompt && state.ranToEnd === true ? redoneBy : undefined,
|
|
3096
3343
|
chainDeriveUsd,
|
|
3097
3344
|
booksOwnSaving: booksOwn(state),
|
|
@@ -3131,7 +3378,7 @@ export class ControlServer {
|
|
|
3131
3378
|
const calls = answer ? this.answerCallsOf(state.scenarioId, kept) : [];
|
|
3132
3379
|
this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer, ...(calls.length > 0 ? { answerCalls: calls } : {}) } : report));
|
|
3133
3380
|
});
|
|
3134
|
-
|
|
3381
|
+
this.holdSeal(run, sent);
|
|
3135
3382
|
return;
|
|
3136
3383
|
}
|
|
3137
3384
|
this.queue.push(() => recorder.reportExecution(report));
|
|
@@ -3230,7 +3477,7 @@ export class ControlServer {
|
|
|
3230
3477
|
const run = session.run;
|
|
3231
3478
|
if (!run)
|
|
3232
3479
|
return;
|
|
3233
|
-
this.sealRun(session, opts.reason);
|
|
3480
|
+
this.sealRun(session, opts.reason, opts.settle === true);
|
|
3234
3481
|
if (opts.detach && session.run === run) {
|
|
3235
3482
|
session.run = undefined;
|
|
3236
3483
|
session.previous = run;
|
|
@@ -3255,7 +3502,14 @@ export class ControlServer {
|
|
|
3255
3502
|
*/
|
|
3256
3503
|
async drainRun(run, flushMs) {
|
|
3257
3504
|
try {
|
|
3258
|
-
|
|
3505
|
+
// The seal adds to what it waits for while it settles — the books wait
|
|
3506
|
+
// for the turn's last message, and then for the answer's text — so this
|
|
3507
|
+
// waits until nothing more was added.
|
|
3508
|
+
for (let sealing = run.sealing;; sealing = run.sealing) {
|
|
3509
|
+
await sealing;
|
|
3510
|
+
if (run.sealing === sealing)
|
|
3511
|
+
break;
|
|
3512
|
+
}
|
|
3259
3513
|
await this.queue.flush();
|
|
3260
3514
|
// Bounded, so an outage cannot pin every sealed turn in memory until the
|
|
3261
3515
|
// service returns; the recorder keeps sending on its own either way.
|
|
@@ -3414,6 +3668,10 @@ export class ControlServer {
|
|
|
3414
3668
|
open.promptSeen = true;
|
|
3415
3669
|
open.input = input;
|
|
3416
3670
|
open.promptDay = localDay();
|
|
3671
|
+
// A prompt all the same: the next one is a turn of its own. Not counted,
|
|
3672
|
+
// the session still looked as if it had had none, so the next prompt took
|
|
3673
|
+
// this branch again and was recorded into this run too.
|
|
3674
|
+
session.prompts += 1;
|
|
3417
3675
|
// This hook announces nothing, so no plan of this run may steer.
|
|
3418
3676
|
open.matchExpired = true;
|
|
3419
3677
|
this.retireUnannounced(session, open);
|
|
@@ -4538,6 +4796,7 @@ export class ControlServer {
|
|
|
4538
4796
|
// session's own cwd from the hook payloads, never the daemon's.
|
|
4539
4797
|
state.sessionCwd = session.cwd ?? this.opts.cwd;
|
|
4540
4798
|
this.declineWithoutDeliveryTool(state);
|
|
4799
|
+
this.declineAfterCancel(session, state);
|
|
4541
4800
|
if (!state.plan) {
|
|
4542
4801
|
// A declined intent hit reports nothing: the turn's cost is not a
|
|
4543
4802
|
// measurement of that scenario's task, so it is no baseline sample either.
|
|
@@ -4660,6 +4919,7 @@ export class ControlServer {
|
|
|
4660
4919
|
scenarioId: f.scenarioId,
|
|
4661
4920
|
stepIndex: f.stepIndex,
|
|
4662
4921
|
...(f.kind ? { kind: f.kind } : {}),
|
|
4922
|
+
...(f.stop ? { stop: f.stop } : {}),
|
|
4663
4923
|
...(f.handoverId ? { id: f.handoverId } : {}),
|
|
4664
4924
|
};
|
|
4665
4925
|
// Whether this hand-over is still the turn's: its fragment due, nothing
|
|
@@ -4996,6 +5256,7 @@ export class ControlServer {
|
|
|
4996
5256
|
// Which half the model's work after it is (the service's fallbk.md
|
|
4997
5257
|
// Part 3), and the id that makes the hand-over one hit.
|
|
4998
5258
|
kind: h.kind,
|
|
5259
|
+
...(h.stop ? { stop: h.stop } : {}),
|
|
4999
5260
|
handoverId: `ho_${randomUUID()}`,
|
|
5000
5261
|
};
|
|
5001
5262
|
}
|
|
@@ -5023,6 +5284,7 @@ export class ControlServer {
|
|
|
5023
5284
|
const sent = {
|
|
5024
5285
|
...fallbackOf,
|
|
5025
5286
|
...(f.kind ? { kind: f.kind } : {}),
|
|
5287
|
+
...(f.stop ? { stop: f.stop } : {}),
|
|
5026
5288
|
...(f.handoverId ? { handoverId: f.handoverId } : {}),
|
|
5027
5289
|
...(f.counted ? { counted: true } : {}),
|
|
5028
5290
|
};
|
|
@@ -5484,7 +5746,21 @@ export class ControlServer {
|
|
|
5484
5746
|
correlation.fallback.unref?.();
|
|
5485
5747
|
return { pending: true };
|
|
5486
5748
|
}
|
|
5487
|
-
|
|
5749
|
+
// The step this answer completes. A host that sends no `tool_use_id` is
|
|
5750
|
+
// paired by the tool's name, oldest call first: the pre stored its call
|
|
5751
|
+
// under its step index (`anon_<index>`) and this looked it up under
|
|
5752
|
+
// `anon_<tool name>`, which never matched, so every answer of such a host
|
|
5753
|
+
// was recorded at an index of its own and the pairs shifted.
|
|
5754
|
+
let builtInKey = toolUseId && run.builtIns.has(toolUseId) ? toolUseId : undefined;
|
|
5755
|
+
if (builtInKey === undefined) {
|
|
5756
|
+
for (const [key, candidate] of run.builtIns) {
|
|
5757
|
+
if (key.startsWith("anon_") && candidate.toolName === toolName) {
|
|
5758
|
+
builtInKey = key;
|
|
5759
|
+
break;
|
|
5760
|
+
}
|
|
5761
|
+
}
|
|
5762
|
+
}
|
|
5763
|
+
const builtIn = builtInKey !== undefined ? run.builtIns.get(builtInKey) : undefined;
|
|
5488
5764
|
const responseIndex = builtIn ? builtIn.responseIndex : run.ordering.allocate();
|
|
5489
5765
|
if (run.recording) {
|
|
5490
5766
|
const output = serializeCapped(redact(payload.tool_response ?? {}));
|
|
@@ -5500,7 +5776,8 @@ export class ControlServer {
|
|
|
5500
5776
|
});
|
|
5501
5777
|
});
|
|
5502
5778
|
}
|
|
5503
|
-
|
|
5779
|
+
if (builtInKey !== undefined)
|
|
5780
|
+
run.builtIns.delete(builtInKey);
|
|
5504
5781
|
logDetail("tool.post", { run: run.runId, tool: toolName, step: responseIndex, failed });
|
|
5505
5782
|
return { stepIndex: responseIndex };
|
|
5506
5783
|
}
|
|
@@ -5562,8 +5839,18 @@ export class ControlServer {
|
|
|
5562
5839
|
// Another host hands the run's answer and its tokens over here — it has
|
|
5563
5840
|
// no transcript to read them from — and says when the run failed or its
|
|
5564
5841
|
// recording is not whole.
|
|
5565
|
-
|
|
5566
|
-
|
|
5842
|
+
// `StopFailure` arrives on this route too: the turn ended on an API error,
|
|
5843
|
+
// with no answer to check and no last message to wait for. Sealed as the
|
|
5844
|
+
// ordinary stop it was taken for, its cut answer went to the answer check
|
|
5845
|
+
// and its cost was reported as a whole turn's.
|
|
5846
|
+
const reason = otherHost(session)
|
|
5847
|
+
? this.noteHostStop(run, payload)
|
|
5848
|
+
: payload.hook_event_name === "StopFailure"
|
|
5849
|
+
? "stop_failure"
|
|
5850
|
+
: "stop";
|
|
5851
|
+
// Claude Code writes the turn's last message after this hook: the turn's
|
|
5852
|
+
// cost is read once it is there (`sealRun`).
|
|
5853
|
+
this.closeRun(session, { detach: false, reason, settle: reason === "stop" });
|
|
5567
5854
|
return {};
|
|
5568
5855
|
}
|
|
5569
5856
|
/**
|
|
@@ -5836,6 +6123,10 @@ export class ControlServer {
|
|
|
5836
6123
|
// prompt may seal the turn before the step in flight has finished.
|
|
5837
6124
|
const onCancel = () => {
|
|
5838
6125
|
this.cancelledPlans.add(state);
|
|
6126
|
+
// And the same plan is not run again at once, in any session, when its
|
|
6127
|
+
// run sent a call ({@link declineAfterCancel}).
|
|
6128
|
+
if (state.scenarioId)
|
|
6129
|
+
this.holdAfterCancel(state.scenarioId, state);
|
|
5839
6130
|
};
|
|
5840
6131
|
if (signal?.aborted)
|
|
5841
6132
|
onCancel();
|
|
@@ -5864,8 +6155,9 @@ export class ControlServer {
|
|
|
5864
6155
|
* run those steps a second time.
|
|
5865
6156
|
*/
|
|
5866
6157
|
stashCancelledNote(sessionId, state) {
|
|
5867
|
-
|
|
5868
|
-
|
|
6158
|
+
// A hand-over's note, or — when every step ran with nobody to read the
|
|
6159
|
+
// results — the note about those.
|
|
6160
|
+
if (!this.replay.hasUndeliveredNote(state))
|
|
5869
6161
|
return;
|
|
5870
6162
|
const session = this.sessions.get(sessionId);
|
|
5871
6163
|
if (!session)
|
|
@@ -6316,12 +6608,8 @@ export class ControlServer {
|
|
|
6316
6608
|
});
|
|
6317
6609
|
}
|
|
6318
6610
|
}
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
* ephemeral one only if it is taken — in which case the installed hooks point at
|
|
6324
|
-
* a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
|
|
6325
|
-
*/
|
|
6326
|
-
export const DEFAULT_CONTROL_PORT = 53411;
|
|
6611
|
+
// The port `bir install` writes into the hook URLs now lives in control/paths.ts:
|
|
6612
|
+
// `bir` needed this one number, and loading this whole file for it added some
|
|
6613
|
+
// 50 ms to every command. Re-exported, so the server's own importers still find it.
|
|
6614
|
+
export { DEFAULT_CONTROL_PORT } from "./paths.js";
|
|
6327
6615
|
//# sourceMappingURL=server.js.map
|