@junghanacs/entwurf 0.16.0 → 0.17.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/AGENTS.md +5 -3
- package/CHANGELOG.md +337 -0
- package/README.md +8 -11
- package/VERIFY.md +8 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +25 -14
- package/docs/setup-clean-host.md +24 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
- package/package.json +10 -10
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
- package/pi-extensions/lib/acp/backend.ts +578 -18
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/run.sh +129 -32
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-gate-qualification.ts +4 -2
- package/scripts/check-omp-fresh-preflight.ts +27 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- package/scripts/mutants/copilot-birth.json +3 -5
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-tool-surface.py +61 -10
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
- package/scripts/smoke-setup-verdict.sh +48 -3
|
@@ -46,7 +46,12 @@ import { Readable, Writable } from "node:stream";
|
|
|
46
46
|
import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
47
47
|
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
48
48
|
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
type AcpClientHandlers,
|
|
51
|
+
type AcpConnectionLike,
|
|
52
|
+
type AcpPromptResponse,
|
|
53
|
+
connectAcpClient,
|
|
54
|
+
} from "./acp-client.js";
|
|
50
55
|
import { prependNewPromptAugment } from "./augment.js";
|
|
51
56
|
import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
|
|
52
57
|
import {
|
|
@@ -104,6 +109,97 @@ const SET_MODEL_TIMEOUT_MS = 30_000;
|
|
|
104
109
|
// to process-group teardown, so an abort always returns promptly.
|
|
105
110
|
const ABORT_CANCEL_GRACE_MS = 5_000;
|
|
106
111
|
|
|
112
|
+
/** Tokens below which a re-billed prefix is breakpoint granularity, not news.
|
|
113
|
+
* This matches pi's token threshold for a native cache-miss notice, which also
|
|
114
|
+
* considers a separate $0.10 cost threshold (read at pi-coding-agent
|
|
115
|
+
* `dist/modes/interactive/interactive-mode.js:3130`). */
|
|
116
|
+
const CACHE_MISS_NOTICE_FLOOR_TOKENS = 20_000;
|
|
117
|
+
|
|
118
|
+
/** ONE turn's ACCOUNTING totals: the SUM over that turn's API round trips, as
|
|
119
|
+
* ACP reports them. Carried on `usage.acp`, never on pi's four `Usage` fields —
|
|
120
|
+
* those mean one REQUEST's prompt shape to readers that would misread a sum
|
|
121
|
+
* (see `sealTurnUsage`). This is the vendor's own arithmetic, relayed. */
|
|
122
|
+
interface AcpTurnAccounting {
|
|
123
|
+
input: number;
|
|
124
|
+
output: number;
|
|
125
|
+
cacheRead: number;
|
|
126
|
+
cacheWrite: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function finiteOrZero(value: unknown): number {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ONE turn's ACCOUNTING totals, preferring the widest scope the backend offers.
|
|
135
|
+
*
|
|
136
|
+
* `_meta.quota.model_usage` comes from `result.modelUsage` and the vendor calls it
|
|
137
|
+
* "the accounting-grade figure per the SDK" — it also counts Task subagents,
|
|
138
|
+
* sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
|
|
139
|
+
* than `token_count`" and are "the fuller picture, not a decomposition of it"
|
|
140
|
+
* (read at claude-agent-acp 0.73.0 `dist/acp-agent.js:5728-5748`). The narrower
|
|
141
|
+
* `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
|
|
142
|
+
*
|
|
143
|
+
* The wider one is the right numerator because the denominator already has that
|
|
144
|
+
* scope: `usage.cost.total` is the diff of the backend's running total, which
|
|
145
|
+
* includes those internal calls. Pairing a main-loop token sum with an
|
|
146
|
+
* all-inclusive cost understates the cache-effect badge exactly when compaction
|
|
147
|
+
* ran — and compaction is a live path again (#94). Rows are summed because a
|
|
148
|
+
* session may in principle report more than one model; with GLG's single-model
|
|
149
|
+
* rule there is exactly one.
|
|
150
|
+
*
|
|
151
|
+
* Falls back to `usage` when the sidecar is absent: `_meta` is a standard ACP
|
|
152
|
+
* extension slot whose contents a client may not assume, and `quota` is not in
|
|
153
|
+
* claude-agent-acp's exported types (#96 carries the re-measure-on-bump duty).
|
|
154
|
+
*/
|
|
155
|
+
function readTurnAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
|
|
156
|
+
const rows = promptResult?._meta?.quota?.model_usage;
|
|
157
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
158
|
+
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const t = row?.token_count;
|
|
161
|
+
if (!t) continue;
|
|
162
|
+
total.input += finiteOrZero(t.inputTokens);
|
|
163
|
+
total.output += finiteOrZero(t.outputTokens);
|
|
164
|
+
total.cacheRead += finiteOrZero(t.cachedInputTokens);
|
|
165
|
+
total.cacheWrite += finiteOrZero(t.cachedWriteTokens);
|
|
166
|
+
}
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
const wire = promptResult?.usage;
|
|
170
|
+
if (!wire) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
input: finiteOrZero(wire.inputTokens),
|
|
173
|
+
output: finiteOrZero(wire.outputTokens),
|
|
174
|
+
cacheRead: finiteOrZero(wire.cachedReadTokens),
|
|
175
|
+
cacheWrite: finiteOrZero(wire.cachedWriteTokens),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The MAIN AGENT LOOP's token totals (`PromptResponse.usage` ==
|
|
181
|
+
* `quota.token_count`). Same four fields as `readTurnAccounting`, different
|
|
182
|
+
* scope: this excludes Task subagents, sidechains, and internal calls such
|
|
183
|
+
* as compaction. The cache-miss bound's recurrence is a MAIN-LOOP identity
|
|
184
|
+
* — occupancy is main-context occupancy — so its IO terms come from here,
|
|
185
|
+
* never from the wide rows. Reporting still uses `readTurnAccounting`.
|
|
186
|
+
*/
|
|
187
|
+
function readMainLoopAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
|
|
188
|
+
const wire = promptResult?.usage;
|
|
189
|
+
if (!wire) return undefined;
|
|
190
|
+
return {
|
|
191
|
+
input: finiteOrZero(wire.inputTokens),
|
|
192
|
+
output: finiteOrZero(wire.outputTokens),
|
|
193
|
+
cacheRead: finiteOrZero(wire.cachedReadTokens),
|
|
194
|
+
cacheWrite: finiteOrZero(wire.cachedWriteTokens),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 191,971 → "192k". Keeps the notice inside the 80-char fragment cap. */
|
|
199
|
+
function formatTokenCount(tokens: number): string {
|
|
200
|
+
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(Math.round(tokens));
|
|
201
|
+
}
|
|
202
|
+
|
|
107
203
|
type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
|
|
108
204
|
|
|
109
205
|
// ---------------------------------------------------------------------------
|
|
@@ -119,7 +215,13 @@ export interface AcpChildLike {
|
|
|
119
215
|
signalCode: NodeJS.Signals | null;
|
|
120
216
|
stdin: { destroy(): void; unref?(): void };
|
|
121
217
|
stdout: { destroy(): void; unref?(): void };
|
|
122
|
-
stderr: {
|
|
218
|
+
stderr: {
|
|
219
|
+
on(event: "data", listener: (chunk: Buffer) => void): void;
|
|
220
|
+
/** Optional: absent on minimal fakes; the flush is best-effort, never load-bearing for liveness. */
|
|
221
|
+
once?(event: "close", listener: () => void): void;
|
|
222
|
+
destroy(): void;
|
|
223
|
+
unref?(): void;
|
|
224
|
+
};
|
|
123
225
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
124
226
|
unref(): void;
|
|
125
227
|
once(event: "exit" | "error", listener: (...args: unknown[]) => void): void;
|
|
@@ -210,6 +312,11 @@ interface BridgeSession {
|
|
|
210
312
|
* nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
|
|
211
313
|
*/
|
|
212
314
|
stderrTail: string[];
|
|
315
|
+
/**
|
|
316
|
+
* SESSION-scoped like `stderrTail`, and for the same reason: the launcher's
|
|
317
|
+
* frame can arrive on a turn later than the one that spawned the child.
|
|
318
|
+
*/
|
|
319
|
+
launchObservation: AcpLaunchObservation;
|
|
213
320
|
/** How the child ended, once it has — folded into the prompt-phase error. */
|
|
214
321
|
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
215
322
|
/**
|
|
@@ -234,6 +341,72 @@ interface BridgeSession {
|
|
|
234
341
|
childEnd: ChildEndLatch;
|
|
235
342
|
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
236
343
|
notifyChildGone?: (err: Error) => void;
|
|
344
|
+
/**
|
|
345
|
+
* The SDK's last-observed SESSION-CUMULATIVE cost, in USD — the baseline the
|
|
346
|
+
* NEXT turn's cost is differenced against — authoritative for this rail in
|
|
347
|
+
* the sense that the backend's own estimate outranks any local recompute,
|
|
348
|
+
* not in the sense of a billing statement (#93).
|
|
349
|
+
*
|
|
350
|
+
* BRIDGE-SESSION SCOPED, and that scope is the invariant. It must track the
|
|
351
|
+
* live child, because the cumulative it measures is that child's own running
|
|
352
|
+
* total and restarts at zero with a new one: a rebuilt session (config-signature
|
|
353
|
+
* drift) gets a fresh object and therefore a fresh baseline, which is exactly
|
|
354
|
+
* right. Hoisting this to the pi session would keep a dead child's total as the
|
|
355
|
+
* baseline and make the next real turn's diff enormous or negative.
|
|
356
|
+
*
|
|
357
|
+
* `undefined` = no cumulative observed yet, which is NOT the same as 0: a turn
|
|
358
|
+
* that reports no cost must HOLD this value, not zero it, or the amount it
|
|
359
|
+
* carried would be double-counted by the next diff.
|
|
360
|
+
*/
|
|
361
|
+
sdkCumulativeCostUsd?: number;
|
|
362
|
+
/**
|
|
363
|
+
* The last observed context OCCUPANCY (`usage_update.used`), carried forward
|
|
364
|
+
* across a turn whose notification never arrived (#93).
|
|
365
|
+
*
|
|
366
|
+
* Without this, such a turn carries accounting at `usage.acp` but has zero
|
|
367
|
+
* `totalTokens` and zero pi-native usage fields. pi's
|
|
368
|
+
* `calculateContextTokens` (read at pi-coding-agent
|
|
369
|
+
* `dist/core/compaction/compaction.js:86-88`) then returns 0, making
|
|
370
|
+
* auto-compaction replace the vendor observation with its local estimate
|
|
371
|
+
* (read at pi-coding-agent `dist/core/agent-session.js:1696-1699`). Carrying
|
|
372
|
+
* the last measurement preserves the last known occupancy. Assigned, never
|
|
373
|
+
* summed.
|
|
374
|
+
*/
|
|
375
|
+
contextOccupancyTokens?: number;
|
|
376
|
+
/**
|
|
377
|
+
* The PREVIOUS turn's MAIN-LOOP `Σinput + Σoutput` (`PromptResponse.usage`,
|
|
378
|
+
* never the wide `model_usage` sum) and the wall-clock ms at which that
|
|
379
|
+
* turn SEALED. The idle gap is then measured from that seal to THIS turn's
|
|
380
|
+
* START (`turnStartedAtMs`), never to its seal, so a turn's own duration can
|
|
381
|
+
* never inflate the gap it reports. Together with `contextOccupancyTokens`,
|
|
382
|
+
* they are the whole input to the cache-miss bound in `sealTurnUsage` — no
|
|
383
|
+
* per-round-trip collection.
|
|
384
|
+
*
|
|
385
|
+
* Why these two and nothing else: within one turn Claude Code's cache
|
|
386
|
+
* breakpoints make `cacheRead_i = cacheRead_(i-1) + cacheWrite_(i-1)`
|
|
387
|
+
* (re-measured 2026-09-02 across the WHOLE ACP overlay corpus, not one ledger:
|
|
388
|
+
* 2,398 of 2,410 adjacent pairs hold — 99.50% — over 30+ session transcripts
|
|
389
|
+
* under `~/.pi/agent/claude-config-overlay/projects` spanning 2026-05 to
|
|
390
|
+
* 2026-09. On the incident ledger alone it is 101 of 102, and that single break
|
|
391
|
+
* IS the miss: cacheRead 0 against a predicted 195,177 at 2026-09-01T21:15:09Z.
|
|
392
|
+
* Of the 12 corpus-wide breaks, 10 fall BELOW prediction — each one a real miss
|
|
393
|
+
* or partial re-read — and the 2 that exceed it do so by 4,357 and 3,283 tokens,
|
|
394
|
+
* both far under the notice floor, so neither could lift a quiet turn over the
|
|
395
|
+
* threshold on its own). Telescoping that identity gives
|
|
396
|
+
*
|
|
397
|
+
* cacheRead_first = used_end − ΣcacheWrite − (input_last + output_last)
|
|
398
|
+
*
|
|
399
|
+
* and the same identity on the previous turn gives what THIS turn's first
|
|
400
|
+
* request would have read had the cache still been warm:
|
|
401
|
+
*
|
|
402
|
+
* expected = used_prev − (input_last' + output_last')
|
|
403
|
+
*
|
|
404
|
+
* Only the LAST request's input+output is unknown at turn scope, and it is
|
|
405
|
+
* bounded by the turn's own sums — so both quantities come out as PROVEN
|
|
406
|
+
* INTERVALS, never estimates. Assigned, never summed.
|
|
407
|
+
*/
|
|
408
|
+
priorTurnInputOutputSum?: number;
|
|
409
|
+
priorTurnSealedAtMs?: number;
|
|
237
410
|
/**
|
|
238
411
|
* This TURN owns reporting the child's end, so the next turn must not also
|
|
239
412
|
* announce it. Raised at the top of a failure path — BEFORE the bounded
|
|
@@ -367,6 +540,85 @@ const CHILD_END_SETTLE_MS = 500;
|
|
|
367
540
|
*/
|
|
368
541
|
const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
|
|
369
542
|
|
|
543
|
+
/**
|
|
544
|
+
* The launcher's control frame — see `claude-acp-launch.js`.
|
|
545
|
+
*
|
|
546
|
+
* Matched as an EXACT FULL LINE and nothing else. The vendor writes prose that
|
|
547
|
+
* mentions signals; a substring test would let vendor text manufacture an
|
|
548
|
+
* entwurf observation, which is precisely the confusion #72 cost three
|
|
549
|
+
* diagnosis passes. Our own frame is a fixed enum, so exact-line matching is
|
|
550
|
+
* sufficient AND necessary.
|
|
551
|
+
*/
|
|
552
|
+
const LAUNCH_SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
|
|
553
|
+
const LAUNCH_SIGNAL_FRAME_VALUES: ReadonlySet<string> = new Set(["SIGTERM", "SIGINT"]);
|
|
554
|
+
|
|
555
|
+
/** A mutable, session-scoped record of a terminating signal the LAUNCHER caught. */
|
|
556
|
+
export type AcpLaunchObservation = { signal?: "SIGTERM" | "SIGINT" };
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Split a stderr chunk stream into lines, consuming our own frames and passing
|
|
560
|
+
* everything else through to the tail untouched.
|
|
561
|
+
*
|
|
562
|
+
* Line-buffered because a chunk boundary can fall inside a frame; the trailing
|
|
563
|
+
* partial line is held, not emitted, so a frame split across two reads is still
|
|
564
|
+
* recognised exactly.
|
|
565
|
+
*/
|
|
566
|
+
function makeLaunchFrameFilter(observation: AcpLaunchObservation, onText: (text: string) => void) {
|
|
567
|
+
let held = "";
|
|
568
|
+
return {
|
|
569
|
+
write(chunk: string): void {
|
|
570
|
+
held += chunk;
|
|
571
|
+
let nl = held.indexOf("\n");
|
|
572
|
+
let passed = "";
|
|
573
|
+
while (nl !== -1) {
|
|
574
|
+
const line = held.slice(0, nl);
|
|
575
|
+
if (
|
|
576
|
+
line.startsWith(LAUNCH_SIGNAL_FRAME_PREFIX) &&
|
|
577
|
+
LAUNCH_SIGNAL_FRAME_VALUES.has(line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length))
|
|
578
|
+
) {
|
|
579
|
+
// FIRST caught signal wins: a later one is our own teardown racing
|
|
580
|
+
// the external kill, and reporting that would bury the cause.
|
|
581
|
+
observation.signal ??= line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length) as "SIGTERM" | "SIGINT";
|
|
582
|
+
} else {
|
|
583
|
+
passed += `${line}\n`;
|
|
584
|
+
}
|
|
585
|
+
held = held.slice(nl + 1);
|
|
586
|
+
nl = held.indexOf("\n");
|
|
587
|
+
}
|
|
588
|
+
if (passed) onText(passed);
|
|
589
|
+
},
|
|
590
|
+
/**
|
|
591
|
+
* The child's LAST words may arrive without a trailing newline — a process
|
|
592
|
+
* dying mid-write is exactly when that happens, and it is exactly when the
|
|
593
|
+
* tail matters most. Line buffering would otherwise hold that fragment
|
|
594
|
+
* forever, so the stream's close flushes it VERBATIM.
|
|
595
|
+
*
|
|
596
|
+
* No frame check here, deliberately: the launcher writes its frame with a
|
|
597
|
+
* single `writeSync` including the newline, well under PIPE_BUF, so a
|
|
598
|
+
* complete frame can never be the un-terminated remainder. Anything left
|
|
599
|
+
* without a newline is vendor text by construction.
|
|
600
|
+
*/
|
|
601
|
+
flush(): void {
|
|
602
|
+
if (!held) return;
|
|
603
|
+
onText(held);
|
|
604
|
+
held = "";
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* What the operator is told about a caught signal — and what they are NOT told.
|
|
611
|
+
*
|
|
612
|
+
* Absence is reported as "not observed", never as "no signal": the launcher can
|
|
613
|
+
* only see what reached it, and an override launch (`CLAUDE_AGENT_ACP_COMMAND`)
|
|
614
|
+
* has no launcher at all. Attribution of the SENDER is not claimed here either —
|
|
615
|
+
* that needs the host's journal, which this process cannot read.
|
|
616
|
+
*/
|
|
617
|
+
function launchSignalLine(observation: AcpLaunchObservation | undefined): string | undefined {
|
|
618
|
+
if (!observation?.signal) return undefined;
|
|
619
|
+
return `[acp] launch observed ${observation.signal} before child exit (sender not attributed)`;
|
|
620
|
+
}
|
|
621
|
+
|
|
370
622
|
function isAcpConnectionClosure(err: unknown): boolean {
|
|
371
623
|
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
372
624
|
return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
|
|
@@ -520,7 +772,7 @@ async function awaitAcpPromptTurn(
|
|
|
520
772
|
session: BridgeSession,
|
|
521
773
|
promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
|
|
522
774
|
opts: { signal?: AbortSignal; graceMs: number },
|
|
523
|
-
): Promise<
|
|
775
|
+
): Promise<AcpPromptResponse> {
|
|
524
776
|
let rejectLifecycle: ((err: Error) => void) | undefined;
|
|
525
777
|
const lifecycle = new Promise<never>((_, reject) => {
|
|
526
778
|
rejectLifecycle = reject;
|
|
@@ -590,9 +842,12 @@ export type AcpStopVerdict = {
|
|
|
590
842
|
/**
|
|
591
843
|
* ACP prompt stopReason → pi verdict.
|
|
592
844
|
*
|
|
593
|
-
* The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.
|
|
594
|
-
* `schema/types.gen`): end_turn | max_tokens | max_turn_requests |
|
|
595
|
-
* cancelled
|
|
845
|
+
* The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.4.0
|
|
846
|
+
* `dist/schema/types.gen.d.ts:3001`): end_turn | max_tokens | max_turn_requests |
|
|
847
|
+
* refusal | cancelled — no `| string` arm. 1.4.0 also ships an OPEN union at
|
|
848
|
+
* `dist/v2/schema/types.gen.d.ts:3607`, but that is behind the `./experimental/v2`
|
|
849
|
+
* export and entwurf imports the bare specifier, which resolves to the closed v1
|
|
850
|
+
* surface. Only three of those are successful or benign ends. The previous
|
|
596
851
|
* implementation returned a bare StopReason with `default: "stop"`, which turned
|
|
597
852
|
* `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
|
|
598
853
|
* clean successful turn — pi then rendered a silently truncated answer as if the
|
|
@@ -867,6 +1122,14 @@ export function streamAcpTurn(
|
|
|
867
1122
|
deps: AcpTurnDeps,
|
|
868
1123
|
): ReturnType<typeof createAssistantMessageEventStream> {
|
|
869
1124
|
const stream = createAssistantMessageEventStream();
|
|
1125
|
+
// When THIS turn began. The idle gap that expires a prompt cache is the time
|
|
1126
|
+
// between turns, so it must be measured to a turn's START — measuring to its
|
|
1127
|
+
// seal would fold this turn's own duration into the gap and call a 58-minute
|
|
1128
|
+
// pause plus a 4-minute turn "62m idle", which crosses the 1h TTL in the
|
|
1129
|
+
// report while nothing crossed it in fact. pi's native detector uses message
|
|
1130
|
+
// timestamps for the same reason (read at pi-coding-agent
|
|
1131
|
+
// `dist/core/cache-stats.js:14-37`, `idleMs`).
|
|
1132
|
+
const turnStartedAtMs = Date.now();
|
|
870
1133
|
const state: AcpPiStreamState = createAcpStreamState(stream, {
|
|
871
1134
|
api: "entwurf",
|
|
872
1135
|
provider: "entwurf",
|
|
@@ -893,13 +1156,258 @@ export function streamAcpTurn(
|
|
|
893
1156
|
};
|
|
894
1157
|
}
|
|
895
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* Seal this turn's ACCOUNTING — the #93 authority boundary.
|
|
1161
|
+
*
|
|
1162
|
+
* Runs ONLY for a backend whose adapter declares `sealsTurnAccounting`. Flag
|
|
1163
|
+
* absence means that backend's usage semantics were never measured, so nothing
|
|
1164
|
+
* is sealed and its emitted usage is whatever the common mapper produced —
|
|
1165
|
+
* unchanged, not guessed at.
|
|
1166
|
+
*
|
|
1167
|
+
* Two INDEPENDENT axes, because they arrive on different wires:
|
|
1168
|
+
*
|
|
1169
|
+
* tokens the turn aggregate, relayed from PromptResponse on `usage.acp`.
|
|
1170
|
+
* cost an ADJACENT DIFF of the backend's own running session total, which
|
|
1171
|
+
* arrives on `usage_update` and is captured raw by the mapper.
|
|
1172
|
+
*
|
|
1173
|
+
* The diff is what makes the session sum reproduce the backend's own running
|
|
1174
|
+
* total exactly — with no residue, which is what the gate measures. Read that
|
|
1175
|
+
* exactness for what it is: agreement with the BACKEND'S CUMULATIVE ESTIMATE,
|
|
1176
|
+
* never with an Anthropic invoice. The vendor calls `total_cost_usd` a
|
|
1177
|
+
* "Cumulative estimated cost" and "An estimate, not a billing statement"
|
|
1178
|
+
* (claude-agent-sdk `sdk.d.ts:4884`), and entwurf has measured no live
|
|
1179
|
+
* comparison against a bill.
|
|
1180
|
+
*
|
|
1181
|
+
* We prefer that estimate anyway for a STRUCTURAL reason, not because it is
|
|
1182
|
+
* more precise in general: it is computed UPSTREAM of a lossy flattening we
|
|
1183
|
+
* cannot undo. The observed cache-writes are entirely 1h, ACP carries no 1h
|
|
1184
|
+
* field, and pi prices a write with no `cacheWrite1h` at the 5m rate (read at
|
|
1185
|
+
* pi-coding-agent `dist/bundle/chunks/bedrock-converse-stream.js`,
|
|
1186
|
+
* `calculateCost`) — a local recompute measured $1.66 low on one live
|
|
1187
|
+
* ledger. Nothing here calls calculateCost: any price table we could apply
|
|
1188
|
+
* runs DOWNSTREAM of that flattening, so it would be a second, wronger
|
|
1189
|
+
* source.
|
|
1190
|
+
*/
|
|
1191
|
+
function sealTurnUsage(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
|
|
1192
|
+
if (!adapter.sealsTurnAccounting) return;
|
|
1193
|
+
|
|
1194
|
+
// --- the turn's ACCOUNTING aggregate, verbatim ------------------------
|
|
1195
|
+
// ACP reports one number set per turn and it is the SUM OVER THAT TURN'S API
|
|
1196
|
+
// ROUND TRIPS. It is carried on its OWN key, never on pi's four, because
|
|
1197
|
+
// those four mean ONE REQUEST's prompt shape to two readers that never go
|
|
1198
|
+
// through `calculateContextTokens` and so cannot be rescued by an honest
|
|
1199
|
+
// `totalTokens` (isContextOverflow, read at pi-ai
|
|
1200
|
+
// `dist/utils/overflow.js:132-145`; cache-stats.detectMiss, read at
|
|
1201
|
+
// pi-coding-agent `dist/core/cache-stats.js:14-37`). Projecting the
|
|
1202
|
+
// aggregate onto them compacted a live 223,516-token session on a 1,000,000
|
|
1203
|
+
// window (measured 2026-09-01).
|
|
1204
|
+
//
|
|
1205
|
+
// Nothing is computed here: these are the vendor's own four numbers, put
|
|
1206
|
+
// somewhere they cannot be mistaken for a per-request reading. pi stores an
|
|
1207
|
+
// assistant message as JSON and reads it back the same way (read at
|
|
1208
|
+
// pi-coding-agent `dist/core/session-manager.js:768-777` appendMessage,
|
|
1209
|
+
// `:98` parseSessionEntries), so a key pi does not know survives a resume.
|
|
1210
|
+
const aggregate = readTurnAccounting(promptResult);
|
|
1211
|
+
const mainLoop = readMainLoopAccounting(promptResult);
|
|
1212
|
+
if (aggregate) {
|
|
1213
|
+
(state.output.usage as unknown as { acp?: AcpTurnAccounting }).acp = aggregate;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// --- token axis: DELIBERATELY UNWRITTEN ------------------------------
|
|
1217
|
+
// The four token fields stay at their zero initialisation. ACP's only token
|
|
1218
|
+
// carrier is `PromptResponse.usage`, and that is the SUM OVER THIS TURN'S
|
|
1219
|
+
// API ROUND TRIPS — measured 2026-09-02 on one 21-round-trip turn whose
|
|
1220
|
+
// overlay rows summed to exactly the four numbers ACP reported
|
|
1221
|
+
// (cacheRead 4,185,084) while the context it occupied was 223,516.
|
|
1222
|
+
//
|
|
1223
|
+
// pi reads these four as ONE REQUEST's prompt shape, in two places that do
|
|
1224
|
+
// NOT go through `calculateContextTokens` and so cannot be rescued by the
|
|
1225
|
+
// honest `totalTokens` written below:
|
|
1226
|
+
//
|
|
1227
|
+
// isContextOverflow `input + cacheRead > contextWindow` (read at pi-ai
|
|
1228
|
+
// `dist/utils/overflow.js:132-145`). The aggregate made
|
|
1229
|
+
// that true at 223k of a 1M window and compacted a live
|
|
1230
|
+
// session — the defect this seal now refuses to feed.
|
|
1231
|
+
// detectMiss `input + cacheRead + cacheWrite` vs the previous
|
|
1232
|
+
// request (read at pi-coding-agent
|
|
1233
|
+
// `dist/core/cache-stats.js:14-37`). Under the aggregate
|
|
1234
|
+
// it invented two phantom misses and SILENCED the real
|
|
1235
|
+
// 195,177-token one after a 401-minute idle gap.
|
|
1236
|
+
//
|
|
1237
|
+
// Writing zeros is not a placeholder for a better number we could compute:
|
|
1238
|
+
// the per-request partition is genuinely absent from the wire. The vendor
|
|
1239
|
+
// builds it in `lastAssistantUsage` and sends only its scalar sum (read at
|
|
1240
|
+
// claude-agent-acp 0.73.0 `dist/acp-agent.js:3273-3297`) — #96.
|
|
1241
|
+
//
|
|
1242
|
+
// But silence is NOT the resting state. A cache miss the operator never sees
|
|
1243
|
+
// is a false reading, not a modest one: a session can run for hours believing
|
|
1244
|
+
// its badge while a full prefix rewrite has already been paid for. So the
|
|
1245
|
+
// aggregate rides its own key above, and the bound below reports the rewrite.
|
|
1246
|
+
|
|
1247
|
+
// --- context occupancy (NOT a turn total — see the field's note) -----
|
|
1248
|
+
// Refresh from THIS turn's notification when there was one, then assign the
|
|
1249
|
+
// session's value UNCONDITIONALLY. The assignment is not conditional on the
|
|
1250
|
+
// notification having been missing: on this path the seal — not the mapper —
|
|
1251
|
+
// is the authority for what pi reads as occupancy, and when a fresh value did
|
|
1252
|
+
// arrive the two agree by construction.
|
|
1253
|
+
//
|
|
1254
|
+
// Why the field must be right rather than merely non-zero: pi's auto-compaction
|
|
1255
|
+
// takes `calculateContextTokens(message.usage)` at face value and falls back to
|
|
1256
|
+
// its own estimate ONLY when the message errored or that value is exactly 0
|
|
1257
|
+
// (read at pi-coding-agent `dist/core/agent-session.js:1696-1697`). A turn
|
|
1258
|
+
// aggregate written here would be non-zero and therefore never corrected — the
|
|
1259
|
+
// session would read as nearly empty all the way to an overflow.
|
|
1260
|
+
const priorOccupancy = session.contextOccupancyTokens;
|
|
1261
|
+
const priorIoSum = session.priorTurnInputOutputSum;
|
|
1262
|
+
const priorSealedAtMs = session.priorTurnSealedAtMs;
|
|
1263
|
+
|
|
1264
|
+
const occupancy = state.observedContextOccupancyTokens;
|
|
1265
|
+
if (typeof occupancy === "number") session.contextOccupancyTokens = occupancy;
|
|
1266
|
+
if (typeof session.contextOccupancyTokens === "number") {
|
|
1267
|
+
state.output.usage.totalTokens = session.contextOccupancyTokens;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// --- cache-miss bound (the rewrite the operator must not miss) -------
|
|
1271
|
+
// Both quantities below are PROVEN INTERVALS derived from numbers already in
|
|
1272
|
+
// hand, not estimates. See `priorTurnInputOutputSum` for the identity and its
|
|
1273
|
+
// telescoping. Only the LAST request's input+output is unknown at turn scope,
|
|
1274
|
+
// and each turn's own sums bound it:
|
|
1275
|
+
//
|
|
1276
|
+
// cacheRead_first ≤ used_end − ΣcacheWrite (upper)
|
|
1277
|
+
// expected ≥ used_prev − (Σinput' + Σoutput') (lower)
|
|
1278
|
+
// miss ≥ expected_lower − cacheRead_first_upper
|
|
1279
|
+
//
|
|
1280
|
+
// The interval is proven only while the within-turn recurrence holds, and that
|
|
1281
|
+
// recurrence is an OBSERVED property of Claude Code's breakpoint placement
|
|
1282
|
+
// (measured 2026-09-02 corpus-wide: 2,398 of 2,410 adjacent pairs, 99.50%; see
|
|
1283
|
+
// `priorTurnInputOutputSum` for the full count and the break characterisation),
|
|
1284
|
+
// not a guarantee. If it breaks mid-turn — a 1h TTL expiring
|
|
1285
|
+
// between round trips, or the child compacting itself — `used_end − ΣcacheWrite`
|
|
1286
|
+
// goes negative and the `max(0, …)` below assumes cacheRead_first = 0. The
|
|
1287
|
+
// notice is still TRUE (a rewrite did happen) but its N is then a floor of a
|
|
1288
|
+
// weaker kind, not a telescoped bound. #96 carries that limit.
|
|
1289
|
+
//
|
|
1290
|
+
// A negative or small bound proves nothing was re-billed beyond breakpoint
|
|
1291
|
+
// granularity, so nothing is said. The floor is the TOKEN half of pi's own
|
|
1292
|
+
// native cache-miss display policy, which suppresses a notice only when the
|
|
1293
|
+
// miss is under 20,000 tokens AND under $0.10 (read at pi-coding-agent
|
|
1294
|
+
// `dist/modes/interactive/interactive-mode.js:3130`). The ACP rail borrows
|
|
1295
|
+
// that familiar token threshold alone and claims no part of the cost half:
|
|
1296
|
+
// this bound is a token interval, and pricing it locally is the very
|
|
1297
|
+
// downstream reprice the cost axis above refuses.
|
|
1298
|
+
// The re-billed size can never exceed what this turn's MAIN LOOP actually
|
|
1299
|
+
// WROTE: a re-billed prefix is paid for as cache creation. Clamping to
|
|
1300
|
+
// `mainLoop.cacheWrite` (not the wide aggregate) is what keeps the notice
|
|
1301
|
+
// a statement about money that changed hands on the prefix rather than
|
|
1302
|
+
// about the recurrence holding, or about an internal compaction write.
|
|
1303
|
+
//
|
|
1304
|
+
// Without it a context SHRINK reads as a giant miss. Worked counterexample
|
|
1305
|
+
// (gpt-5.6-sol, 2026-09-02): prior occupancy 200,000, prior IO 1,000, this
|
|
1306
|
+
// turn occupancy 20,000 with cacheWrite 1,000 — organic compaction, nothing
|
|
1307
|
+
// re-billed — yields 200,000 − 1,000 − 19,000 = 180,000 and would announce
|
|
1308
|
+
// "cache miss ≥180k" over a turn that wrote 1,000 tokens. The clamp answers
|
|
1309
|
+
// 1,000, which is under the floor, so nothing is said. On the real incident
|
|
1310
|
+
// the clamp does not bind: 191,971 ≤ cacheWrite 221,084.
|
|
1311
|
+
//
|
|
1312
|
+
// Both prior marks are read from ONE turn and written from ONE turn. Mixing
|
|
1313
|
+
// an older occupancy with a newer IO sum puts two turns in one equation and
|
|
1314
|
+
// can skew the bound HIGH, so the update below is all-or-nothing.
|
|
1315
|
+
//
|
|
1316
|
+
// The same all-or-nothing applies to SCOPE. Occupancy is main-context;
|
|
1317
|
+
// the recurrence is a main-loop identity. `aggregate.cacheWrite` is the
|
|
1318
|
+
// WIDE accounting figure (Task subagents, sidechains, internal compaction).
|
|
1319
|
+
// A large unrelated wide write shrinks `max(0, occupancy − cacheWrite)`
|
|
1320
|
+
// (less is subtracted) AND raises the `min(…, cacheWrite)` ceiling — both
|
|
1321
|
+
// paths push the bound UP, so a warm main prefix can announce a miss and
|
|
1322
|
+
// attach this turn's all-inclusive dollar figure to it. The IO terms
|
|
1323
|
+
// therefore come from `mainLoop`, never from `aggregate`.
|
|
1324
|
+
const derivable =
|
|
1325
|
+
mainLoop !== undefined &&
|
|
1326
|
+
typeof occupancy === "number" &&
|
|
1327
|
+
typeof priorOccupancy === "number" &&
|
|
1328
|
+
typeof priorIoSum === "number";
|
|
1329
|
+
const cacheWriteForBound = mainLoop !== undefined ? mainLoop.cacheWrite : 0;
|
|
1330
|
+
const rawBound = derivable
|
|
1331
|
+
? (priorOccupancy as number) - (priorIoSum as number) - Math.max(0, (occupancy as number) - cacheWriteForBound)
|
|
1332
|
+
: undefined;
|
|
1333
|
+
const missLowerBound = rawBound === undefined ? undefined : Math.min(rawBound, cacheWriteForBound);
|
|
1334
|
+
|
|
1335
|
+
if (mainLoop && typeof occupancy === "number") {
|
|
1336
|
+
session.priorTurnInputOutputSum = mainLoop.input + mainLoop.output;
|
|
1337
|
+
session.priorTurnSealedAtMs = Date.now();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// --- cost axis ------------------------------------------------------
|
|
1341
|
+
// ALWAYS assigned on this path, including the 0 cases: the mapper may have
|
|
1342
|
+
// written the running cumulative into this field, and leaving it would put a
|
|
1343
|
+
// session total on a dashboard that sums per-turn costs — the whole defect.
|
|
1344
|
+
const observed = state.observedSessionCostUsd;
|
|
1345
|
+
let turnCostUsd: number | undefined;
|
|
1346
|
+
if (typeof observed !== "number") {
|
|
1347
|
+
// No cumulative arrived this turn. HOLD the baseline: this turn's real
|
|
1348
|
+
// amount is still inside the backend's running total and the next
|
|
1349
|
+
// adjacent diff absorbs it. Attributed to the wrong turn, exact in the
|
|
1350
|
+
// session sum. Zeroing the baseline here would double-count it instead.
|
|
1351
|
+
state.output.usage.cost.total = 0;
|
|
1352
|
+
} else {
|
|
1353
|
+
const diff = observed - (session.sdkCumulativeCostUsd ?? 0);
|
|
1354
|
+
if (diff < 0) {
|
|
1355
|
+
// The backend's running total went BACKWARDS. TWO receipts, and the gap
|
|
1356
|
+
// between them is exactly why the notice below names a MECHANISM and
|
|
1357
|
+
// never a cause: claude-agent-acp's `conversation_reset` handler only
|
|
1358
|
+
// switches the SDK to a fresh conversation and touches no cost at all
|
|
1359
|
+
// (read at 0.73.0 `dist/acp-agent.js:3675-3682`), while claude-agent-sdk
|
|
1360
|
+
// separately documents that "a mid-session /clear resets the running
|
|
1361
|
+
// total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
|
|
1362
|
+
// explains a backwards total, but nothing here has MEASURED that it did,
|
|
1363
|
+
// and asserting the cause would be the same unmeasured claim this lane
|
|
1364
|
+
// exists to end.
|
|
1365
|
+
// Rebaseline and attribute 0 — but SAY SO. Silently absorbing it would
|
|
1366
|
+
// hide the one observation that can settle what a reset does to the
|
|
1367
|
+
// total, which is precisely the "no silent misaccounting" this lane owes.
|
|
1368
|
+
const detail = `${(session.sdkCumulativeCostUsd ?? 0).toFixed(6)} → ${observed.toFixed(6)} USD`;
|
|
1369
|
+
session.sdkCumulativeCostUsd = observed;
|
|
1370
|
+
state.output.usage.cost.total = 0;
|
|
1371
|
+
// Kept under the notice fragment cap (80) so the two numbers survive
|
|
1372
|
+
// verbatim — a truncated diagnostic is not evidence.
|
|
1373
|
+
pushAcpLifecycleNotice(state, `cost baseline reset (${detail}) — this turn attributed $0`);
|
|
1374
|
+
console.error(
|
|
1375
|
+
`entwurf: ACP backend reported a DECREASING session cost (${detail}). Rebaselined; this turn is ` +
|
|
1376
|
+
`attributed $0. The cause is NOT measured here. The one documented mechanism is a mid-session ` +
|
|
1377
|
+
`/clear, which claude-agent-sdk (sdk.d.ts:4884) says resets the running total; the adapter's ` +
|
|
1378
|
+
`conversation_reset event is NOT it — that handler switches conversation and touches no cost. ` +
|
|
1379
|
+
`The session total from here on is measured against the new baseline.`,
|
|
1380
|
+
);
|
|
1381
|
+
} else {
|
|
1382
|
+
session.sdkCumulativeCostUsd = observed;
|
|
1383
|
+
state.output.usage.cost.total = diff;
|
|
1384
|
+
turnCostUsd = diff;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// --- report the rewrite ----------------------------------------------
|
|
1389
|
+
// Said LAST so it can quote what this turn actually cost. The cost is the
|
|
1390
|
+
// SDK's own adjacent diff, never a local repricing.
|
|
1391
|
+
if (typeof missLowerBound === "number" && missLowerBound >= CACHE_MISS_NOTICE_FLOOR_TOKENS) {
|
|
1392
|
+
// Only when there is an idle gap worth naming. A sub-minute one explains
|
|
1393
|
+
// nothing and "after 0m idle" reads as noise, so the clause is dropped —
|
|
1394
|
+
// the re-billed size and the cost still stand on their own.
|
|
1395
|
+
const idleMinutes =
|
|
1396
|
+
typeof priorSealedAtMs === "number" ? Math.round((turnStartedAtMs - priorSealedAtMs) / 60_000) : 0;
|
|
1397
|
+
const idle = idleMinutes >= 1 ? ` after ${idleMinutes}m idle` : "";
|
|
1398
|
+
const paid = typeof turnCostUsd === "number" ? ` — this turn $${turnCostUsd.toFixed(2)}` : "";
|
|
1399
|
+
pushAcpLifecycleNotice(state, `cache miss ≥${formatTokenCount(missLowerBound)} re-billed${idle}${paid}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
896
1403
|
/**
|
|
897
1404
|
* Seal the turn from the ACP prompt result. "Success" here means the RPC
|
|
898
1405
|
* returned, not that the turn ended well — a returned `refusal` /
|
|
899
1406
|
* `max_turn_requests` / unknown / absent reason is sealed as an error event,
|
|
900
1407
|
* never a `done`. `rawStopReason` carries the wire value out either way.
|
|
901
1408
|
*/
|
|
902
|
-
function finishSuccess(
|
|
1409
|
+
function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
|
|
1410
|
+
sealTurnUsage(adapter, session, promptResult);
|
|
903
1411
|
finalizeAcpStreamState(state);
|
|
904
1412
|
const verdict = mapPromptStopReason(promptResult?.stopReason);
|
|
905
1413
|
if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
|
|
@@ -921,14 +1429,24 @@ export function streamAcpTurn(
|
|
|
921
1429
|
stream.end();
|
|
922
1430
|
}
|
|
923
1431
|
|
|
924
|
-
function finishError(
|
|
1432
|
+
function finishError(
|
|
1433
|
+
err: unknown,
|
|
1434
|
+
aborted: boolean,
|
|
1435
|
+
stderrTail?: string[],
|
|
1436
|
+
lifecycle?: string,
|
|
1437
|
+
launchObservation?: AcpLaunchObservation,
|
|
1438
|
+
): void {
|
|
925
1439
|
finalizeAcpStreamState(state);
|
|
926
1440
|
state.output.stopReason = aborted ? "aborted" : "error";
|
|
927
1441
|
const base = err instanceof Error ? err.message : String(err);
|
|
928
1442
|
// The FIRST failure stays first and verbatim (it is what the backend
|
|
929
1443
|
// actually said); the lifecycle line is added, never substituted, so a
|
|
930
1444
|
// reader can still match the transport's own text.
|
|
931
|
-
const
|
|
1445
|
+
const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
|
|
1446
|
+
// Its OWN line, above the vendor tail: an entwurf-owned observation must not
|
|
1447
|
+
// have to be recovered by reading vendor prose.
|
|
1448
|
+
const observed = launchSignalLine(launchObservation);
|
|
1449
|
+
const diagnosed = observed ? `${withLifecycle}\n${observed}` : withLifecycle;
|
|
932
1450
|
const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
|
|
933
1451
|
const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
|
|
934
1452
|
// A-c: a real failure (not an abort) that looks like a context-window
|
|
@@ -1100,7 +1618,7 @@ export function streamAcpTurn(
|
|
|
1100
1618
|
inFlightKeys.add(sessionKey);
|
|
1101
1619
|
try {
|
|
1102
1620
|
if (decision.path === "reuse" && existing) {
|
|
1103
|
-
await runReuseTurn(existing, ctxSigs);
|
|
1621
|
+
await runReuseTurn(existing, ctxSigs, adapter);
|
|
1104
1622
|
} else {
|
|
1105
1623
|
await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
|
|
1106
1624
|
}
|
|
@@ -1124,6 +1642,8 @@ export function streamAcpTurn(
|
|
|
1124
1642
|
/** Which phase a failure belongs to — flipped once the prompt is on the wire. */
|
|
1125
1643
|
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1126
1644
|
const stderrTail: string[] = [];
|
|
1645
|
+
// Session-scoped alongside the tail — see the field's note on the session type.
|
|
1646
|
+
const launchObservation: AcpLaunchObservation = {};
|
|
1127
1647
|
const sessionKey = resolveSessionKey(opts, cwd);
|
|
1128
1648
|
try {
|
|
1129
1649
|
if (signal?.aborted) throw new Error("aborted before launch");
|
|
@@ -1156,10 +1676,18 @@ export function streamAcpTurn(
|
|
|
1156
1676
|
const spawned = child;
|
|
1157
1677
|
|
|
1158
1678
|
// Drain stderr (an unconsumed pipe can backpressure-deadlock a long turn).
|
|
1159
|
-
|
|
1160
|
-
|
|
1679
|
+
// The launcher's own control frames are consumed here and kept OUT of the
|
|
1680
|
+
// tail: the tail is vendor evidence, the observation is an entwurf fact,
|
|
1681
|
+
// and mixing the two is the overloading #72 was made of.
|
|
1682
|
+
const consumeStderr = makeLaunchFrameFilter(launchObservation, (text) => {
|
|
1683
|
+
stderrTail.push(text);
|
|
1161
1684
|
if (stderrTail.length > 50) stderrTail.shift();
|
|
1162
1685
|
});
|
|
1686
|
+
spawned.stderr.on("data", (c: Buffer) => consumeStderr.write(c.toString()));
|
|
1687
|
+
// `close` rather than `end`: it covers the destroy path our own teardown
|
|
1688
|
+
// takes, and on the EOF-first death it lands inside the post-mortem
|
|
1689
|
+
// settle window, so the flushed fragment is in the tail before we seal.
|
|
1690
|
+
spawned.stderr.once?.("close", () => consumeStderr.flush());
|
|
1163
1691
|
|
|
1164
1692
|
// Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
|
|
1165
1693
|
// there is no prompt turn for the agent to cancel yet, so the child is
|
|
@@ -1211,6 +1739,8 @@ export function streamAcpTurn(
|
|
|
1211
1739
|
// this turn with the session, so a later reuse turn can still report
|
|
1212
1740
|
// the child's dying words.
|
|
1213
1741
|
stderrTail,
|
|
1742
|
+
// SAME object the frame filter writes into, for the same reason.
|
|
1743
|
+
launchObservation,
|
|
1214
1744
|
// Armed at spawn, before ANY turn can fail on this child — the latch
|
|
1215
1745
|
// must already exist when the `exit` listener below can fire.
|
|
1216
1746
|
childEnd: makeChildEndLatch(),
|
|
@@ -1314,18 +1844,42 @@ export function streamAcpTurn(
|
|
|
1314
1844
|
|
|
1315
1845
|
session.activePromptHandler = undefined;
|
|
1316
1846
|
session.busy = false;
|
|
1317
|
-
finishSuccess(promptResult);
|
|
1318
1847
|
|
|
1319
1848
|
// Retain ONLY a long-lived process-scoped session that survived the turn
|
|
1320
1849
|
// alive and un-aborted. A turn-scoped one-shot (and any aborted/dead
|
|
1321
1850
|
// turn) tears down so its stdio handle cannot pin pi's exit (S2c hang).
|
|
1322
|
-
|
|
1851
|
+
const retain = params.lifecyclePolicy === "process-scoped" && !signal?.aborted && session.alive;
|
|
1852
|
+
|
|
1853
|
+
// DISCOVERABILITY BEFORE THE SEAL. `finishSuccess` ends the stream, and
|
|
1854
|
+
// ending the stream is what releases the caller — which may start the next
|
|
1855
|
+
// turn on that event. A completed process-scoped session that is not yet in
|
|
1856
|
+
// the map would send that turn down the NEW path: a second child, and with
|
|
1857
|
+
// it a FRESH cost baseline, so a long session's accounting would silently
|
|
1858
|
+
// reset at a turn boundary (#93).
|
|
1859
|
+
//
|
|
1860
|
+
// Two things make that window empty today — nothing here awaits, and the
|
|
1861
|
+
// turn's in-flight claim is not released until after this returns — but both
|
|
1862
|
+
// are invariants of surrounding code rather than of this ordering, and
|
|
1863
|
+
// neither is visible from this line. Registering first makes the guarantee
|
|
1864
|
+
// local: the session is discoverable before anything can act on the seal,
|
|
1865
|
+
// whatever the code around it later does.
|
|
1866
|
+
if (retain) {
|
|
1323
1867
|
bridgeSessions.set(sessionKey, session);
|
|
1324
1868
|
retainedChildren.add(spawned);
|
|
1325
1869
|
registerGlobalCleanup();
|
|
1326
1870
|
// unref so the retained stdio cannot pin pi's exit at resident
|
|
1327
1871
|
// shutdown — reuse is unaffected (unref ≠ destroy). GPT amber.
|
|
1328
1872
|
unrefRetainedChild(spawned);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
finishSuccess(adapter, session, promptResult);
|
|
1876
|
+
|
|
1877
|
+
if (retain) {
|
|
1878
|
+
// AFTER the seal, deliberately: persisting is bookkeeping about a turn
|
|
1879
|
+
// that already answered, so a failed record write must not convert an
|
|
1880
|
+
// answered turn into an error turn. If it throws, the catch below still
|
|
1881
|
+
// un-registers this session and tears its child down — the same cleanup
|
|
1882
|
+
// a sealing exception gets.
|
|
1329
1883
|
persistRecord(session, deps);
|
|
1330
1884
|
} else {
|
|
1331
1885
|
session.retiring = true;
|
|
@@ -1345,7 +1899,13 @@ export function streamAcpTurn(
|
|
|
1345
1899
|
// own SIGTERM; the tail collected by then is sealed before our cleanup
|
|
1346
1900
|
// touches the stderr pipe.
|
|
1347
1901
|
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1348
|
-
finishError(
|
|
1902
|
+
finishError(
|
|
1903
|
+
err,
|
|
1904
|
+
aborted,
|
|
1905
|
+
session?.stderrTail ?? stderrTail,
|
|
1906
|
+
lifecycle,
|
|
1907
|
+
session?.launchObservation ?? launchObservation,
|
|
1908
|
+
);
|
|
1349
1909
|
// error/abort → drop the (uncertain) session and close its child; an
|
|
1350
1910
|
// uncertain connection must never be reused (GPT ④).
|
|
1351
1911
|
if (child) {
|
|
@@ -1360,7 +1920,7 @@ export function streamAcpTurn(
|
|
|
1360
1920
|
}
|
|
1361
1921
|
|
|
1362
1922
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1363
|
-
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
1923
|
+
async function runReuseTurn(session: BridgeSession, ctxSigs: string[], adapter: AcpBackendAdapter): Promise<void> {
|
|
1364
1924
|
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1365
1925
|
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1366
1926
|
try {
|
|
@@ -1396,7 +1956,7 @@ export function streamAcpTurn(
|
|
|
1396
1956
|
// prefix-compat check sees the full prior history (GPT ④: store the
|
|
1397
1957
|
// ctxSigs from the START of this call, only after the turn succeeds).
|
|
1398
1958
|
session.contextMessageSignatures = ctxSigs;
|
|
1399
|
-
finishSuccess(promptResult);
|
|
1959
|
+
finishSuccess(adapter, session, promptResult);
|
|
1400
1960
|
persistRecord(session, deps);
|
|
1401
1961
|
} catch (err) {
|
|
1402
1962
|
const aborted = Boolean(signal?.aborted);
|
|
@@ -1412,7 +1972,7 @@ export function streamAcpTurn(
|
|
|
1412
1972
|
// Without the session-scoped tail a mid-turn child death on a resident
|
|
1413
1973
|
// session surfaced as a bare "ACP connection closed" with nothing to read
|
|
1414
1974
|
// it by.
|
|
1415
|
-
finishError(err, aborted, session.stderrTail, lifecycle);
|
|
1975
|
+
finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
|
|
1416
1976
|
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1417
1977
|
session.retiring = true;
|
|
1418
1978
|
retainedChildren.delete(session.child);
|