@junghanacs/entwurf 0.14.0 → 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 +13 -2
- package/CHANGELOG.md +63 -0
- package/DELIVERY.md +57 -0
- package/README.md +16 -7
- 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/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
- 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/src/index.ts +14 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +13 -4
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/mux-fresh-call.ts +57 -4
- package/pi-extensions/lib/mux-resume-call.ts +21 -53
- 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 +5 -3
- package/scripts/check-mux-resume-call.ts +11 -10
- 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/meta-bridge-state.py +75 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-fresh-call.json +48 -4
- package/scripts/mutants/mux-resume-call.json +3 -3
- 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 +12 -4
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +169 -3
- 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
|
@@ -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,20 +800,22 @@ 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,
|
|
811
|
+
"meta-retire": 3,
|
|
810
812
|
"mux-boundary": 14,
|
|
811
|
-
"mux-fresh-call":
|
|
813
|
+
"mux-fresh-call": 19,
|
|
812
814
|
"mux-launcher-fence": 7,
|
|
813
815
|
"mux-parent-artifact": 3,
|
|
814
816
|
"mux-resume-call": 12,
|
|
815
817
|
"probe-ordering": 1,
|
|
816
|
-
"release-gate":
|
|
818
|
+
"release-gate": 12,
|
|
817
819
|
"resume-args": 6,
|
|
818
820
|
"resume-launch-identity": 6,
|
|
819
821
|
"self-address": 3,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Every cwd claim below is a MEASURED tmux 3.6a behaviour, not a precaution. They were taken on a
|
|
10
10
|
* private server on 2026-08-06, and each one is a way a resume would look successful while being
|
|
11
|
-
* wrong
|
|
11
|
+
* wrong. Since #73 the classification itself lives in the shared `classify-tmux-cwd.ts` leaf
|
|
12
|
+
* (fresh-call consumes the same rules), so the CWD claims here are asserted against that leaf:
|
|
12
13
|
*
|
|
13
14
|
* MUXRESUME-CWD-MISSING-REFUSED a nonexistent `-c` does NOT fail: tmux exits 0, opens the
|
|
14
15
|
* window, and the child lands in $HOME. Nothing downstream can
|
|
@@ -31,10 +32,10 @@ import fs from "node:fs";
|
|
|
31
32
|
import os from "node:os";
|
|
32
33
|
import path from "node:path";
|
|
33
34
|
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { classifyTmuxCwd } from "../pi-extensions/lib/classify-tmux-cwd.ts";
|
|
34
36
|
import type { Placement } from "../pi-extensions/lib/mux-placement.ts";
|
|
35
37
|
import {
|
|
36
38
|
buildResumeCallArgs,
|
|
37
|
-
classifyResumeCwd,
|
|
38
39
|
RESUME_CALL_REJECT_HINT,
|
|
39
40
|
RESUME_CALL_RUNTIME,
|
|
40
41
|
type ResumeCallRejectReason,
|
|
@@ -77,32 +78,32 @@ function main(): void {
|
|
|
77
78
|
|
|
78
79
|
try {
|
|
79
80
|
// ── cwd classification ────────────────────────────────────────────────────────
|
|
80
|
-
ok("an existing absolute directory is accepted",
|
|
81
|
+
ok("an existing absolute directory is accepted", classifyTmuxCwd(realDir) === null);
|
|
81
82
|
|
|
82
83
|
ok(
|
|
83
84
|
"[QK:MUXRESUME-CWD-MISSING-REFUSED] a cwd that no longer exists is refused HERE, because tmux would not refuse it — measured: rc=0, the window opens, and the child silently falls back to $HOME, so the resume would land a visible citizen in the wrong project and look successful",
|
|
84
|
-
|
|
85
|
+
classifyTmuxCwd(path.join(tmp, "deleted-project")) === "cwd-missing",
|
|
85
86
|
);
|
|
86
87
|
ok(
|
|
87
88
|
"a path that exists but is a FILE is refused as its own cause, not as missing",
|
|
88
|
-
|
|
89
|
+
classifyTmuxCwd(filePath) === "cwd-not-directory",
|
|
89
90
|
);
|
|
90
91
|
ok(
|
|
91
92
|
"[QK:MUXRESUME-CWD-FORMAT-REFUSED] a cwd containing '#' is refused unrun — measured: tmux FORMAT-EXPANDS the -c value, so `<dir>/#{pane_id}` silently became `<dir>/%0` and a `#(…)` value was observed executing its command; a path is data and tmux reads it as a format",
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
classifyTmuxCwd(path.join(tmp, "#{pane_id}")) === "cwd-format-token" &&
|
|
94
|
+
classifyTmuxCwd(path.join(tmp, "#(touch x)")) === "cwd-format-token",
|
|
94
95
|
);
|
|
95
96
|
ok(
|
|
96
97
|
"the '#' refusal precedes the filesystem question, whose answer would be about a path tmux is not going to use",
|
|
97
|
-
|
|
98
|
+
classifyTmuxCwd(path.join(realDir, "#nope")) === "cwd-format-token",
|
|
98
99
|
);
|
|
99
100
|
ok(
|
|
100
101
|
"a relative cwd is refused before anything touches the filesystem",
|
|
101
|
-
|
|
102
|
+
classifyTmuxCwd("project") === "cwd-not-absolute",
|
|
102
103
|
);
|
|
103
104
|
ok(
|
|
104
105
|
"[QK:MUXRESUME-CWD-WHITESPACE-OK] a cwd containing whitespace is ACCEPTED — measured: argv is an array, tmux does not re-split it, and the directory arrived intact; inventing a quoting grammar here would refuse real project paths for a danger that was measured not to exist",
|
|
105
|
-
|
|
106
|
+
classifyTmuxCwd(spaceDir) === null,
|
|
106
107
|
);
|
|
107
108
|
|
|
108
109
|
// ── argv shape ────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-probe-bridge-command — deterministic contract gate for the #81 boot probe.
|
|
3
|
+
//
|
|
4
|
+
// The probe is what two doctors now stake their verdict on, so its reason taxonomy is a contract,
|
|
5
|
+
// not an implementation detail: each value names a DIFFERENT operator situation, and collapsing two
|
|
6
|
+
// of them would put the wrong repair in front of an operator. This gate pins the classification and
|
|
7
|
+
// the one side effect the probe owns — reaping the child it spawned.
|
|
8
|
+
//
|
|
9
|
+
// Hermetic: every subject is a stub script in a mktemp dir. No network, no operator state, no MCP
|
|
10
|
+
// server of ours is booted (check-entwurf-bridge-boot owns that axis).
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { EXPECTED_TOOLS, probeBridgeCommand } from "./probe-bridge-command.ts";
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
|
+
const PROBE_SRC = readFileSync(join(REPO_ROOT, "scripts", "probe-bridge-command.ts"), "utf8");
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
// The `[QK:…]` token must land on a FAILURE line (qualification does not count a token printed on
|
|
23
|
+
// an `ok` line — a later red must not self-certify an earlier claim), and it must appear exactly
|
|
24
|
+
// once in this file. So: strip it from the green line, print it verbatim on the red one.
|
|
25
|
+
function ok(label: string, cond: boolean, detail?: string): void {
|
|
26
|
+
if (!cond) {
|
|
27
|
+
console.error(`FAIL: ${label}`);
|
|
28
|
+
if (detail) console.error(detail);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
console.log(` ok ${label.replace(/\[QK:[^\]]+\]\s*/, "")}`);
|
|
32
|
+
passed++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "entwurf-probe-gate-"));
|
|
36
|
+
const stub = (name: string, body: string): string => {
|
|
37
|
+
const p = join(dir, name);
|
|
38
|
+
writeFileSync(p, body);
|
|
39
|
+
chmodSync(p, 0o755);
|
|
40
|
+
return p;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** An MCP stub that answers tools/list with exactly the given verb names. */
|
|
44
|
+
const mcpStub = (name: string, names: readonly string[]): string => {
|
|
45
|
+
const tools = names.map((n) => `{"name":"${n}"}`).join(",");
|
|
46
|
+
return stub(
|
|
47
|
+
name,
|
|
48
|
+
`#!/usr/bin/env bash
|
|
49
|
+
while IFS= read -r line; do
|
|
50
|
+
case "$line" in
|
|
51
|
+
*'"id":1'*) printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
|
|
52
|
+
*'"id":2'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[${tools}]}}' ;;
|
|
53
|
+
esac
|
|
54
|
+
done
|
|
55
|
+
`,
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// ── the healthy shape ─────────────────────────────────────────────────────
|
|
61
|
+
const healthy = mcpStub("healthy", EXPECTED_TOOLS);
|
|
62
|
+
const rHealthy = await probeBridgeCommand({ command: healthy });
|
|
63
|
+
ok("the exact verb set is the only green", rHealthy.ok && rHealthy.reason === "ok", JSON.stringify(rHealthy));
|
|
64
|
+
|
|
65
|
+
// ── identity: booting an MCP server is not being THIS bridge ──────────────
|
|
66
|
+
// Missing one verb is the observed #81 shape (a session whose schema had no entwurf_self).
|
|
67
|
+
const partial = mcpStub(
|
|
68
|
+
"partial",
|
|
69
|
+
EXPECTED_TOOLS.filter((t) => t !== "entwurf_self"),
|
|
70
|
+
);
|
|
71
|
+
const rPartial = await probeBridgeCommand({ command: partial });
|
|
72
|
+
ok(
|
|
73
|
+
"[QK:PROBE-REASON-MISSING-VERB] a served MCP surface MISSING a verb is a mismatch, not ok",
|
|
74
|
+
!rPartial.ok && rPartial.reason === "tool-set-mismatch" && rPartial.detail.includes("missing entwurf_self"),
|
|
75
|
+
JSON.stringify(rPartial),
|
|
76
|
+
);
|
|
77
|
+
// The other direction: a stale build still serving a retired verb must not read as this bridge.
|
|
78
|
+
const extra = mcpStub("extra", [...EXPECTED_TOOLS, "entwurf_retired_verb"]);
|
|
79
|
+
const rExtra = await probeBridgeCommand({ command: extra });
|
|
80
|
+
ok(
|
|
81
|
+
"an EXTRA verb is a mismatch too (stale build / foreign binary)",
|
|
82
|
+
!rExtra.ok && rExtra.reason === "tool-set-mismatch" && rExtra.detail.includes("unexpected entwurf_retired_verb"),
|
|
83
|
+
JSON.stringify(rExtra),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// ── the defect that started #81: resolves, then dies on exec ──────────────
|
|
87
|
+
const dead = stub("dead", "#!/usr/bin/env bash\necho 'boom: no such file or directory' >&2\nexit 127\n");
|
|
88
|
+
const rDead = await probeBridgeCommand({ command: dead });
|
|
89
|
+
ok(
|
|
90
|
+
"a launcher that exits before tools/list is classified as such, with its stderr",
|
|
91
|
+
!rDead.ok && rDead.reason === "exited-before-tools-list" && rDead.detail.includes("boom"),
|
|
92
|
+
JSON.stringify(rDead),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ── not executable at all ─────────────────────────────────────────────────
|
|
96
|
+
const rMissing = await probeBridgeCommand({ command: join(dir, "does-not-exist") });
|
|
97
|
+
ok(
|
|
98
|
+
"an unexecutable command is spawn-failed, NOT a boot failure",
|
|
99
|
+
!rMissing.ok && rMissing.reason === "spawn-failed",
|
|
100
|
+
JSON.stringify(rMissing),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// ── initialize is a handshake, not a frame we merely write ───────────────
|
|
104
|
+
const noInitialize = stub(
|
|
105
|
+
"no-initialize",
|
|
106
|
+
`#!/usr/bin/env bash
|
|
107
|
+
printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[${EXPECTED_TOOLS.map((n) => `{"name":"${n}"}`).join(",")}]}}'
|
|
108
|
+
while IFS= read -r _line; do :; done
|
|
109
|
+
`,
|
|
110
|
+
);
|
|
111
|
+
const rNoInitialize = await probeBridgeCommand({ command: noInitialize, timeoutMs: 700 });
|
|
112
|
+
ok(
|
|
113
|
+
"[QK:PROBE-REQUIRES-INITIALIZE] an exact tools/list sent before initialize completes is not a healthy MCP bridge",
|
|
114
|
+
!rNoInitialize.ok && rNoInitialize.reason === "initialize-failed",
|
|
115
|
+
JSON.stringify(rNoInitialize),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// ── not an MCP server (answers id:2, no tools array) ──────────────────────
|
|
119
|
+
const notMcp = stub(
|
|
120
|
+
"not-mcp",
|
|
121
|
+
`#!/usr/bin/env bash
|
|
122
|
+
while IFS= read -r line; do
|
|
123
|
+
case "$line" in
|
|
124
|
+
*'"id":1'*) printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"not-mcp","version":"0"}}}' ;;
|
|
125
|
+
*'"id":2'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{}}' ;;
|
|
126
|
+
esac
|
|
127
|
+
done
|
|
128
|
+
`,
|
|
129
|
+
);
|
|
130
|
+
const rNotMcp = await probeBridgeCommand({ command: notMcp });
|
|
131
|
+
ok(
|
|
132
|
+
"an id:2 reply without result.tools is not an MCP server",
|
|
133
|
+
!rNotMcp.ok && rNotMcp.reason === "no-tools-array",
|
|
134
|
+
JSON.stringify(rNotMcp),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// ── timeout + bounded cleanup ─────────────────────────────────────────────
|
|
138
|
+
// The probe runs whatever the operator configured, including a launcher that ignores SIGTERM.
|
|
139
|
+
// If it resolved without escalating, that process would outlive the doctor. The stub records
|
|
140
|
+
// its own pid so this gate can assert the reap actually happened — an unobserved kill is a
|
|
141
|
+
// claim, not evidence.
|
|
142
|
+
const pidFile = join(dir, "hung.pid");
|
|
143
|
+
const hung = stub(
|
|
144
|
+
"hung",
|
|
145
|
+
`#!/usr/bin/env bash
|
|
146
|
+
trap '' TERM
|
|
147
|
+
echo $$ > ${JSON.stringify(pidFile)}
|
|
148
|
+
while true; do sleep 0.05; done
|
|
149
|
+
`,
|
|
150
|
+
);
|
|
151
|
+
const rHung = await probeBridgeCommand({ command: hung, timeoutMs: 700 });
|
|
152
|
+
ok(
|
|
153
|
+
"a launcher that stays up but never answers is a timeout",
|
|
154
|
+
!rHung.ok && rHung.reason === "timeout",
|
|
155
|
+
JSON.stringify(rHung),
|
|
156
|
+
);
|
|
157
|
+
const hungPid = Number(readFileSync(pidFile, "utf8").trim());
|
|
158
|
+
ok(
|
|
159
|
+
"timeout stub recorded a usable pid (the cleanup assertion below has a subject)",
|
|
160
|
+
Number.isInteger(hungPid) && hungPid > 0,
|
|
161
|
+
);
|
|
162
|
+
// SIGTERM is trapped, so only the SIGKILL escalation can end it — and the probe must have
|
|
163
|
+
// completed that escalation BEFORE returning. Checking right here (no polling, no grace loop)
|
|
164
|
+
// is what pins that ordering: a probe that merely scheduled the kill would fail this cell,
|
|
165
|
+
// which is exactly the state a doctor's immediate process.exit() would leave behind.
|
|
166
|
+
const alive = spawnSync("kill", ["-0", String(hungPid)], { stdio: "ignore" }).status === 0;
|
|
167
|
+
// Clean up BEFORE asserting. When this claim's mutant re-plants the leak, `alive` is true and
|
|
168
|
+
// the assertion exits the process immediately — so without this line the gate that exists to
|
|
169
|
+
// prove nothing is orphaned would itself orphan the stub on the host, once per qualification
|
|
170
|
+
// run. A killed mutant must still leave zero residue.
|
|
171
|
+
if (alive) spawnSync("kill", ["-9", String(hungPid)], { stdio: "ignore" });
|
|
172
|
+
ok(
|
|
173
|
+
"[QK:PROBE-CLEANUP-ESCALATES] a SIGTERM-ignoring child is escalated to SIGKILL, never left orphaned",
|
|
174
|
+
!alive,
|
|
175
|
+
`pid ${hungPid} was still alive after the probe returned — the probe leaked the process it spawned`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// ── source pin: the stdin EPIPE guard ─────────────────────────────────────
|
|
179
|
+
// WHY pinned instead of measured: the failure needs the child to exec, die and close its stdin
|
|
180
|
+
// read end BETWEEN uv_spawn and the parent's first write. Nothing this gate can write to a stub
|
|
181
|
+
// controls that ordering — measured on this host, five stub shapes (bad shebang, self-closed
|
|
182
|
+
// stdin, instant exit, missing file, a directory) produced EPIPE 0/20 each while idle, and only
|
|
183
|
+
// 12-way CPU contention opened the window (11/60 crashes before the fix, 0/60 after). A cell
|
|
184
|
+
// that only fires under load is a flaky gate, not a proof, so the property is pinned at the
|
|
185
|
+
// source. It is load-bearing because the crash it prevents is silent-by-substitution: the probe
|
|
186
|
+
// dies printing a Node stack trace where the doctor verdict should carry the LAUNCHER's stderr —
|
|
187
|
+
// the one line that names which hop broke. Ordering matters as much as presence: the listener
|
|
188
|
+
// must be installed before any write can dispatch.
|
|
189
|
+
const guard = '\t\tchild.stdin.on("error", () => {});';
|
|
190
|
+
const guardAt = PROBE_SRC.indexOf(guard);
|
|
191
|
+
const firstWriteAt = PROBE_SRC.indexOf("child.stdin.write(");
|
|
192
|
+
ok(
|
|
193
|
+
"[QK:PROBE-STDIN-EPIPE-GUARD] child.stdin carries an error listener installed before the first write — an async EPIPE from a launcher that died on exec must never replace the verdict with an uncaught exception",
|
|
194
|
+
guardAt >= 0 && firstWriteAt >= 0 && guardAt < firstWriteAt,
|
|
195
|
+
`guard index ${guardAt}, first child.stdin.write index ${firstWriteAt} in scripts/probe-bridge-command.ts`,
|
|
196
|
+
);
|
|
197
|
+
} finally {
|
|
198
|
+
rmSync(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(`check-probe-bridge-command: ${passed} checks passed`);
|