@junghanacs/entwurf 0.16.1 → 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 +4 -2
- package/CHANGELOG.md +212 -0
- package/VERIFY.md +8 -1
- package/docs/acp-backend-rail.md +25 -14
- 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 +15 -0
- package/package.json +4 -4
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +59 -0
- package/pi-extensions/lib/acp/backend.ts +453 -11
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/run.sh +21 -0
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -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
|
@@ -804,6 +804,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
804
804
|
"acp-prompt-lifecycle": 15,
|
|
805
805
|
"acp-stop-reason": 6,
|
|
806
806
|
"acp-stream-hooks": 10,
|
|
807
|
+
"acp-usage-accounting": 12,
|
|
807
808
|
"agy-permission": 6,
|
|
808
809
|
"bridge-boot-resume": 3,
|
|
809
810
|
"bridge-command-boot": 9,
|
|
@@ -16,13 +16,60 @@
|
|
|
16
16
|
* runs of the acp socket smokes).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
19
20
|
import * as fsp from "node:fs/promises";
|
|
21
|
+
import * as os from "node:os";
|
|
20
22
|
import * as path from "node:path";
|
|
21
23
|
|
|
22
24
|
function sleep(ms: number): Promise<void> {
|
|
23
25
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* pi's own cross-process locks. `FileAuthStorageBackend` (pi `dist/core/auth-storage.js`) guards
|
|
30
|
+
* `auth.json` AND `models-store.json` with `proper-lockfile`, and every boot reads through it, so
|
|
31
|
+
* these two directories are the ones a killed resident can leave behind.
|
|
32
|
+
*/
|
|
33
|
+
const PI_LOCK_PATHS = ["auth.json.lock", "models-store.json.lock"].map((name) =>
|
|
34
|
+
path.join(os.homedir(), ".pi", "agent", name),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The bound a LIVE smoke must give a `pi` boot — and the reason it is NOT "how long a boot takes".
|
|
39
|
+
*
|
|
40
|
+
* MEASURED 2026-09-03 on the release host, in the smokes' own spawn shape:
|
|
41
|
+
* - an undisturbed boot → V3 record is **1008–1212ms across 80 consecutive boots** (5.1–5.4s under
|
|
42
|
+
* 4× CPU oversubscription), so a bound in the tens of seconds is not about boot cost;
|
|
43
|
+
* - pi reads its auth/models store under a `proper-lockfile` lock whose stale window is
|
|
44
|
+
* `staleMs = 30_000` (pi `dist/core/auth-storage.js`), and a contender that finds the lock held
|
|
45
|
+
* retries for exactly that long before taking it over;
|
|
46
|
+
* - SIGTERM lands inside that window often enough to matter — sweeping 24 kill offsets across a
|
|
47
|
+
* boot left `~/.pi/agent/models-store.json.lock` orphaned once (at +375ms), because the signal
|
|
48
|
+
* ends the process before `proper-lockfile`'s release ever runs;
|
|
49
|
+
* - with such an orphan present, the very next boot measured **30_148ms** (and 1_114ms immediately
|
|
50
|
+
* after, once the stale takeover had cleared it).
|
|
51
|
+
*
|
|
52
|
+
* So a 30_000 bound is the single worst value available: it expires 148ms INSIDE the takeover, and
|
|
53
|
+
* the record lands just after the smoke has stopped looking — an empty stderr, a live child, and no
|
|
54
|
+
* record in any store. That is what blocked two of the three 0.17.0 `--cut` runs on C1b, while
|
|
55
|
+
* `smoke-entwurf-chain-live` — same two-resident dance, 45_000 — passed all three. This constant is
|
|
56
|
+
* that value, shared so no smoke sits on the cliff again. Raise it if pi's `staleMs` ever grows;
|
|
57
|
+
* it must stay strictly greater than that window plus a boot.
|
|
58
|
+
*/
|
|
59
|
+
export const PI_BOOT_TIMEOUT_MS = 45_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which pi locks are on disk right now, for a failure diagnostic — a boot that overran its bound
|
|
63
|
+
* while one of these exists overran it for a NAMED reason, not a mysterious one. Read-only: a lock
|
|
64
|
+
* is arbitrated by pi's own stale protocol and must never be deleted by a smoke, because a live
|
|
65
|
+
* holder and an orphan look identical from here.
|
|
66
|
+
*/
|
|
67
|
+
export function describePiLockResidue(): string {
|
|
68
|
+
const present = PI_LOCK_PATHS.filter((lock) => existsSync(lock));
|
|
69
|
+
if (present.length === 0) return "none held (so a boot overrun here is not the pi lock-stale window)";
|
|
70
|
+
return `${present.join(", ")} — a boot contending with this waits out pi's ${30_000}ms stale window before taking over`;
|
|
71
|
+
}
|
|
72
|
+
|
|
26
73
|
export async function waitForPiRecord(storeDir: string, timeoutMs: number, pollMs = 100): Promise<string | null> {
|
|
27
74
|
const deadline = Date.now() + timeoutMs;
|
|
28
75
|
while (Date.now() < deadline) {
|
|
@@ -144,9 +144,11 @@
|
|
|
144
144
|
"claim": "ACPHOOK-ONRESPONSE-NEVER-CALLED",
|
|
145
145
|
"title": "someone 'completes' the hook contract by fabricating an HTTP 200 response on turn success — exactly the false evidence the exemption exists to forbid",
|
|
146
146
|
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
147
|
-
"find": [
|
|
147
|
+
"find": [
|
|
148
|
+
"\tfunction finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {"
|
|
149
|
+
],
|
|
148
150
|
"replace": [
|
|
149
|
-
"\tfunction finishSuccess(
|
|
151
|
+
"\tfunction finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {",
|
|
150
152
|
"\t\tvoid options?.onResponse?.({ status: 200, headers: {} }, model);"
|
|
151
153
|
],
|
|
152
154
|
"gate": ["bash", "run.sh", "check-acp-stream-hooks"],
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "acp-usage-accounting",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "ACP-TURN-AGGREGATE-NOT-PROJECTED",
|
|
7
|
+
"title": "the turn's ROUND-TRIP AGGREGATE is projected back onto pi's four per-request usage fields — re-plants the exact 2026-09-02 defect: pi's own isContextOverflow then read 42 + 4,185,084 against a 1,000,000 window and compacted a live session whose context was 223,516",
|
|
8
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
9
|
+
"find": ["\t\tsealTurnUsage(adapter, session, promptResult);"],
|
|
10
|
+
"replace": [
|
|
11
|
+
"\t\tsealTurnUsage(adapter, session, promptResult);",
|
|
12
|
+
"\t\tconst reprojected = promptResult?.usage;",
|
|
13
|
+
"\t\tif (adapter.sealsTurnAccounting && reprojected) {",
|
|
14
|
+
"\t\t\tstate.output.usage.input = reprojected.inputTokens ?? 0;",
|
|
15
|
+
"\t\t\tstate.output.usage.output = reprojected.outputTokens ?? 0;",
|
|
16
|
+
"\t\t\tstate.output.usage.cacheRead = reprojected.cachedReadTokens ?? 0;",
|
|
17
|
+
"\t\t\tstate.output.usage.cacheWrite = reprojected.cachedWriteTokens ?? 0;",
|
|
18
|
+
"\t\t}"
|
|
19
|
+
],
|
|
20
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
21
|
+
"timeoutSeconds": 240,
|
|
22
|
+
"signature": "[QK:ACP-TURN-AGGREGATE-NOT-PROJECTED]",
|
|
23
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"claim": "ACP-CONTEXT-OCCUPANCY-PRESERVED",
|
|
27
|
+
"title": "the seal stops writing the session's context occupancy into totalTokens — pi's calculateContextTokens then falls through to summing the four (all zero) usage fields, so the status-line percentage and auto-compaction read a long session as empty",
|
|
28
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
29
|
+
"find": [
|
|
30
|
+
"\t\tif (typeof session.contextOccupancyTokens === \"number\") {",
|
|
31
|
+
"\t\t\tstate.output.usage.totalTokens = session.contextOccupancyTokens;",
|
|
32
|
+
"\t\t}"
|
|
33
|
+
],
|
|
34
|
+
"replace": [
|
|
35
|
+
"\t\tif (typeof session.contextOccupancyTokens === \"number\") {",
|
|
36
|
+
"\t\t\tstate.output.usage.totalTokens = 0;",
|
|
37
|
+
"\t\t}"
|
|
38
|
+
],
|
|
39
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
40
|
+
"timeoutSeconds": 240,
|
|
41
|
+
"signature": "[QK:ACP-CONTEXT-OCCUPANCY-PRESERVED]",
|
|
42
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"claim": "ACP-CONTEXT-OCCUPANCY-CARRIED",
|
|
46
|
+
"title": "the seal stops carrying the session's last known context occupancy — a turn whose usage_update never arrived now emits a token partition with a zero totalTokens, so pi falls through to that partition and reads a long session as nearly empty",
|
|
47
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
48
|
+
"find": [
|
|
49
|
+
"\t\tif (typeof occupancy === \"number\") session.contextOccupancyTokens = occupancy;",
|
|
50
|
+
"\t\tif (typeof session.contextOccupancyTokens === \"number\") {",
|
|
51
|
+
"\t\t\tstate.output.usage.totalTokens = session.contextOccupancyTokens;",
|
|
52
|
+
"\t\t}"
|
|
53
|
+
],
|
|
54
|
+
"replace": ["\t\tif (typeof occupancy === \"number\") session.contextOccupancyTokens = occupancy;"],
|
|
55
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
56
|
+
"timeoutSeconds": 240,
|
|
57
|
+
"signature": "[QK:ACP-CONTEXT-OCCUPANCY-CARRIED]",
|
|
58
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"claim": "ACP-TURN-COST-SUM-MATCHES-SDK",
|
|
62
|
+
"title": "the backend's RUNNING SESSION TOTAL is assigned to the turn cost again instead of the adjacent diff — the arithmetic that displayed a $24.261 session as $444.370",
|
|
63
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
64
|
+
"find": [
|
|
65
|
+
"\t\t\t\tsession.sdkCumulativeCostUsd = observed;",
|
|
66
|
+
"\t\t\t\tstate.output.usage.cost.total = diff;",
|
|
67
|
+
"\t\t\t\tturnCostUsd = diff;"
|
|
68
|
+
],
|
|
69
|
+
"replace": [
|
|
70
|
+
"\t\t\t\tsession.sdkCumulativeCostUsd = observed;",
|
|
71
|
+
"\t\t\t\tstate.output.usage.cost.total = observed;",
|
|
72
|
+
"\t\t\t\tturnCostUsd = diff;"
|
|
73
|
+
],
|
|
74
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
75
|
+
"timeoutSeconds": 240,
|
|
76
|
+
"signature": "[QK:ACP-TURN-COST-SUM-MATCHES-SDK]",
|
|
77
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"claim": "ACP-COST-BASELINE-HELD-WHEN-MISSING",
|
|
81
|
+
"title": "a turn with no cost notification zeroes the baseline instead of holding it — the next diff then re-attributes every dollar the session had already spent",
|
|
82
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
83
|
+
"find": [
|
|
84
|
+
"\t\t\tstate.output.usage.cost.total = 0;",
|
|
85
|
+
"\t\t} else {",
|
|
86
|
+
"\t\t\tconst diff = observed - (session.sdkCumulativeCostUsd ?? 0);"
|
|
87
|
+
],
|
|
88
|
+
"replace": [
|
|
89
|
+
"\t\t\tsession.sdkCumulativeCostUsd = 0;",
|
|
90
|
+
"\t\t\tstate.output.usage.cost.total = 0;",
|
|
91
|
+
"\t\t} else {",
|
|
92
|
+
"\t\t\tconst diff = observed - (session.sdkCumulativeCostUsd ?? 0);"
|
|
93
|
+
],
|
|
94
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
95
|
+
"timeoutSeconds": 240,
|
|
96
|
+
"signature": "[QK:ACP-COST-BASELINE-HELD-WHEN-MISSING]",
|
|
97
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"claim": "ACP-COST-RESET-NOT-SILENT",
|
|
101
|
+
"title": "a running total that goes backwards is absorbed silently — the turn reports a negative cost, nobody is told, and the only observation that could settle what a conversation reset does to the total is destroyed",
|
|
102
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
103
|
+
"find": ["\t\tif (diff < 0) {"],
|
|
104
|
+
"replace": ["\t\tif (diff < -1_000_000) {"],
|
|
105
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
106
|
+
"timeoutSeconds": 240,
|
|
107
|
+
"signature": "[QK:ACP-COST-RESET-NOT-SILENT]",
|
|
108
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"claim": "ACP-CORTEX-USAGE-UNTOUCHED",
|
|
112
|
+
"title": "the seal stops being gated on the adapter DECLARING measured semantics — claude's accounting is sealed onto a backend nobody measured, minting the same unmeasured accounting this lane exists to end",
|
|
113
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
114
|
+
"find": ["\t\tif (!adapter.sealsTurnAccounting) return;"],
|
|
115
|
+
"replace": ["\t\tvoid adapter.sealsTurnAccounting;"],
|
|
116
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
117
|
+
"timeoutSeconds": 240,
|
|
118
|
+
"signature": "[QK:ACP-CORTEX-USAGE-UNTOUCHED]",
|
|
119
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"claim": "ACP-TURN-ACCOUNTING-ATTACHED",
|
|
123
|
+
"title": "the vendor's four turn totals stop riding their own key — pi's four fields are 0 and nothing else carries the numbers, so the cache-effect badge has no inputs and a paid-for prefix rewrite reaches the operator as silence",
|
|
124
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
125
|
+
"find": ["\t\t\t(state.output.usage as unknown as { acp?: AcpTurnAccounting }).acp = aggregate;"],
|
|
126
|
+
"replace": ["\t\t\tvoid aggregate;"],
|
|
127
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
128
|
+
"timeoutSeconds": 240,
|
|
129
|
+
"signature": "[QK:ACP-TURN-ACCOUNTING-ATTACHED]",
|
|
130
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"claim": "ACP-CACHE-REBILL-REPORTED",
|
|
134
|
+
"title": "the re-billed-prefix bound drops its ΣcacheWrite term, so a full prefix rewrite after an idle gap no longer clears the notice floor — the operator runs on a cache-effect badge while having already paid to rewrite the whole prefix",
|
|
135
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
136
|
+
"find": [
|
|
137
|
+
"\t\t\t? (priorOccupancy as number) - (priorIoSum as number) - Math.max(0, (occupancy as number) - cacheWriteForBound)"
|
|
138
|
+
],
|
|
139
|
+
"replace": ["\t\t\t? (priorOccupancy as number) - (priorIoSum as number) - Math.max(0, occupancy as number)"],
|
|
140
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
141
|
+
"timeoutSeconds": 240,
|
|
142
|
+
"signature": "[QK:ACP-CACHE-REBILL-REPORTED]",
|
|
143
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"claim": "ACP-REBILL-NEVER-EXCEEDS-WRITE",
|
|
147
|
+
"title": "the re-billed claim loses its physical cap, so a context shrink (organic compaction) announces a six-figure cache miss over a turn that wrote a thousand tokens — a fact the operator has no way to disbelieve",
|
|
148
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
149
|
+
"find": [
|
|
150
|
+
"\t\tconst missLowerBound = rawBound === undefined ? undefined : Math.min(rawBound, cacheWriteForBound);"
|
|
151
|
+
],
|
|
152
|
+
"replace": ["\t\tconst missLowerBound = rawBound;"],
|
|
153
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
154
|
+
"timeoutSeconds": 240,
|
|
155
|
+
"signature": "[QK:ACP-REBILL-NEVER-EXCEEDS-WRITE]",
|
|
156
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"claim": "ACP-ACCOUNTING-PREFERS-WIDEST",
|
|
160
|
+
"title": "the accounting-grade model_usage rows stop being preferred, so the turn silently reports MAIN-LOOP-ONLY tokens against an all-inclusive cost denominator — the cache-effect badge understates itself exactly when compaction or a subagent ran, and nothing says so",
|
|
161
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
162
|
+
"find": ["\tif (Array.isArray(rows) && rows.length > 0) {"],
|
|
163
|
+
"replace": ["\tif (Array.isArray(rows) && rows.length > 99) {"],
|
|
164
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
165
|
+
"timeoutSeconds": 240,
|
|
166
|
+
"signature": "[QK:ACP-ACCOUNTING-PREFERS-WIDEST]",
|
|
167
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"claim": "ACP-REBILL-MAIN-LOOP-SCOPE",
|
|
171
|
+
"title": "the re-billed bound takes cacheWrite from the WIDE accounting rows instead of the main loop, so a warm main prefix announces a six-figure miss (and this turn's dollar figure) because an internal/compaction call wrote extra cache — mixing occupancy's scope with model_usage's",
|
|
172
|
+
"subject": "pi-extensions/lib/acp/backend.ts",
|
|
173
|
+
"find": ["\t\tconst cacheWriteForBound = mainLoop !== undefined ? mainLoop.cacheWrite : 0;"],
|
|
174
|
+
"replace": ["\t\tconst cacheWriteForBound = aggregate !== undefined ? aggregate.cacheWrite : 0;"],
|
|
175
|
+
"gate": ["bash", "run.sh", "check-acp-usage-accounting"],
|
|
176
|
+
"timeoutSeconds": 240,
|
|
177
|
+
"signature": "[QK:ACP-REBILL-MAIN-LOOP-SCOPE]",
|
|
178
|
+
"signatureSource": "scripts/check-acp-usage-accounting.ts"
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
@@ -53,7 +53,7 @@ import { fileURLToPath } from "node:url";
|
|
|
53
53
|
import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensions/lib/entwurf-control-rpc.ts";
|
|
54
54
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
55
55
|
import { skipLive } from "./lib/live-skip.ts";
|
|
56
|
-
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
56
|
+
import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
57
57
|
|
|
58
58
|
const ACP_PROVIDER = "entwurf";
|
|
59
59
|
const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
|
|
@@ -64,7 +64,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
64
64
|
// Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
|
|
65
65
|
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
66
66
|
|
|
67
|
-
const BOOT_TIMEOUT_MS =
|
|
67
|
+
const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
|
|
68
68
|
const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
|
|
69
69
|
const POLL_MS = 100;
|
|
70
70
|
|
|
@@ -54,7 +54,7 @@ import { fileURLToPath } from "node:url";
|
|
|
54
54
|
import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
|
|
55
55
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
56
56
|
import { skipLive } from "./lib/live-skip.ts";
|
|
57
|
-
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
57
|
+
import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
58
58
|
|
|
59
59
|
const ACP_PROVIDER = "entwurf";
|
|
60
60
|
const ACP_MODEL = process.env.ENTWURF_ACP_CORTEX_MODEL?.trim() || "cortex-claude-sonnet-5";
|
|
@@ -66,7 +66,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
66
66
|
// Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
|
|
67
67
|
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
68
68
|
|
|
69
|
-
const BOOT_TIMEOUT_MS =
|
|
69
|
+
const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
|
|
70
70
|
// The cortex CLI self-extracts on first launch and its newSession alone is
|
|
71
71
|
// ~2.5 s; keep the generous default of the sibling smoke.
|
|
72
72
|
const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_CORTEX_TIMEOUT_MS) || 300_000;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// LIVE=1 ./run.sh smoke-acp-raw-turn-live
|
|
4
4
|
//
|
|
5
5
|
// What this proves (and ONLY this): the pinned Claude ACP adapter
|
|
6
|
-
// (@agentclientprotocol/claude-agent-acp@0.
|
|
6
|
+
// (@agentclientprotocol/claude-agent-acp@0.73.0) spawns, speaks the ACP wire
|
|
7
7
|
// protocol over stdio NDJSON, and returns one real model turn. It is the
|
|
8
8
|
// bytes-flow proof that the S2a dep surface is not just installable but
|
|
9
9
|
// actually drivable — before any provider/overlay/streamSimple code (S2b+).
|
|
@@ -32,7 +32,7 @@ import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensi
|
|
|
32
32
|
import { scanSocketProbes } from "../pi-extensions/lib/socket-discovery.ts";
|
|
33
33
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
34
34
|
import { skipLive } from "./lib/live-skip.ts";
|
|
35
|
-
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
35
|
+
import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
36
36
|
|
|
37
37
|
const ACP_PROVIDER = "entwurf";
|
|
38
38
|
const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-5";
|
|
@@ -43,7 +43,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
43
43
|
// Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
|
|
44
44
|
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
45
45
|
|
|
46
|
-
const BOOT_TIMEOUT_MS =
|
|
46
|
+
const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
|
|
47
47
|
const POLL_MS = 100;
|
|
48
48
|
|
|
49
49
|
let passed = 0;
|
|
@@ -82,7 +82,7 @@ import { fileURLToPath } from "node:url";
|
|
|
82
82
|
import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
|
|
83
83
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
84
84
|
import { skipLive } from "./lib/live-skip.ts";
|
|
85
|
-
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
85
|
+
import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
86
86
|
|
|
87
87
|
const ACP_PROVIDER = "entwurf";
|
|
88
88
|
const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
|
|
@@ -93,7 +93,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
93
93
|
// Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
|
|
94
94
|
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
95
95
|
|
|
96
|
-
const BOOT_TIMEOUT_MS =
|
|
96
|
+
const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
|
|
97
97
|
const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
|
|
98
98
|
const POLL_MS = 100;
|
|
99
99
|
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
} from "../pi-extensions/lib/meta-session.ts";
|
|
66
66
|
import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
67
67
|
import { skipLive } from "./lib/live-skip.ts";
|
|
68
|
-
import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
68
|
+
import { describePiLockResidue, PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
|
|
69
69
|
|
|
70
70
|
// pi's control socket lives at the canonical dir keyed by the RECORD's garden id (#50 C4: the
|
|
71
71
|
// record is the sole address authority — never a transcript/session id; :196 below proves it),
|
|
@@ -78,7 +78,14 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
78
78
|
const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
|
|
79
79
|
|
|
80
80
|
// Staged timeouts (automation): short and per-stage so a stall is attributable.
|
|
81
|
-
|
|
81
|
+
//
|
|
82
|
+
// The boot bound is SHARED and carries its own receipt (see `PI_BOOT_TIMEOUT_MS` in
|
|
83
|
+
// pi-record-discovery). Short version, because this smoke is where it was found: this cell used to
|
|
84
|
+
// wait 30_000ms, which is EXACTLY pi's `proper-lockfile` stale window, and C1's own SIGTERM can
|
|
85
|
+
// orphan that lock — so C1b's resident spent the whole wait queueing behind it and birthed its
|
|
86
|
+
// record at a measured 30_148ms, 148ms after the smoke had stopped looking. That is what blocked
|
|
87
|
+
// two of the three 0.17.0 `--cut` runs.
|
|
88
|
+
const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // pi --entwurf-control record + socket appear
|
|
82
89
|
const POLL_MS = 100;
|
|
83
90
|
|
|
84
91
|
let passed = 0;
|
|
@@ -90,6 +97,43 @@ function ok(label: string, cond: boolean): void {
|
|
|
90
97
|
passed++;
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/** What a spawned resident DID while the smoke waited on it — the fact a bare boot timeout
|
|
101
|
+
* throws away. Each resident owns its own stderr here: a single shared tail cannot say which
|
|
102
|
+
* of the two children spoke, and the C1b post-mortem is exactly that question. */
|
|
103
|
+
interface ResidentWatch {
|
|
104
|
+
label: string;
|
|
105
|
+
pid: number | undefined;
|
|
106
|
+
startedAt: number;
|
|
107
|
+
exit: string | null;
|
|
108
|
+
stderr: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function watchResident(child: ChildProcess, label: string): ResidentWatch {
|
|
112
|
+
const watch: ResidentWatch = { label, pid: child.pid, startedAt: Date.now(), exit: null, stderr: "" };
|
|
113
|
+
child.once("exit", (code, signal) => {
|
|
114
|
+
watch.exit = `code=${code} signal=${signal} at +${Date.now() - watch.startedAt}ms`;
|
|
115
|
+
});
|
|
116
|
+
child.stderr?.on("data", (b: Buffer) => {
|
|
117
|
+
watch.stderr = (watch.stderr + b.toString()).slice(-2000);
|
|
118
|
+
});
|
|
119
|
+
return watch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read at FAILURE time, before the `finally` reaps anything: a resident that is still alive
|
|
123
|
+
* having birthed nothing is a different defect from one that exited silently, and "no pid" is a
|
|
124
|
+
* third (the spawn itself never took). */
|
|
125
|
+
function describeResident(watch: ResidentWatch): string {
|
|
126
|
+
const waited = `${Date.now() - watch.startedAt}ms after spawn`;
|
|
127
|
+
if (watch.pid === undefined) return `never acquired a pid — the spawn did not take (${waited})`;
|
|
128
|
+
if (watch.exit) return `pid=${watch.pid} EXITED ${watch.exit} — it was gone before the wait ended`;
|
|
129
|
+
try {
|
|
130
|
+
process.kill(watch.pid, 0);
|
|
131
|
+
return `pid=${watch.pid} still ALIVE ${waited} and had birthed no record`;
|
|
132
|
+
} catch {
|
|
133
|
+
return `pid=${watch.pid} is GONE with no exit event seen (reaped elsewhere), ${waited}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
function resolveTarget(): { provider: string; model: string } {
|
|
94
138
|
const combined = process.env.ENTWURF_LIVE_TARGET?.trim();
|
|
95
139
|
if (combined) {
|
|
@@ -153,7 +197,7 @@ async function main(): Promise<void> {
|
|
|
153
197
|
let resident: ChildProcess | null = null;
|
|
154
198
|
let residentGid = "";
|
|
155
199
|
let c1bGid = "";
|
|
156
|
-
|
|
200
|
+
const residents: ResidentWatch[] = [];
|
|
157
201
|
let succeeded = false;
|
|
158
202
|
|
|
159
203
|
const prodDeps = (sender: SenderEnvelope) =>
|
|
@@ -182,9 +226,7 @@ async function main(): Promise<void> {
|
|
|
182
226
|
[...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", provider, "--model", model, "--mode", "rpc"],
|
|
183
227
|
{ cwd: tmp, stdio: ["pipe", "ignore", "pipe"], detached: false },
|
|
184
228
|
);
|
|
185
|
-
|
|
186
|
-
stderrTail = (stderrTail + b.toString()).slice(-2000);
|
|
187
|
-
});
|
|
229
|
+
residents.push(watchResident(resident, "C1"));
|
|
188
230
|
|
|
189
231
|
const bornGid = await waitForPiRecord(sessionsDir, BOOT_TIMEOUT_MS);
|
|
190
232
|
ok("C1 the resident BIRTHED its own V3 backend:pi record (the address authority)", bornGid !== null);
|
|
@@ -241,9 +283,7 @@ async function main(): Promise<void> {
|
|
|
241
283
|
env: { ...process.env, ENTWURF_META_SESSIONS_DIR: hiddenStore },
|
|
242
284
|
},
|
|
243
285
|
);
|
|
244
|
-
|
|
245
|
-
stderrTail = (stderrTail + b.toString()).slice(-2000);
|
|
246
|
-
});
|
|
286
|
+
residents.push(watchResident(resident, "C1b"));
|
|
247
287
|
|
|
248
288
|
const bornGid = await waitForPiRecord(hiddenStore, BOOT_TIMEOUT_MS);
|
|
249
289
|
ok("C1b the resident birthed its record into the HIDDEN store", bornGid !== null);
|
|
@@ -358,7 +398,17 @@ async function main(): Promise<void> {
|
|
|
358
398
|
} catch (err) {
|
|
359
399
|
console.error("\n[smoke-entwurf-v2-matrix-live] FAILED — diagnostic artifacts:");
|
|
360
400
|
for (const [k, v] of Object.entries(artifacts)) console.error(` ${k} = ${v}`);
|
|
361
|
-
|
|
401
|
+
// Probed HERE, not in `finally`: the reaper runs after this block, so this is the last
|
|
402
|
+
// moment the residents' real state can still be read. An empty stderr is itself evidence
|
|
403
|
+
// and is printed as "(empty)" rather than skipped — silence that is never stated reads as
|
|
404
|
+
// a missing diagnostic instead of the observation it is.
|
|
405
|
+
console.error(` pi locks held right now: ${describePiLockResidue()}`);
|
|
406
|
+
for (const watch of residents) {
|
|
407
|
+
console.error(` resident ${watch.label}: ${describeResident(watch)}`);
|
|
408
|
+
console.error(
|
|
409
|
+
` resident ${watch.label} stderr: ${watch.stderr ? `\n${watch.stderr.replace(/^/gm, " ")}` : "(empty)"}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
362
412
|
throw err;
|
|
363
413
|
} finally {
|
|
364
414
|
if (resident) {
|
|
@@ -213,6 +213,46 @@ function pidIsAlive(pid: number): boolean {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* What the launched window actually shows, read at the moment a nonce callback never arrived.
|
|
218
|
+
*
|
|
219
|
+
* The launch receipt says so itself — "if it never comes, the window is visible and can be read
|
|
220
|
+
* directly" — but on a headless release-gate run nobody is there to look, and the window is torn
|
|
221
|
+
* down in teardown seconds later. So the harness looks for the operator: is the pane process even
|
|
222
|
+
* alive, and what is painted in it. That separates the three states a bare timeout collapses into
|
|
223
|
+
* one — the runtime never started (or died), it started and is sitting on an error or a prompt, or
|
|
224
|
+
* it is running fine and the model simply did not call the callback tool. A pi-native callback
|
|
225
|
+
* timeout with no window evidence is what blocked the second 0.17.0 `--cut` (and, in the same
|
|
226
|
+
* shape, a 0.16.1 run), leaving nothing to tell those three apart afterwards.
|
|
227
|
+
*
|
|
228
|
+
* Diagnostics must never become the failure: every step is best-effort, and a tmux that cannot
|
|
229
|
+
* answer is reported as its own line rather than thrown.
|
|
230
|
+
*/
|
|
231
|
+
function paneForensics(label: string, socket: string, env: NodeJS.ProcessEnv, coords: Coordinates): string {
|
|
232
|
+
const lines: string[] = [`--- ${label} window forensics (the callback never came) ---`];
|
|
233
|
+
try {
|
|
234
|
+
lines.push(`pane ${coords.paneId} pid ${coords.panePid}: ${pidIsAlive(Number(coords.panePid)) ? "ALIVE" : "GONE"}`);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
lines.push(`pane pid liveness unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
237
|
+
}
|
|
238
|
+
const panes = tmux(
|
|
239
|
+
socket,
|
|
240
|
+
["list-panes", "-a", "-F", "#{pane_id} #{pane_pid} #{pane_dead} #{pane_current_command}"],
|
|
241
|
+
env,
|
|
242
|
+
);
|
|
243
|
+
lines.push(
|
|
244
|
+
panes.status === 0 ? `list-panes:\n${panes.stdout || "(none)"}` : `list-panes failed (status ${panes.status})`,
|
|
245
|
+
);
|
|
246
|
+
const captured = tmux(socket, ["capture-pane", "-p", "-t", coords.paneId], env);
|
|
247
|
+
if (captured.status === 0) {
|
|
248
|
+
const tail = captured.stdout.split("\n").slice(-40).join("\n");
|
|
249
|
+
lines.push(`capture-pane (last 40 lines):\n${tail || "(the pane painted nothing)"}`);
|
|
250
|
+
} else {
|
|
251
|
+
lines.push(`capture-pane failed (status ${captured.status}) — the window is already gone`);
|
|
252
|
+
}
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
216
256
|
async function waitForPidsGone(pids: ReadonlySet<number>, timeoutMs = 10_000): Promise<boolean> {
|
|
217
257
|
const deadline = Date.now() + timeoutMs;
|
|
218
258
|
while (Date.now() < deadline) {
|
|
@@ -704,7 +744,9 @@ async function main(): Promise<void> {
|
|
|
704
744
|
ok(
|
|
705
745
|
`${cell}: the nonce came back and its SENDER ENVELOPE carries a garden id — correlation without asking the sibling`,
|
|
706
746
|
gid !== null && GARDEN_ID.test(gid),
|
|
707
|
-
|
|
747
|
+
gid === null
|
|
748
|
+
? `--- launch receipt ---\n${launch.text}\n${paneForensics(cell, srv.socket, srv.env, coords)}`
|
|
749
|
+
: `--- launch receipt ---\n${launch.text}`,
|
|
708
750
|
);
|
|
709
751
|
const citizen = gid as string;
|
|
710
752
|
siblingGids.add(citizen);
|
|
@@ -936,7 +978,9 @@ async function main(): Promise<void> {
|
|
|
936
978
|
ok(
|
|
937
979
|
"claude-code: the nonce came back and its SENDER ENVELOPE carries a garden id",
|
|
938
980
|
ccGid !== null && GARDEN_ID.test(ccGid),
|
|
939
|
-
|
|
981
|
+
ccGid === null
|
|
982
|
+
? `--- launch receipt ---\n${ccLaunch.text}\n${paneForensics("claude-code", cc.socket, cc.env, ccCoords)}`
|
|
983
|
+
: `--- launch receipt ---\n${ccLaunch.text}`,
|
|
940
984
|
);
|
|
941
985
|
const ccCitizen = ccGid as string;
|
|
942
986
|
siblingGids.add(ccCitizen);
|