@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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Raw measurement — the ACP Claude child that leaves at exit 0 (#72)
|
|
2
|
+
|
|
3
|
+
Receipts for the 2026-09-01 diagnosis pass on
|
|
4
|
+
[#72](https://github.com/junghan0611/entwurf/issues/72). Everything below was read
|
|
5
|
+
off pi session transcripts and vendor `dist/` on the named hosts. Nothing here is
|
|
6
|
+
a repair. The **cause is closed** — see §"ANSWERED": the child is shot by
|
|
7
|
+
`acp-zombie-reaper.service`, a timer installed on oracle for a *different*
|
|
8
|
+
harness's bug. The four retired candidates below stay because they are what
|
|
9
|
+
cleared the ground for it.
|
|
10
|
+
|
|
11
|
+
Evidence grade is on every claim. `measured here` names the file the row came
|
|
12
|
+
from; `read at <path:line>` names source that was read, not run.
|
|
13
|
+
|
|
14
|
+
- `reaper-correlation.py` — lines the anomalous turns up against this host's
|
|
15
|
+
reaper SIGTERM events. Zero events on a host without the unit; that null is
|
|
16
|
+
itself the receipt for the split.
|
|
17
|
+
- `acp-turn-population.py` — run per host, `> acp-pop-<host>.json`. One row per
|
|
18
|
+
ACP-backed assistant turn: outcome class, new-vs-reuse, tool-call count,
|
|
19
|
+
totalTokens, child age, and whether the previous child had already died between
|
|
20
|
+
turns. Re-runnable; the numbers below are its output.
|
|
21
|
+
|
|
22
|
+
## The signature, from logs rather than a screen
|
|
23
|
+
|
|
24
|
+
The #72 handoff carried the signature as quotes read off Termux screenshots. This
|
|
25
|
+
pass re-read it from the host's own transcript, which is the receipt a screenshot
|
|
26
|
+
is not.
|
|
27
|
+
|
|
28
|
+
Measured here — `oracle:~/.pi/agent/sessions/--home-junghan-nixos-config--/2026-08-31T09-50-53-520Z_01a0573a-9a50-78f2-bbb3-d833d5df50b8.jsonl`,
|
|
29
|
+
records **173 / 175 / 177**, field `message.errorMessage`. Byte-identical to the
|
|
30
|
+
screenshots plus one line they cut off (`(Use \`node --trace-warnings ...\`)`).
|
|
31
|
+
|
|
32
|
+
Child transcripts, measured here — `oracle:~/.pi/agent/claude-config-overlay/projects/-home-junghan-nixos-config/`
|
|
33
|
+
`a190b806-91a3-4d74-a760-668ae60d57f2.jsonl`, `c798f097-9b28-432d-af2c-e9f73ef023ce.jsonl`,
|
|
34
|
+
`b40bce08-3556-42fa-b358-76b09212632f.jsonl`. Lifetimes 1177s / 957s / 983s.
|
|
35
|
+
|
|
36
|
+
thinkpad holds **no** sample: all three sessionIds are absent from its
|
|
37
|
+
`~/.pi/agent/sessions` and overlay `projects` (measured here, 2026-09-01).
|
|
38
|
+
|
|
39
|
+
## Three readings the handoff carried that the sources do not support
|
|
40
|
+
|
|
41
|
+
1. **"All three samples are new children, not retained reuse."** Record 173 opens
|
|
42
|
+
with `[acp: reusing live session]` — it is a reuse turn. The `resume=none` in
|
|
43
|
+
the stderr tail is the *child's own spawn line*, and that buffer is
|
|
44
|
+
session-scoped by design, so it outlives the turn and says nothing about it
|
|
45
|
+
(read at `pi-extensions/lib/acp/backend.ts:207-212`, `:1206-1210`). The split
|
|
46
|
+
is 1 reuse + 2 new.
|
|
47
|
+
|
|
48
|
+
2. **"`[tool:start] Terminal` is where it fails."** `Terminal` is the placeholder
|
|
49
|
+
*title* the vendor gives a `Bash` tool call whose `input.command` has not
|
|
50
|
+
finished streaming (read at
|
|
51
|
+
`node_modules/@agentclientprotocol/claude-agent-acp/dist/tools.js:38`). Every
|
|
52
|
+
Bash call opens that way, in successful turns too. The real shape is: the last
|
|
53
|
+
`tool:start` has no matching `tool:done`.
|
|
54
|
+
|
|
55
|
+
3. **"`[Request interrupted by user for tool use]` in the child transcript."**
|
|
56
|
+
It lands 0.2–0.4s *before* pi seals the turn — it is `dispose()`'s abort
|
|
57
|
+
artifact, the same misattribution the 2026-08-20 issue comment already named,
|
|
58
|
+
in its `for tool use` variant. Consequence, not cause.
|
|
59
|
+
|
|
60
|
+
## Four candidate causes, retired by counterexample
|
|
61
|
+
|
|
62
|
+
| candidate | retired by | receipt |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| the `CLAUDE_SDK_CAN_USE_TOOL_SHADOWED` warning | child `a190b806` printed it at spawn (`11:33:24Z`), then delivered a **successful** turn (record 171, `stopReason=stop`, `11:34:32Z`), then died on the next turn (record 173). The tail is the last 1000 chars of the session-scoped buffer and holds only the spawn lines, so nothing else was written across that success. | oracle session jsonl, records 171/173 + child transcript |
|
|
65
|
+
| the `bypassPermissions` config overlay | `~/.pi/agent/claude-config-overlay/settings.json` is **byte-identical** on thinkpad and oracle. thinkpad: 0 exit-0 deaths in 507 turns. The overlay is a constant across the split. | `cat` on each host, 2026-09-01 |
|
|
66
|
+
| context size | oracle **successes** reached `totalTokens` 416111 — above all three failures (382174 / 390831 / 404972). thinkpad successes reach 494431. | population output |
|
|
67
|
+
| tool-call volume in one turn | on oracle no success exceeded 39 tool starts while all four failures were 47/73/61/67 — but thinkpad succeeds at 138, 111, 89, 78. Real correlation inside oracle; refuted as a cause. | population output |
|
|
68
|
+
|
|
69
|
+
Elapsed time has no *constant* — lifetimes 1177s / 957s / 983s (+969.9s for the
|
|
70
|
+
2026-08-20 sample) share no value — but it is the axis after all: every one is
|
|
71
|
+
over the reaper's 900s floor (§"ANSWERED"). Read this row as "no constant", not
|
|
72
|
+
as "age is irrelevant". The 2026-07-30
|
|
73
|
+
`prompt timed out after 600000ms` turns are a **different, already-removed**
|
|
74
|
+
failure and the classifier keeps them in their own bucket so they cannot be
|
|
75
|
+
folded in.
|
|
76
|
+
|
|
77
|
+
## The population
|
|
78
|
+
|
|
79
|
+
| host | ACP turns | EXIT0 mid-turn | died between turns | max starts on a success | max totalTokens on a success | pi |
|
|
80
|
+
|---|---|---|---|---|---|---|
|
|
81
|
+
| oracle (aarch64, 4 cores) | 360 | **4** | **4** | 39 | 416111 | 0.84.3 |
|
|
82
|
+
| thinkpad (x86_64) | 507 | **0** | **0** | 138 | 494431 | 0.84.3 at collection; 0.84.4 since |
|
|
83
|
+
|
|
84
|
+
All four oracle EXIT0 turns are `claude-opus-5` (44 opus-5 turns on that host).
|
|
85
|
+
thinkpad ran 340 opus-5 turns with none. So the model is not the discriminator
|
|
86
|
+
either.
|
|
87
|
+
|
|
88
|
+
**What the split is bounded by, so it is not read as "the host is cursed":** same
|
|
89
|
+
pi (0.84.3 at collection), same node (v24.18.1), same
|
|
90
|
+
`@agentclientprotocol/claude-agent-acp` 0.70.0, same ACP SDK 1.3.0, byte-identical
|
|
91
|
+
overlay. Claude CLI differs by a patch (oracle 2.1.245 / thinkpad 2.1.241) and the
|
|
92
|
+
architecture differs (aarch64 / x86_64). Neither is the discriminator: it is the
|
|
93
|
+
`acp-zombie-reaper` timer that exists only on oracle (§"ANSWERED").
|
|
94
|
+
|
|
95
|
+
The thinkpad rows were collected under pi 0.84.3 and that host is now on 0.84.4,
|
|
96
|
+
so any further thinkpad rows belong in a separate bucket.
|
|
97
|
+
|
|
98
|
+
## Where the exit(0) comes from
|
|
99
|
+
|
|
100
|
+
Read at `node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js:81-95`:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
async function shutdown() { await agent.dispose().catch(...); process.exit(0); }
|
|
104
|
+
connection.closed.then(shutdown);
|
|
105
|
+
process.on("SIGTERM", shutdown);
|
|
106
|
+
process.on("SIGINT", shutdown);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Three paths, one exit code. `exit 0, no signal` is what all three produce, so the
|
|
110
|
+
exit fact `df7525f` preserved cannot by itself say which fired.
|
|
111
|
+
|
|
112
|
+
entwurf's side saw a close carrying **no reason** — a clean stdout EOF rather than
|
|
113
|
+
an errored stream (read at `pi-extensions/lib/acp/backend.ts:355-365`). So the
|
|
114
|
+
child went first; pi did not close it.
|
|
115
|
+
|
|
116
|
+
## ANSWERED (2026-09-01, oracle) — it is SIGTERM, from a timer on this host
|
|
117
|
+
|
|
118
|
+
The open question below is closed. It was not derivable from the artifacts the
|
|
119
|
+
previous pass had; it is derivable from one this pass added — **the host's own
|
|
120
|
+
systemd user journal**, which no reading had opened.
|
|
121
|
+
|
|
122
|
+
Measured here — `journalctl --user -b 0` on oracle, and `reaper-correlation.py`,
|
|
123
|
+
which re-derives the table below from `acp-pop-oracle.json` plus that journal:
|
|
124
|
+
|
|
125
|
+
| turn (UTC) | kind | nearest reap | delta |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| 2026-08-31T11:53:01.452Z | EXIT0 | 11:53:01Z pid=1107102 | **0.5s** |
|
|
128
|
+
| 2026-08-31T12:09:26.272Z | EXIT0 | 12:09:26Z pid=1147382 | **0.3s** |
|
|
129
|
+
| 2026-08-31T12:25:52.596Z | EXIT0 | 12:25:52Z pid=1162114 | **0.6s** |
|
|
130
|
+
| 2026-08-20T09:19:52.107Z | EXIT0 | 09:19:52Z pid=3872971 | **0.1s** |
|
|
131
|
+
| 2026-08-31T06:13:15.421Z | between | 06:00:52Z pid=382085 | 743.4s idle |
|
|
132
|
+
| 2026-08-31T06:35:07.230Z | between | 06:29:52Z pid=413476 | 315.2s idle |
|
|
133
|
+
| 2026-08-31T12:56:18.364Z | between | 12:43:52Z pid=1176076 | 746.4s idle |
|
|
134
|
+
|
|
135
|
+
**The pid closes it.** `(node:<pid>)` in the stderr tail is what node's
|
|
136
|
+
`emitWarning` stamps with the emitting process's OWN pid — so the #72 signature
|
|
137
|
+
carried the child's pid all along. Measured here, by `\(node:(\d+)\)` over the
|
|
138
|
+
same `message.errorMessage` fields, against the journal's reaping lines:
|
|
139
|
+
|
|
140
|
+
| record | node pid in the tail | pid the reaper SIGTERMed | reap (KST) |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| 173 | 1107102 | **1107102** | 20:53:01 |
|
|
143
|
+
| 175 | 1147382 | **1147382** | 21:09:26 |
|
|
144
|
+
| 177 | 1162114 | **1162114** | 21:25:52 |
|
|
145
|
+
|
|
146
|
+
Identical in all three. Timestamp agreement alone would leave coincidence and
|
|
147
|
+
reverse-causation open; pid identity does not. Note what this costs the earlier
|
|
148
|
+
readings: the warning line retired as a candidate cause was, the whole time,
|
|
149
|
+
naming the killer.
|
|
150
|
+
|
|
151
|
+
**All four EXIT0 turns land inside one second of a SIGTERM this host sent on
|
|
152
|
+
purpose.** The three surviving `between` rows are the first turn after a reap
|
|
153
|
+
that killed an idle child — the operator never sees those, pi just opens a new
|
|
154
|
+
one. That is **7 turns carrying 8 flags** (2026-08-20 is EXIT0 and `between` at
|
|
155
|
+
once), not eight turns — the table has seven rows and the prose must match it.
|
|
156
|
+
|
|
157
|
+
**Extended to 12/12 across two boots** (measured by fable, oracle, 2026-09-01,
|
|
158
|
+
independent re-extraction). boot -1's journal survives and covers 2026-07-02 to
|
|
159
|
+
08-16, so the samples that PREDATE this boot close too:
|
|
160
|
+
|
|
161
|
+
| sample | node pid in tail | reap | delta |
|
|
162
|
+
|---|---|---|---|
|
|
163
|
+
| 2026-07-30T11:04:23.555Z — **the sample that opened #72** | 3662184 | Jul 30 20:04:23 | 0.555s |
|
|
164
|
+
| 2026-08-16T10:16:51.415Z — the original field report | 501006 | Aug 16 19:16:51 | ~0s |
|
|
165
|
+
| 2026-08-07T08:23:23.559Z (CLOSED-other) | — | 17:23:23 | 0.559s |
|
|
166
|
+
| 2026-08-07T08:41:23.544Z (CLOSED-other) | — | 17:41:23 | 0.544s |
|
|
167
|
+
|
|
168
|
+
So every anomalous termination in the population — EXIT0 4 + between 4 +
|
|
169
|
+
CLOSED-other 4 — corresponds to a reap. **The founding sample and the last
|
|
170
|
+
sample have one cause.** The reaper script's mtime is 2026-04-18, earlier than
|
|
171
|
+
every sample.
|
|
172
|
+
|
|
173
|
+
**Evidence grade, stated honestly.** The rows with a pid in a transcript
|
|
174
|
+
(173/175/177, 07-30, 08-16) are pid+timestamp double-locked. The four
|
|
175
|
+
`between`/prior-child deaths (382085, 413476, 1176076, 3857303) rest on
|
|
176
|
+
**journal alone** — a between-turn death leaves no stderr tail to carry a pid.
|
|
177
|
+
Timestamp, lifetime and mechanism all agree, but that is a weaker grade and is
|
|
178
|
+
labelled as such rather than folded into the locked set.
|
|
179
|
+
|
|
180
|
+
The killer, read at `/home/junghan/openclaw/scripts/acp-zombie-reaper.sh`, driven
|
|
181
|
+
by `~/.config/systemd/user/acp-zombie-reaper.timer` (`OnUnitActiveSec=5min`,
|
|
182
|
+
`ACP_REAPER_AGE_SECS=900`):
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
mapfile -t stale_acp < <(ps -eo pid,etimes,user,args --no-headers |
|
|
186
|
+
awk -v age="$AGE_SECS" '$2 > age && $0 ~ /claude-agent-acp/ ...')
|
|
187
|
+
...
|
|
188
|
+
kill_pid "$pid" TERM # → process.on("SIGTERM", shutdown) → dispose() → exit(0)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
So of the three exit-0 doors, it is **SIGTERM**. Every downstream fact follows:
|
|
192
|
+
exit 0 with no signal recorded (the handler catches it and leaves cleanly), the
|
|
193
|
+
reasonless stdout EOF, and the `[Request interrupted by user...]` line as
|
|
194
|
+
`dispose()`'s abort artifact.
|
|
195
|
+
|
|
196
|
+
**Why nothing in the previous pass could see it.** The reaper matches on the
|
|
197
|
+
process *name*, and entwurf's ACP child carries the same name as the one acpx
|
|
198
|
+
leaks. It is a workaround for a **different harness's** bug (openclaw acpx 0.5.3,
|
|
199
|
+
upstream PR #245) that fires on any `claude-agent-acp` older than 900s. Its own
|
|
200
|
+
comment states the assumption that fails here — *"Legitimate ACP turns complete
|
|
201
|
+
well under 60s"* — which is true of a turn and false of entwurf's **retained**
|
|
202
|
+
child, whose age is the age of the *session*. A session older than 15 minutes is
|
|
203
|
+
shot at, every 5 minutes, forever.
|
|
204
|
+
|
|
205
|
+
This also names the host discriminator the previous pass left open, and retires
|
|
206
|
+
the last of the elapsed-time reading: lifetimes 1177s / 957s / 983s / 969.9s have
|
|
207
|
+
no constant because the axis is not a constant but a **floor** — every one of
|
|
208
|
+
them is over 900. The script lives in `~/openclaw/scripts/`, installed on oracle
|
|
209
|
+
by hand; it is not in `nixos-config`, so thinkpad never had it. That is the whole
|
|
210
|
+
of the 4-vs-0 split. Not the architecture, not the Claude CLI patch level.
|
|
211
|
+
|
|
212
|
+
**What this does NOT say.** It is not an entwurf defect, and the fix is not in
|
|
213
|
+
this repo's runtime: a correctly working child was shot by an unrelated janitor.
|
|
214
|
+
What #72 may still owe is *diagnosis* — the turn's error text says the child
|
|
215
|
+
"ended (exit code 0)" and gives the operator nothing to distinguish a clean
|
|
216
|
+
external kill from a vendor fault. Any such change is still bounded by the
|
|
217
|
+
issue's repair fence (no timeout, no blind replay, no watcher/supervisor/retry).
|
|
218
|
+
Disposition is GLG's.
|
|
219
|
+
|
|
220
|
+
Repair candidates for the reaper itself, none of them taken here (it is another
|
|
221
|
+
project's file on GLG's operating host):
|
|
222
|
+
|
|
223
|
+
- narrow Phase 1 the way Phase 2 already is — it reaps orphan `claude` only when
|
|
224
|
+
`PPID == 1`, while Phase 1 reaps *any* `claude-agent-acp` by age alone. A child
|
|
225
|
+
with a live parent is somebody's working session.
|
|
226
|
+
- raise `ACP_REAPER_AGE_SECS` above a plausible session lifetime (a palliative,
|
|
227
|
+
not a fix — it only moves the floor).
|
|
228
|
+
- retire the unit if acpx PR #245 has landed upstream.
|
|
229
|
+
|
|
230
|
+
## The open question, stated so it is measurable
|
|
231
|
+
|
|
232
|
+
> Which of the three exit-0 paths fires — `connection.closed`, `SIGTERM`, or
|
|
233
|
+
> `SIGINT`?
|
|
234
|
+
|
|
235
|
+
It is not derivable from the artifacts we have. It **is** separable from outside
|
|
236
|
+
by whether a signal was delivered. Any probe for that is a launch-seam change and
|
|
237
|
+
therefore GLG's decision, and it owes its own gate cell plus a mutant that dies
|
|
238
|
+
for the right reason before it is worth anything.
|
|
239
|
+
|
|
240
|
+
That question is answered above (SIGTERM). This section is kept as the record
|
|
241
|
+
of how it was stated before it was closed.
|
|
242
|
+
|
|
243
|
+
## The repair, measured on the host that produced the failure
|
|
244
|
+
|
|
245
|
+
The launcher (`pi-extensions/lib/acp/claude-acp-launch.js`) is a claim about two
|
|
246
|
+
things a unit gate cannot see: what `ps` shows, and what a real signal does.
|
|
247
|
+
Reproduced here on oracle, 2026-09-01 — run it again with the block below.
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
mkfifo /tmp/f
|
|
251
|
+
node pi-extensions/lib/acp/claude-acp-launch.js < /tmp/f 2> /tmp/live.err &
|
|
252
|
+
NPID=$!; exec 3> /tmp/f; sleep 2
|
|
253
|
+
tr '\0' ' ' < /proc/$NPID/cmdline; echo
|
|
254
|
+
# the janitor's own Phase 1 selector, with the age threshold forced to 0
|
|
255
|
+
ps -eo pid,etimes,user,args --no-headers -p $NPID |
|
|
256
|
+
awk -v age=0 '$2 > age && $0 ~ /claude-agent-acp/ && $0 !~ /awk/ { print "MATCH " $1 }'
|
|
257
|
+
kill -TERM $NPID; wait $NPID; echo "exit code: $?"; cat /tmp/live.err
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Output:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
node pi-extensions/lib/acp/claude-acp-launch.js
|
|
264
|
+
<- no MATCH line: the selector finds nothing
|
|
265
|
+
exit code: 0
|
|
266
|
+
ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Three facts, in the order they matter:
|
|
270
|
+
|
|
271
|
+
1. **`/proc/<pid>/cmdline` carries no vendor name.** The vendor runs, but as an
|
|
272
|
+
`import` inside this process — so the string the janitor scans is ours.
|
|
273
|
+
2. **The janitor's own awk, with `age` forced to 0 so every process qualifies,
|
|
274
|
+
selects nothing.** Not "would probably not match": its selector, run against
|
|
275
|
+
the real `ps` row.
|
|
276
|
+
3. **A real SIGTERM still ends at exit 0** — the vendor's handler is intact and
|
|
277
|
+
nothing was made signal-immune — **and the frame is on stderr.** That line is
|
|
278
|
+
what the backend lifts into `launch observed SIGTERM before child exit`, which
|
|
279
|
+
is the fact this whole investigation had to reconstruct from a journal.
|
|
280
|
+
|
|
281
|
+
Independently reproduced by a second session (fable, oracle, 2026-09-01), which
|
|
282
|
+
also confirmed the adjacent paths: stdin EOF alone exits 0 (the `connection.closed`
|
|
283
|
+
door, live), a SIGTERM arriving 0.15s into the import still terminates (the sink
|
|
284
|
+
guard stands down and the re-raised signal lands), and a launcher copied outside
|
|
285
|
+
the repo exits 1 rather than degrading.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Population of ACP turns from a host's pi session transcripts — the #72 receipt.
|
|
3
|
+
|
|
4
|
+
Reads every `~/.pi/agent/sessions/*/*.jsonl` and emits ONE row per assistant turn
|
|
5
|
+
that carried an entwurf ACP lifecycle notice. The point is to compare turns that
|
|
6
|
+
FAILED with a clean child exit(0) against turns that SUCCEEDED on the same host,
|
|
7
|
+
so a candidate cause (context size, tool-call volume, elapsed time, a stderr
|
|
8
|
+
warning) can be retired by a counterexample instead of by argument.
|
|
9
|
+
|
|
10
|
+
The classifier is deliberately narrow:
|
|
11
|
+
|
|
12
|
+
EXIT0 stopReason=error, "ACP connection closed", "exit code 0"
|
|
13
|
+
— the #72 shape: the child left cleanly while owing an answer.
|
|
14
|
+
CLOSED-other same close, no exit fact preserved (pre-df7525f evidence loss).
|
|
15
|
+
ERR-other any other error, INCLUDING the retired 600s `prompt timed out`
|
|
16
|
+
turns. Those are a different, already-removed failure — the
|
|
17
|
+
classifier keeps them separate so they cannot be folded in.
|
|
18
|
+
OK the turn answered.
|
|
19
|
+
|
|
20
|
+
`between` marks a turn whose notice says the PREVIOUS child had already ended at
|
|
21
|
+
exit 0 between turns. Those deaths are invisible to the operator (pi just opens a
|
|
22
|
+
new child), so counting them is what shows that the user-visible failure is a
|
|
23
|
+
RACE with an in-flight prompt, not a distinct failure mode.
|
|
24
|
+
|
|
25
|
+
Run it on each host and diff the two outputs; a cause must survive both.
|
|
26
|
+
|
|
27
|
+
python3 acp-turn-population.py > acp-pop-<host>.json
|
|
28
|
+
"""
|
|
29
|
+
import json, sys, os, glob, datetime
|
|
30
|
+
|
|
31
|
+
rows = []
|
|
32
|
+
for f in glob.glob(os.path.expanduser("~/.pi/agent/sessions/*/*.jsonl")):
|
|
33
|
+
prev_end = None
|
|
34
|
+
spawn = None
|
|
35
|
+
try:
|
|
36
|
+
lines = open(f, encoding="utf-8", errors="replace").readlines()
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
for line in lines:
|
|
40
|
+
# Cheap prefilter: the vast majority of records are not assistant turns.
|
|
41
|
+
if '"assistant"' not in line:
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
o = json.loads(line)
|
|
45
|
+
except ValueError:
|
|
46
|
+
continue
|
|
47
|
+
if o.get("type") != "message":
|
|
48
|
+
continue
|
|
49
|
+
m = o.get("message") or {}
|
|
50
|
+
if m.get("role") != "assistant":
|
|
51
|
+
continue
|
|
52
|
+
c = m.get("content") or []
|
|
53
|
+
# The lifecycle notice is what marks a turn as ACP-backed at all.
|
|
54
|
+
notices = [b.get("text", "").strip() for b in c if b.get("textSignature") == "entwurf:lifecycle-notice-v1"]
|
|
55
|
+
if not notices:
|
|
56
|
+
continue
|
|
57
|
+
ts = o.get("timestamp")
|
|
58
|
+
t = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
59
|
+
newchild = any("preparing claude session" in n for n in notices)
|
|
60
|
+
if newchild:
|
|
61
|
+
spawn = t
|
|
62
|
+
texts = [b.get("text") for b in c if isinstance(b.get("text"), str)]
|
|
63
|
+
# "Terminal" is only the vendor's placeholder TITLE for a Bash call whose
|
|
64
|
+
# `input.command` has not finished streaming, so a start is one tool call.
|
|
65
|
+
starts = sum(1 for x in texts if x.startswith("\n[tool:start]"))
|
|
66
|
+
dones = sum(1 for x in texts if x.startswith("\n[tool:done]") or x.startswith("\n[tool:failed]"))
|
|
67
|
+
em = m.get("errorMessage") or ""
|
|
68
|
+
if m.get("stopReason") != "error":
|
|
69
|
+
kind = "OK"
|
|
70
|
+
elif "ACP connection closed" in em and "exit code 0" in em:
|
|
71
|
+
kind = "EXIT0"
|
|
72
|
+
elif "ACP connection closed" in em:
|
|
73
|
+
kind = "CLOSED-other"
|
|
74
|
+
else:
|
|
75
|
+
kind = "ERR-other"
|
|
76
|
+
rows.append(dict(
|
|
77
|
+
f=os.path.basename(f), ts=ts, kind=kind,
|
|
78
|
+
reuse=any("reusing live session" in n for n in notices),
|
|
79
|
+
newchild=newchild,
|
|
80
|
+
between=any("ended between turns" in n for n in notices),
|
|
81
|
+
starts=starts, dones=dones,
|
|
82
|
+
idle=(t - prev_end).total_seconds() if prev_end else None,
|
|
83
|
+
age=(t - spawn).total_seconds() if spawn else None,
|
|
84
|
+
tt=(m.get("usage") or {}).get("totalTokens"),
|
|
85
|
+
model=m.get("model"),
|
|
86
|
+
))
|
|
87
|
+
prev_end = t
|
|
88
|
+
|
|
89
|
+
json.dump(rows, sys.stdout)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Correlate anomalous ACP turns against this host's reaper SIGTERM events — #72.
|
|
3
|
+
|
|
4
|
+
The population script says WHICH turns died at exit 0. This one says WHY, on a
|
|
5
|
+
host that runs `acp-zombie-reaper.service`: it lines every EXIT0 / between-turn
|
|
6
|
+
row up against the systemd user journal's reaping events and prints the delta.
|
|
7
|
+
|
|
8
|
+
python3 acp-turn-population.py > acp-pop-<host>.json
|
|
9
|
+
python3 reaper-correlation.py acp-pop-<host>.json
|
|
10
|
+
|
|
11
|
+
An EXIT0 turn whose delta is under a second was killed by that SIGTERM. A
|
|
12
|
+
`between` row's delta is the idle gap since the reap that killed the previous
|
|
13
|
+
child — the operator never sees those, pi just opens a new one.
|
|
14
|
+
|
|
15
|
+
Hosts without the reaper unit produce zero reap events; that is the expected
|
|
16
|
+
output on thinkpad and is itself the receipt for the host split.
|
|
17
|
+
"""
|
|
18
|
+
import json, re, sys, datetime, subprocess
|
|
19
|
+
|
|
20
|
+
pop = sys.argv[1] if len(sys.argv) > 1 else "acp-pop-oracle.json"
|
|
21
|
+
rows = json.load(open(pop))
|
|
22
|
+
|
|
23
|
+
out = subprocess.run(
|
|
24
|
+
["journalctl", "--user", "-b", "0", "--no-pager"],
|
|
25
|
+
capture_output=True, text=True,
|
|
26
|
+
).stdout
|
|
27
|
+
reaps = []
|
|
28
|
+
for line in out.splitlines():
|
|
29
|
+
m = re.search(r"\[acp-reaper (\S+)\] reaping (\d+) stale.*: (.+)$", line)
|
|
30
|
+
if m:
|
|
31
|
+
t = datetime.datetime.fromisoformat(m.group(1)).astimezone(datetime.timezone.utc)
|
|
32
|
+
reaps.append((t, m.group(3).strip()))
|
|
33
|
+
|
|
34
|
+
print(f"reaper SIGTERM events in this boot: {len(reaps)}")
|
|
35
|
+
if not reaps:
|
|
36
|
+
print("no reaper on this host — nothing to correlate")
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
|
|
39
|
+
print(f"{'turn (UTC)':26} {'kind':13} {'between':8} {'nearest reap':22} {'delta(s)':>9}")
|
|
40
|
+
for r in rows:
|
|
41
|
+
if r["kind"] != "EXIT0" and not r["between"]:
|
|
42
|
+
continue
|
|
43
|
+
t = datetime.datetime.fromisoformat(r["ts"].replace("Z", "+00:00"))
|
|
44
|
+
best = min(reaps, key=lambda x: abs((x[0] - t).total_seconds()))
|
|
45
|
+
d = (t - best[0]).total_seconds()
|
|
46
|
+
print(f"{r['ts']:26} {r['kind']:13} {str(r['between']):8} "
|
|
47
|
+
f"{best[0].strftime('%Y-%m-%dT%H:%M:%SZ'):22} {d:>9.1f} pid={best[1]}")
|
|
@@ -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) {
|