@junghanacs/entwurf 0.14.1 → 0.14.2
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 +1 -1
- package/CHANGELOG.md +34 -0
- package/DELIVERY.md +57 -0
- package/README.md +1 -1
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
|
@@ -28,8 +28,12 @@ Subcommands (argv[1]):
|
|
|
28
28
|
This is NOT tracked for an honest inverse: the legacy entry was wrong and stays gone.
|
|
29
29
|
|
|
30
30
|
doctor-static <config_path>
|
|
31
|
-
Print one line describing the candidate for the shell doctor: `absent` / `
|
|
32
|
-
|
|
31
|
+
Print one line describing the candidate for the shell doctor: `absent` / `invalid-json` /
|
|
32
|
+
`invalid-entry` / `not-configured` / `configured <cmd>`. Never mutates.
|
|
33
|
+
|
|
34
|
+
doctor-invocation <config_path>
|
|
35
|
+
Print the configured server's exact `{command,args,env}` as compact JSON for the boot probe.
|
|
36
|
+
Invalid or absent entries fail rather than silently dropping argv/environment.
|
|
33
37
|
|
|
34
38
|
permission-install <settings_path> <state_path>
|
|
35
39
|
The OTHER half of "agy can call our bridge": registering the server (above) makes the tools
|
|
@@ -349,25 +353,51 @@ def cmd_clean_legacy(config_path: str) -> None:
|
|
|
349
353
|
sys.stdout.write(f"cleaned-kept {config_path}\n")
|
|
350
354
|
|
|
351
355
|
|
|
352
|
-
def
|
|
353
|
-
|
|
354
|
-
|
|
356
|
+
def _doctor_invocation(config_path: str):
|
|
357
|
+
"""Return the exact stdio invocation agy reads, or a status token.
|
|
358
|
+
|
|
359
|
+
Command-only inspection is a false-success surface: configured argv/env can make a launcher
|
|
360
|
+
fail even while the same command boots with defaults. Display and execution share this parser
|
|
361
|
+
so they cannot disagree about whether an entry is valid.
|
|
362
|
+
"""
|
|
355
363
|
real = os.path.realpath(config_path)
|
|
356
364
|
if not os.path.exists(real):
|
|
357
|
-
|
|
358
|
-
return
|
|
365
|
+
return "absent", None
|
|
359
366
|
try:
|
|
360
367
|
with open(real, "r", encoding="utf-8") as fh:
|
|
361
368
|
data = json.loads(fh.read() or "{}")
|
|
362
369
|
except (json.JSONDecodeError, OSError):
|
|
363
|
-
|
|
364
|
-
return
|
|
370
|
+
return "invalid-json", None
|
|
365
371
|
server = (data.get("mcpServers") or {}).get(SERVER_KEY) if isinstance(data, dict) else None
|
|
366
|
-
if
|
|
367
|
-
|
|
372
|
+
if server is None:
|
|
373
|
+
return "not-configured", None
|
|
374
|
+
if not isinstance(server, dict):
|
|
375
|
+
return "invalid-entry", None
|
|
376
|
+
command = server.get("command")
|
|
377
|
+
args = server.get("args", [])
|
|
378
|
+
env = server.get("env", {})
|
|
379
|
+
if not isinstance(command, str) or not command:
|
|
380
|
+
return "invalid-entry", None
|
|
381
|
+
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
|
382
|
+
return "invalid-entry", None
|
|
383
|
+
if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()):
|
|
384
|
+
return "invalid-entry", None
|
|
385
|
+
return "configured", {"command": command, "args": args, "env": env}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_doctor_static(config_path: str) -> None:
|
|
389
|
+
status, invocation = _doctor_invocation(config_path)
|
|
390
|
+
if status != "configured":
|
|
391
|
+
sys.stdout.write(f"{status}\n")
|
|
368
392
|
return
|
|
369
|
-
|
|
370
|
-
|
|
393
|
+
sys.stdout.write(f"configured {invocation['command']}\n")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def cmd_doctor_invocation(config_path: str) -> None:
|
|
397
|
+
status, invocation = _doctor_invocation(config_path)
|
|
398
|
+
if status != "configured":
|
|
399
|
+
_die(4, f"agy-bridge: cannot read configured invocation from {config_path}: {status}")
|
|
400
|
+
sys.stdout.write(json.dumps(invocation, separators=(",", ":")) + "\n")
|
|
371
401
|
|
|
372
402
|
|
|
373
403
|
def cmd_permission_install(settings_path: str, state_path: str) -> None:
|
|
@@ -619,6 +649,10 @@ def main(argv: list) -> None:
|
|
|
619
649
|
if len(argv) != 3:
|
|
620
650
|
_die(5, "usage: agy-bridge-config.py doctor-static <config_path>")
|
|
621
651
|
cmd_doctor_static(argv[2])
|
|
652
|
+
elif sub == "doctor-invocation":
|
|
653
|
+
if len(argv) != 3:
|
|
654
|
+
_die(5, "usage: agy-bridge-config.py doctor-invocation <config_path>")
|
|
655
|
+
cmd_doctor_invocation(argv[2])
|
|
622
656
|
elif sub == "permission-state-doctor":
|
|
623
657
|
if len(argv) != 3:
|
|
624
658
|
_die(5, "usage: agy-bridge-config.py permission-state-doctor <state_path>")
|
package/scripts/agy-bridge.sh
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
# install-state for an honest inverse. The command written is a STABLE bin
|
|
8
8
|
# (`entwurf-bridge`), NEVER a repo/git-hash path (the oracle dangling lesson).
|
|
9
9
|
# uninstall honest inverse from the install-state (restore preimage / remove our key).
|
|
10
|
-
# doctor 2-tier: STATIC proves both candidate configs (documented + observed) resolve,
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# doctor 2-tier: STATIC proves both candidate configs (documented + observed) resolve, parse,
|
|
11
|
+
# and carry a command that actually BOOTS the entwurf MCP surface (#81 — resolvable
|
|
12
|
+
# is necessary, not sufficient; it execs the configured command); LIVE proves
|
|
13
|
+
# runtime-effectiveness only when an agy process exists, else an honest SKIP (never a
|
|
14
|
+
# PASS in disguise).
|
|
13
15
|
#
|
|
14
16
|
# GLOBAL ROOT (the one file that matters): live agy reads its global MCP config from
|
|
15
17
|
# ~/.gemini/config/mcp_config.json (agy's own builtin doc: mcp_servers.md — "Global Configuration:
|
|
@@ -25,6 +27,7 @@
|
|
|
25
27
|
set -euo pipefail
|
|
26
28
|
|
|
27
29
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
REPO_DIR="$(cd "$HERE/.." && pwd)"
|
|
28
31
|
CONFIG_PY="$HERE/agy-bridge-config.py"
|
|
29
32
|
|
|
30
33
|
GLOBAL_CONFIG="${AGY_MCP_CONFIG:-$HOME/.gemini/config/mcp_config.json}"
|
|
@@ -62,6 +65,33 @@ command_resolvable() {
|
|
|
62
65
|
esac
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
# Does COMMAND actually BOOT and serve the entwurf MCP surface? (#81) Resolvability is necessary
|
|
69
|
+
# and NOT sufficient: on the reference host the bare name resolved through a relocated pnpm shim
|
|
70
|
+
# whose $0-derived target was gone, so it exited 127 and agy would have had no entwurf tool — while
|
|
71
|
+
# this doctor printed "(resolvable)" and stayed green. The verdict leaf is shared with the pi
|
|
72
|
+
# doctor via run.sh (the ONE strip-types fence crossing), so both lanes judge boot the same way.
|
|
73
|
+
#
|
|
74
|
+
# Cached per exact invocation: the probe spawns a real child, and the doctor asks about the same
|
|
75
|
+
# configured {command,args,env} for two candidate configs plus the live tier. BOOT_DETAIL carries
|
|
76
|
+
# the last probe's one-line verdict for the caller to print.
|
|
77
|
+
BOOT_PROBED_INVOCATION=""
|
|
78
|
+
BOOT_PROBED_RC=1
|
|
79
|
+
BOOT_PROBED_OUT=""
|
|
80
|
+
BOOT_DETAIL=""
|
|
81
|
+
command_boots() {
|
|
82
|
+
local invocation="$1" out rc
|
|
83
|
+
if [ "$invocation" = "$BOOT_PROBED_INVOCATION" ]; then
|
|
84
|
+
BOOT_DETAIL="$BOOT_PROBED_OUT"
|
|
85
|
+
return "$BOOT_PROBED_RC"
|
|
86
|
+
fi
|
|
87
|
+
set +e
|
|
88
|
+
out="$("$REPO_DIR/run.sh" probe-bridge-command --invocation-json "$invocation" 2>&1)"
|
|
89
|
+
rc=$?
|
|
90
|
+
set -e
|
|
91
|
+
BOOT_PROBED_INVOCATION="$invocation"; BOOT_PROBED_RC="$rc"; BOOT_PROBED_OUT="$out"; BOOT_DETAIL="$out"
|
|
92
|
+
return "$rc"
|
|
93
|
+
}
|
|
94
|
+
|
|
65
95
|
# Prune the agy MCP tool-schema cache for the KNOWN-legacy server keys (LEGACY_CACHE_KEYS). Removes
|
|
66
96
|
# ONLY the exact-named dirs — a symlink is left intact (not ours), and any OTHER server's cache is
|
|
67
97
|
# never touched. Idempotent (absent = no-op). One-shot cutover hygiene, not honest-inverse tracked.
|
|
@@ -171,8 +201,8 @@ do_uninstall() {
|
|
|
171
201
|
}
|
|
172
202
|
|
|
173
203
|
# Static-check ONE candidate config. Prints a status line; returns 1 on a hard failure
|
|
174
|
-
# (invalid JSON / configured
|
|
175
|
-
#
|
|
204
|
+
# (invalid JSON / configured invocation that cannot boot), 0 otherwise (absent / not-configured
|
|
205
|
+
# are not doctor failures — a candidate may legitimately be unused).
|
|
176
206
|
doctor_static_one() {
|
|
177
207
|
local label="$1" candidate="$2"
|
|
178
208
|
local link_note=""
|
|
@@ -185,13 +215,25 @@ doctor_static_one() {
|
|
|
185
215
|
absent) log " $label: absent$link_note"; return 0 ;;
|
|
186
216
|
not-configured) log " $label: present but entwurf-bridge NOT configured$link_note"; return 0 ;;
|
|
187
217
|
invalid-json) log " $label: INVALID JSON$link_note"; return 1 ;;
|
|
218
|
+
invalid-entry) log " $label: INVALID entwurf-bridge entry (command/args/env)$link_note"; return 1 ;;
|
|
188
219
|
configured\ *)
|
|
189
|
-
local cmd="${status#configured }"
|
|
190
|
-
if
|
|
191
|
-
log " $label: configured → '$cmd'
|
|
220
|
+
local cmd="${status#configured }" invocation
|
|
221
|
+
if ! invocation="$(python3 "$CONFIG_PY" doctor-invocation "$candidate")"; then
|
|
222
|
+
log " $label: configured → '$cmd' but its exact invocation is unreadable$link_note"
|
|
223
|
+
return 1
|
|
224
|
+
fi
|
|
225
|
+
if command_boots "$invocation"; then
|
|
226
|
+
log " $label: configured → '$cmd' (the exact configured invocation boots the entwurf MCP surface)$link_note"
|
|
192
227
|
return 0
|
|
193
228
|
fi
|
|
194
|
-
|
|
229
|
+
# A configured environment may itself carry PATH, so a shell-side `command -v` is not the
|
|
230
|
+
# runtime's subject. The shared probe executes the exact command + args + env instead.
|
|
231
|
+
# entwurf does NOT repair a failed launcher here: it may be owned by a foreign file.
|
|
232
|
+
log " $label: configured → '$cmd' does NOT serve MCP with its configured args/env$link_note"
|
|
233
|
+
log " $BOOT_DETAIL"
|
|
234
|
+
log " Identify the launcher: command -v '$cmd'; readlink -f \"\$(command -v '$cmd')\""
|
|
235
|
+
log " If it is entwurf's managed dev link, restore it with ./run.sh expose-dev-bin (it REFUSES a foreign link)."
|
|
236
|
+
log " If a foreign launcher owns the name, repair/remove it yourself or put a working one earlier on PATH."
|
|
195
237
|
return 1 ;;
|
|
196
238
|
*) log " $label: unexpected status '$status'$link_note"; return 1 ;;
|
|
197
239
|
esac
|
|
@@ -283,23 +325,29 @@ sys.exit(0 if same and owned else 1)' "$PERMISSION_STATE_FILE" "$SETTINGS_FILE";
|
|
|
283
325
|
|
|
284
326
|
do_doctor() {
|
|
285
327
|
log "[agy-bridge doctor]"
|
|
286
|
-
local hard_fail=0 configured_any=0
|
|
328
|
+
local hard_fail=0 configured_any=0 bootable_any=0
|
|
287
329
|
|
|
288
330
|
log "── static (configured candidates)"
|
|
289
331
|
doctor_static_one "global ($GLOBAL_CONFIG)" "$GLOBAL_CONFIG" || hard_fail=1
|
|
290
332
|
doctor_static_one "legacy ($LEGACY_CONFIG)" "$LEGACY_CONFIG" || hard_fail=1
|
|
291
333
|
doctor_permission || hard_fail=1
|
|
292
|
-
# Did EITHER candidate carry a configured
|
|
293
|
-
# separate from ownership-state failures below: a FOREIGN TARGET makes the doctor red, but it
|
|
294
|
-
# does not make a visibly configured command disappear.
|
|
295
|
-
|
|
334
|
+
# Did EITHER candidate carry a configured entwurf-bridge that actually BOOTS? Keep this runtime
|
|
335
|
+
# fact separate from ownership-state failures below: a FOREIGN TARGET makes the doctor red, but it
|
|
336
|
+
# does not make a visibly configured command disappear. Boot (not mere resolvability) is the fact
|
|
337
|
+
# the live tier reports on — the probe is cached, so this loop re-costs nothing.
|
|
338
|
+
local c candidate_status candidate_cmd candidate_invocation
|
|
296
339
|
for c in "$GLOBAL_CONFIG" "$LEGACY_CONFIG"; do
|
|
297
340
|
candidate_status="$(python3 "$CONFIG_PY" doctor-static "$c")"
|
|
298
341
|
case "$candidate_status" in
|
|
299
342
|
configured\ *)
|
|
300
343
|
configured_any=1
|
|
301
344
|
candidate_cmd="${candidate_status#configured }"
|
|
302
|
-
|
|
345
|
+
if candidate_invocation="$(python3 "$CONFIG_PY" doctor-invocation "$c")"; then
|
|
346
|
+
command_boots "$candidate_invocation" && bootable_any=1
|
|
347
|
+
else
|
|
348
|
+
log " runtime: configured '$candidate_cmd' has an unreadable exact invocation."
|
|
349
|
+
hard_fail=1
|
|
350
|
+
fi
|
|
303
351
|
;;
|
|
304
352
|
esac
|
|
305
353
|
done
|
|
@@ -373,17 +421,19 @@ do_doctor() {
|
|
|
373
421
|
|
|
374
422
|
log "── live (runtime wiring)"
|
|
375
423
|
if command -v pgrep >/dev/null 2>&1 && pgrep -x agy >/dev/null 2>&1; then
|
|
376
|
-
if [ "$
|
|
377
|
-
# HONEST label (N2): a running agy + a
|
|
378
|
-
# runtime wiring
|
|
379
|
-
#
|
|
424
|
+
if [ "$bootable_any" -eq 1 ]; then
|
|
425
|
+
# HONEST label (N2): a running agy + a configured candidate whose command BOOTS and serves the
|
|
426
|
+
# entwurf MCP surface is CONSISTENT with runtime wiring. It still does NOT prove agy actually
|
|
427
|
+
# READ that config — that remains the deferred half. What is no longer deferred (#81) is the
|
|
428
|
+
# tool-listing evidence itself: the static tier above got it from the command, not from the
|
|
429
|
+
# mere fact that a name resolved. Ownership/state failures remain red independently.
|
|
380
430
|
if [ "$hard_fail" -eq 0 ]; then
|
|
381
|
-
log " live: agy is running AND a configured candidate
|
|
431
|
+
log " live: agy is running AND a configured candidate's command boots the entwurf MCP surface — consistent with runtime wiring (agy's own config-read NOT proven)."
|
|
382
432
|
else
|
|
383
|
-
log " live: agy is running and a configured candidate
|
|
433
|
+
log " live: agy is running and a configured candidate boots, but ownership/state errors above keep this doctor red (agy's own config-read NOT proven)."
|
|
384
434
|
fi
|
|
385
435
|
else
|
|
386
|
-
log " live: agy is running but no
|
|
436
|
+
log " live: agy is running but no configured candidate whose command boots the entwurf MCP surface — runtime wiring is broken."
|
|
387
437
|
hard_fail=1
|
|
388
438
|
fi
|
|
389
439
|
else
|
|
@@ -391,7 +441,7 @@ do_doctor() {
|
|
|
391
441
|
fi
|
|
392
442
|
|
|
393
443
|
if [ "$hard_fail" -ne 0 ]; then
|
|
394
|
-
fail "doctor found a broken candidate (invalid JSON / dangling command / broken live wiring)."
|
|
444
|
+
fail "doctor found a broken candidate (invalid JSON / dangling command / command that does not serve MCP / broken live wiring)."
|
|
395
445
|
fi
|
|
396
446
|
log "doctor: ok (static candidates clean)."
|
|
397
447
|
}
|
|
@@ -172,6 +172,19 @@ function makeHarness(recordDir: string) {
|
|
|
172
172
|
settle(stopReason: string) {
|
|
173
173
|
pending?.resolve({ stopReason });
|
|
174
174
|
},
|
|
175
|
+
/**
|
|
176
|
+
* The TRANSPORT ends under the pending request — the real SDK's own
|
|
177
|
+
* rejection, and the one production actually sees FIRST.
|
|
178
|
+
*
|
|
179
|
+
* Distinct from `close()` on purpose: the SDK does not route this through
|
|
180
|
+
* an explicit close call. Its read loop hits stdout EOF and rejects every
|
|
181
|
+
* pending response with `closeSignal.reason ?? new Error("ACP connection
|
|
182
|
+
* closed")` — a GENERIC message, because a clean EOF carries no reason.
|
|
183
|
+
* That verbatim text is the subject of the cells below.
|
|
184
|
+
*/
|
|
185
|
+
transportClosed(reason?: string) {
|
|
186
|
+
pending?.reject(new Error(reason ?? "ACP connection closed"));
|
|
187
|
+
},
|
|
175
188
|
/** the agent streams something mid-turn (proof the turn is progressing) */
|
|
176
189
|
async progress(text: string) {
|
|
177
190
|
await notifier?.({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
|
|
@@ -524,6 +537,187 @@ try {
|
|
|
524
537
|
await t2.done;
|
|
525
538
|
}
|
|
526
539
|
|
|
540
|
+
// ----------------------------------------------------------------------
|
|
541
|
+
// CELL 9 — THE OTHER TEMPORAL ORDER: EOF first, exit one tick later.
|
|
542
|
+
//
|
|
543
|
+
// CELLS 4/5 drive the lifecycle-FIRST order: the child's `exit` event fires
|
|
544
|
+
// while the prompt is still pending, so `notifyChildGone` wins the race and
|
|
545
|
+
// reports the exit status. Those cells stay — that order remains possible and
|
|
546
|
+
// must keep working. This cell covers the order the FIELD showed.
|
|
547
|
+
//
|
|
548
|
+
// Measured with the production shape (piped stdio, detached, `Readable.toWeb`)
|
|
549
|
+
// on Linux, the child's stdout EOF landed ~1ms BEFORE node emitted `exit` —
|
|
550
|
+
// for a clean exit(0) and for SIGKILL alike (numbers preserved in issue #72).
|
|
551
|
+
//
|
|
552
|
+
// In that order the SDK's generic rejection settles the turn first,
|
|
553
|
+
// `awaitAcpPromptTurn`'s `finally` clears `notifyChildGone`, and the exit
|
|
554
|
+
// status arriving one tick later has nowhere to go. Issue #72's field sample
|
|
555
|
+
// is exactly that: `ACP connection closed` plus a stderr tail, naming neither
|
|
556
|
+
// exit code nor signal — while the backend HAD an exit status the whole time.
|
|
557
|
+
//
|
|
558
|
+
// The order here is scheduled EXPLICITLY (reject, then `die` on a later tick)
|
|
559
|
+
// rather than borrowed from a production helper: the claim IS the ordering,
|
|
560
|
+
// so the oracle must state it, not inherit it from the subject.
|
|
561
|
+
// ----------------------------------------------------------------------
|
|
562
|
+
let eofFirstMessage = "";
|
|
563
|
+
{
|
|
564
|
+
const h = makeHarness(recordDir);
|
|
565
|
+
const t1 = startTurn(backend, userCtx("first NONCE-E1"), { sessionId: "life-eof-first" }, h.deps);
|
|
566
|
+
await delay(20);
|
|
567
|
+
h.settle("end_turn");
|
|
568
|
+
await t1.done;
|
|
569
|
+
assert.equal(sealed(t1.events)[0].type, "done", "turn 1 completes so the session is retained for reuse");
|
|
570
|
+
|
|
571
|
+
// Turn 2 is the field shape: a retained child, a completed tool phase, and
|
|
572
|
+
// then the transport ends before the final answer.
|
|
573
|
+
const t2 = startTurn(
|
|
574
|
+
backend,
|
|
575
|
+
reuseCtx("first NONCE-E1", "second NONCE-E2"),
|
|
576
|
+
{ sessionId: "life-eof-first" },
|
|
577
|
+
h.deps,
|
|
578
|
+
);
|
|
579
|
+
await delay(30);
|
|
580
|
+
assert.equal(h.children.length, 1, "turn 2 reused the live child (no respawn)");
|
|
581
|
+
h.children[0].writeStderr(
|
|
582
|
+
"(node:501006) [CLAUDE_SDK_CAN_USE_TOOL_SHADOWED] Warning: canUseTool will not be invoked\n",
|
|
583
|
+
);
|
|
584
|
+
// EOF first …
|
|
585
|
+
h.transportClosed();
|
|
586
|
+
// … and the exit status one tick later, exactly as measured.
|
|
587
|
+
await delay(1);
|
|
588
|
+
h.children[0].die(0, null);
|
|
589
|
+
await t2.done;
|
|
590
|
+
|
|
591
|
+
eofFirstMessage = String(sealed(t2.events)[0].error.errorMessage);
|
|
592
|
+
assert.ok(
|
|
593
|
+
eofFirstMessage.includes("ACP connection closed"),
|
|
594
|
+
"the backend's own first words are preserved verbatim, not swapped out for ours — " +
|
|
595
|
+
`a reader must still be able to match the transport's text. Got: ${JSON.stringify(eofFirstMessage)}`,
|
|
596
|
+
);
|
|
597
|
+
assert.ok(
|
|
598
|
+
eofFirstMessage.includes("exit code 0"),
|
|
599
|
+
"[QK:EOF-FIRST-CARRIES-CHILD-END] when the transport closes BEFORE node reports the child's exit — the order " +
|
|
600
|
+
"the field sample exhibited — the sealed error must still name how the child ended. Losing it to that " +
|
|
601
|
+
"~1ms race is what left issue #72's field sample with no exit code and no signal. " +
|
|
602
|
+
`Got: ${JSON.stringify(eofFirstMessage)}`,
|
|
603
|
+
);
|
|
604
|
+
assert.ok(
|
|
605
|
+
eofFirstMessage.includes("while the prompt was still in flight"),
|
|
606
|
+
"the sealed error names the PHASE that died — a closure during bootstrap and one under a live prompt are " +
|
|
607
|
+
`different failures. Got: ${JSON.stringify(eofFirstMessage)}`,
|
|
608
|
+
);
|
|
609
|
+
// NARROW on purpose: this proves the tail collected BEFORE the seal is not
|
|
610
|
+
// cut short by our own cleanup. It does NOT claim the child's last words —
|
|
611
|
+
// nothing here waits for the stderr pipe to drain, and node's `exit` can
|
|
612
|
+
// precede that drain. Draining is a separate lever; see backend.ts.
|
|
613
|
+
assert.ok(
|
|
614
|
+
eofFirstMessage.includes("CLAUDE_SDK_CAN_USE_TOOL_SHADOWED"),
|
|
615
|
+
`the stderr tail collected before the seal rides the sealed error. Got: ${JSON.stringify(eofFirstMessage)}`,
|
|
616
|
+
);
|
|
617
|
+
// Cleanup still runs after the seal — settling first DELAYS teardown, it
|
|
618
|
+
// does not skip it. The signal itself is not the evidence here: the child
|
|
619
|
+
// is already dead by now, and `teardownChild` correctly declines to signal
|
|
620
|
+
// a corpse. The connection close is the part that always runs.
|
|
621
|
+
assert.ok(
|
|
622
|
+
h.closes.length > 0,
|
|
623
|
+
"the uncertain connection is still closed after the seal — an error path must never leave it reusable",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ----------------------------------------------------------------------
|
|
628
|
+
// CELL 10 — a SIGKILL under the same order is told apart from a clean exit.
|
|
629
|
+
//
|
|
630
|
+
// exit(0) and SIGKILL are the two candidate deaths behind #72 — a backend
|
|
631
|
+
// that chose to shut down (claude-agent-acp's index.js exits 0 when its ACP
|
|
632
|
+
// connection closes, silently) versus one killed from outside. They are
|
|
633
|
+
// distinguishable ONLY by this field, which is why CELL 9's claim is not
|
|
634
|
+
// enough on its own: a report that always said "exit code 0" would satisfy it
|
|
635
|
+
// while telling the operator nothing.
|
|
636
|
+
// ----------------------------------------------------------------------
|
|
637
|
+
{
|
|
638
|
+
const h = makeHarness(recordDir);
|
|
639
|
+
const turn = startTurn(backend, userCtx("killed from outside"), { sessionId: "life-eof-kill" }, h.deps);
|
|
640
|
+
await delay(30);
|
|
641
|
+
h.children[0].writeStderr("KILLED-STDERR-MARK\n");
|
|
642
|
+
h.transportClosed();
|
|
643
|
+
await delay(1);
|
|
644
|
+
h.children[0].die(null, "SIGKILL");
|
|
645
|
+
await turn.done;
|
|
646
|
+
|
|
647
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
648
|
+
assert.ok(
|
|
649
|
+
message.includes("signal SIGKILL") && !message.includes("exit code"),
|
|
650
|
+
"a child killed from outside must read as a SIGNAL, never as a clean exit — a report that always said " +
|
|
651
|
+
`"exit code 0" would satisfy the previous cell while telling the operator nothing. Got: ${JSON.stringify(message)}`,
|
|
652
|
+
);
|
|
653
|
+
assert.ok(
|
|
654
|
+
message.includes("KILLED-STDERR-MARK"),
|
|
655
|
+
`the stderr tail rides the signal case too. Got: ${JSON.stringify(message)}`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ----------------------------------------------------------------------
|
|
660
|
+
// CELL 11 — a child that never reports an end says SO, bounded.
|
|
661
|
+
//
|
|
662
|
+
// A closed transport does not prove a dead child: the connection can end
|
|
663
|
+
// while the process lives. The honest report then is that we waited and it
|
|
664
|
+
// never said — NOT a guessed exit code, and NOT an unbounded wait. Silence
|
|
665
|
+
// must be reported as silence.
|
|
666
|
+
// ----------------------------------------------------------------------
|
|
667
|
+
let noEndMessage = "";
|
|
668
|
+
{
|
|
669
|
+
const h = makeHarness(recordDir);
|
|
670
|
+
const turn = startTurn(backend, userCtx("transport closes, child lives"), { sessionId: "life-eof-noend" }, h.deps);
|
|
671
|
+
await delay(30);
|
|
672
|
+
const closedAt = Date.now();
|
|
673
|
+
h.transportClosed();
|
|
674
|
+
await turn.done;
|
|
675
|
+
const waited = Date.now() - closedAt;
|
|
676
|
+
|
|
677
|
+
noEndMessage = String(sealed(turn.events)[0].error.errorMessage);
|
|
678
|
+
assert.ok(
|
|
679
|
+
noEndMessage.includes("reported no exit status within") &&
|
|
680
|
+
!noEndMessage.includes("exit code") &&
|
|
681
|
+
!noEndMessage.includes("signal "),
|
|
682
|
+
"when the transport closed but the child never reported an end, the turn must SAY so and invent nothing — " +
|
|
683
|
+
`a guessed exit status would be worse than the bare closure. Got: ${JSON.stringify(noEndMessage)}`,
|
|
684
|
+
);
|
|
685
|
+
// The window is BOTH real and bounded. The lower bound is what stops a
|
|
686
|
+
// mutant from passing by never waiting at all (it would then report silence
|
|
687
|
+
// for a child that was about to answer); the upper bound is what stops the
|
|
688
|
+
// wait from growing into the wall clock this gate exists to keep out. The
|
|
689
|
+
// shipped bound is CHILD_END_SETTLE_MS (500ms) — not imported, since the
|
|
690
|
+
// gate must not widen the backend's module surface — so the range allows a
|
|
691
|
+
// loaded host some slack while refusing multi-second drift.
|
|
692
|
+
assert.ok(
|
|
693
|
+
waited >= 400 && waited < 1_500,
|
|
694
|
+
"[QK:CHILD-END-SILENCE-BOUNDED] the post-mortem wait must actually happen AND be bounded — a failing turn may " +
|
|
695
|
+
`neither skip the window nor hang in it. Waited ${waited}ms`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// A closure that CARRIES A REASON already explains itself, so it must not be
|
|
700
|
+
// given the post-mortem window: only the SDK's bare, reason-less text earns
|
|
701
|
+
// it. Without this, a broadened match would tax unrelated prompt errors with a
|
|
702
|
+
// delay — and the near-miss below is exactly what a substring test would catch
|
|
703
|
+
// by mistake.
|
|
704
|
+
{
|
|
705
|
+
const h = makeHarness(recordDir);
|
|
706
|
+
const turn = startTurn(backend, userCtx("closure with a reason"), { sessionId: "life-near-match" }, h.deps);
|
|
707
|
+
await delay(30);
|
|
708
|
+
const closedAt = Date.now();
|
|
709
|
+
h.transportClosed("ACP connection closed by the operator's proxy");
|
|
710
|
+
await turn.done;
|
|
711
|
+
const waited = Date.now() - closedAt;
|
|
712
|
+
|
|
713
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
714
|
+
assert.ok(
|
|
715
|
+
!message.includes("[acp] lifecycle:"),
|
|
716
|
+
`a closure that named its own reason must not be re-diagnosed. Got: ${JSON.stringify(message)}`,
|
|
717
|
+
);
|
|
718
|
+
assert.ok(waited < 300, `…and must not pay the post-mortem wait either. Waited ${waited}ms`);
|
|
719
|
+
}
|
|
720
|
+
|
|
527
721
|
// ----------------------------------------------------------------------
|
|
528
722
|
// CELL 8 — our prompt-phase failure text is not transient, judged by pi.
|
|
529
723
|
// ----------------------------------------------------------------------
|
|
@@ -533,12 +727,28 @@ try {
|
|
|
533
727
|
"positive control: pi still classifies the RETIRED 600s cutoff text as transient — that classification is " +
|
|
534
728
|
"exactly why one wall-clock kill cost four full cold turns",
|
|
535
729
|
);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
730
|
+
// EVERY prompt-phase failure text this backend authors, judged together by pi's
|
|
731
|
+
// own classifier. One aggregate assertion rather than three: the claim is the
|
|
732
|
+
// same for all of them, and the token that names it must appear exactly once.
|
|
733
|
+
//
|
|
734
|
+
// The two closure diagnoses are the ones that carry real risk — they are
|
|
735
|
+
// APPENDED to the SDK's own message, and the first draft said "within 500ms",
|
|
736
|
+
// which pi read as an HTTP 500 and classified as transient.
|
|
737
|
+
const authoredFailureTexts = [
|
|
738
|
+
["mid-prompt child death", childDeathMessage],
|
|
739
|
+
["EOF-first child end", eofFirstMessage],
|
|
740
|
+
["no child end within the bound", noEndMessage],
|
|
741
|
+
] as const;
|
|
742
|
+
const transientlyWorded = authoredFailureTexts.filter(([, text]) =>
|
|
743
|
+
isRetryableAssistantError({ stopReason: "error", errorMessage: text } as any),
|
|
744
|
+
);
|
|
745
|
+
assert.deepEqual(
|
|
746
|
+
transientlyWorded.map(([label]) => label),
|
|
747
|
+
[],
|
|
748
|
+
"[QK:PROMPT-ERROR-NOT-TRANSIENT] pi must NOT classify any prompt-phase lifecycle failure we authored as a " +
|
|
749
|
+
"transient provider error — a retry here is a cold replay of the whole prompt, and the tool side effects " +
|
|
750
|
+
"this turn already produced make that the expensive failure, not the cheap one. Offending texts: " +
|
|
751
|
+
JSON.stringify(transientlyWorded),
|
|
542
752
|
);
|
|
543
753
|
} finally {
|
|
544
754
|
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
@@ -559,7 +769,9 @@ console.log(
|
|
|
559
769
|
"session/cancel first and seals cancelled→aborted without signalling a cooperating child; a wedged agent is torn " +
|
|
560
770
|
"down after the bounded grace and still returns promptly with no new child; a child that dies mid-prompt is " +
|
|
561
771
|
"reported with its exit status AND stderr tail on BOTH the new and the reuse path; a death BETWEEN turns is " +
|
|
562
|
-
"announced once by the next turn while a teardown WE performed stays silent;
|
|
563
|
-
"
|
|
564
|
-
"
|
|
772
|
+
"announced once by the next turn while a teardown WE performed stays silent; the child's end survives the " +
|
|
773
|
+
"temporal order the field showed too (transport EOF first, exit one tick later, alongside the opposite order), " +
|
|
774
|
+
"telling a clean exit apart from a signal and reporting silence AS silence within a bounded window; and pi's " +
|
|
775
|
+
"own isRetryableAssistantError refuses to classify any of those failures as transient while still matching " +
|
|
776
|
+
"the retired 600s text",
|
|
565
777
|
);
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { EXPECTED_TOOLS } from "./probe-bridge-command.ts";
|
|
28
29
|
|
|
29
30
|
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
31
|
const START_SH = path.join(REPO_DIR, "mcp", "entwurf-bridge", "start.sh");
|
|
@@ -246,6 +247,33 @@ async function main(): Promise<void> {
|
|
|
246
247
|
`--- want ---\n${expectedSurface.join(",")}\n--- got ---\n${publicSurface.join(",")}`,
|
|
247
248
|
);
|
|
248
249
|
|
|
250
|
+
// G1g (#81) — bind probe-bridge-command's identity constant to the REAL runtime surface. That
|
|
251
|
+
// probe decides whether a configured launcher is this bridge by comparing tools/list against
|
|
252
|
+
// EXPECTED_TOOLS, so a verb added or retired without updating the constant would silently make
|
|
253
|
+
// every doctor red (or, worse, bless a stale build). The constant has exactly one oracle: the
|
|
254
|
+
// server booting right here. Add or retire a verb → update the constant in the same commit.
|
|
255
|
+
//
|
|
256
|
+
// Compared as SORTED ARRAYS, not as sets: set comparison would hide a duplicate on either side —
|
|
257
|
+
// including one inside the constant itself, a copy-paste that makes the probe's own length report
|
|
258
|
+
// lie. The length check here catches a runtime duplicate too; G1f ABOVE owns that as its named
|
|
259
|
+
// subject, so a duplicated verb turns BOTH red. Judging the same fact twice from two artifacts is
|
|
260
|
+
// the point, not an overlap to trim.
|
|
261
|
+
//
|
|
262
|
+
// POSITION IS LOAD-BEARING: this runs AFTER every named assertion. `ok()` exits on the first
|
|
263
|
+
// failure, so a cell placed earlier swallows the reds below it — when this block sat next to
|
|
264
|
+
// G1a, planting a defect in the resume-call or public-surface contracts turned THIS cell red
|
|
265
|
+
// first and gate qualification read WRONG-REASON for two claims that were in fact working.
|
|
266
|
+
// A gate that hides which contract broke is worse than the one it was added to strengthen.
|
|
267
|
+
// Keep unnamed/derived checks last; anything carrying a [QK:…] claim comes first.
|
|
268
|
+
const runtimeNames = tools.map((t) => t?.name).filter((n): n is string => typeof n === "string");
|
|
269
|
+
const runtimeSorted = [...runtimeNames].sort();
|
|
270
|
+
const constantSorted = [...EXPECTED_TOOLS].sort();
|
|
271
|
+
ok(
|
|
272
|
+
"G1g: probe-bridge-command EXPECTED_TOOLS equals the runtime tools/list exactly (sorted, duplicates included)",
|
|
273
|
+
runtimeSorted.length === constantSorted.length && runtimeSorted.every((n, i) => n === constantSorted[i]),
|
|
274
|
+
`--- runtime tools/list (sorted) ---\n${runtimeSorted.join(", ")}\n--- EXPECTED_TOOLS (sorted) ---\n${constantSorted.join(", ")}`,
|
|
275
|
+
);
|
|
276
|
+
|
|
249
277
|
console.log(`\ncheck-entwurf-bridge-boot: ${passed} checks passed`);
|
|
250
278
|
}
|
|
251
279
|
|
|
@@ -800,11 +800,12 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
800
800
|
"acp-augment": 10,
|
|
801
801
|
"acp-cortex": 12,
|
|
802
802
|
"acp-overlay": 1,
|
|
803
|
-
"acp-prompt-lifecycle":
|
|
803
|
+
"acp-prompt-lifecycle": 10,
|
|
804
804
|
"acp-stop-reason": 6,
|
|
805
805
|
"acp-stream-hooks": 10,
|
|
806
806
|
"agy-permission": 6,
|
|
807
807
|
"bridge-boot-resume": 3,
|
|
808
|
+
"bridge-command-boot": 9,
|
|
808
809
|
"meta-facts": 4,
|
|
809
810
|
"meta-identity": 4,
|
|
810
811
|
"meta-retire": 3,
|
|
@@ -814,7 +815,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
814
815
|
"mux-parent-artifact": 3,
|
|
815
816
|
"mux-resume-call": 12,
|
|
816
817
|
"probe-ordering": 1,
|
|
817
|
-
"release-gate":
|
|
818
|
+
"release-gate": 12,
|
|
818
819
|
"resume-args": 6,
|
|
819
820
|
"resume-launch-identity": 6,
|
|
820
821
|
"self-address": 3,
|