@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.
@@ -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": ["\tfunction finishSuccess(promptResult: { stopReason?: string }): void {"],
147
+ "find": [
148
+ "\tfunction finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {"
149
+ ],
148
150
  "replace": [
149
- "\tfunction finishSuccess(promptResult: { stopReason?: string }): void {",
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 = 30_000;
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 = 30_000;
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.70.0) spawns, speaks the ACP wire
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 = 30_000;
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 = 30_000;
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
- const BOOT_TIMEOUT_MS = 30_000; // pi --entwurf-control socket appears
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
- let stderrTail = "";
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
- resident.stderr?.on("data", (b: Buffer) => {
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
- resident.stderr?.on("data", (b: Buffer) => {
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
- if (stderrTail) console.error(` pi stderr (tail):\n${stderrTail.replace(/^/gm, " ")}`);
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
- `--- launch receipt ---\n${launch.text}`,
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
- `--- launch receipt ---\n${ccLaunch.text}`,
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);