@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.
Files changed (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +1 -1
  5. package/VERIFY.md +4 -4
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/setup-clean-host.md +3 -3
  11. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  12. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  13. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  14. package/package.json +9 -9
  15. package/pi-extensions/lib/acp/backend.ts +229 -9
  16. package/run.sh +70 -25
  17. package/scripts/agy-bridge-config.py +47 -13
  18. package/scripts/agy-bridge.sh +73 -23
  19. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  20. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  21. package/scripts/check-gate-qualification.ts +3 -2
  22. package/scripts/check-probe-bridge-command.ts +201 -0
  23. package/scripts/check-release-gate-outcomes.ts +54 -1
  24. package/scripts/doctor-pi-provider.ts +155 -51
  25. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  26. package/scripts/mutants/bridge-command-boot.json +107 -0
  27. package/scripts/mutants/release-gate.json +13 -0
  28. package/scripts/probe-bridge-command.ts +330 -0
  29. package/scripts/raw-async-delivery/README.md +158 -1
  30. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  31. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  32. package/scripts/smoke-agy-install-state.sh +76 -2
  33. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  34. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  35. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  36. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  37. package/scripts/smoke-pi-provider-state.sh +135 -6
  38. 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` / `symlink ->
32
- <target>` prefix / `invalid-json` / `not-configured` / `command <cmd>`. Never mutates.
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 cmd_doctor_static(config_path: str) -> None:
353
- # Report the RESOLVED path's config status in one shell-parseable token line. Symlink
354
- # detection/reporting is the shell's job (realpath here just follows any link).
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
- sys.stdout.write("absent\n")
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
- sys.stdout.write("invalid-json\n")
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 not isinstance(server, dict) or not server.get("command"):
367
- sys.stdout.write("not-configured\n")
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
- # "configured <command>" — command is the trailing token(s); shell takes field 2..N.
370
- sys.stdout.write(f"configured {server['command']}\n")
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>")
@@ -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
- # parse, and carry a resolvable command; LIVE proves runtime-effectiveness only
12
- # when an agy process exists, else an honest SKIP (never a PASS in disguise).
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-but-dangling command), 0 otherwise (absent / not-configured /
175
- # configured+resolvable are not doctor failures — a candidate may legitimately be unused).
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 command_resolvable "$cmd"; then
191
- log " $label: configured → '$cmd' (resolvable)$link_note"
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
- log " $label: configured '$cmd' DANGLING (not on PATH / not executable)$link_note"
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 resolvable_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 + resolvable entwurf-bridge? Keep this runtime fact
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
- local c candidate_status candidate_cmd
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
- command_resolvable "$candidate_cmd" && resolvable_any=1
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 [ "$resolvable_any" -eq 1 ]; then
377
- # HONEST label (N2): a running agy + a resolvable configured candidate is CONSISTENT with
378
- # runtime wiring, but it does NOT prove agy actually read that config — that needs MCP
379
- # tool-listing-grade evidence (deferred). Ownership/state failures remain red independently.
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 has a resolvable command — consistent with runtime wiring (config-read NOT proven; MCP-tool-listing evidence deferred)."
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 resolves, but ownership/state errors above keep this doctor red (config-read NOT proven)."
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 resolvable configured candidate — runtime wiring is broken."
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
- assert.equal(
537
- isRetryableAssistantError({ stopReason: "error", errorMessage: childDeathMessage } as any),
538
- false,
539
- "[QK:PROMPT-ERROR-NOT-TRANSIENT] pi must NOT classify a prompt-phase lifecycle failure we authored as a " +
540
- "transient provider error a retry here is a cold replay of the whole prompt, not a cheap retry. " +
541
- `Judged text: ${JSON.stringify(childDeathMessage)}`,
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; and pi's own " +
563
- "isRetryableAssistantError refuses " +
564
- "to classify that failure as transient while still matching the retired 600s text",
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": 8,
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": 11,
818
+ "release-gate": 12,
818
819
  "resume-args": 6,
819
820
  "resume-launch-identity": 6,
820
821
  "self-address": 3,