@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
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "meta-retire",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "META-RETIRE-COMPARE-TYPE",
|
|
7
|
+
"title": "comparing the current value without its JSON scalar type lets a truthy 1 pass as the true entwurf wrote, so relinquishment overwrites a value that was never ours (#71)",
|
|
8
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
9
|
+
"find": [
|
|
10
|
+
" existed, value = get_nested(settings, path)",
|
|
11
|
+
" if existed and type(value) is type(last_managed_value) and value == last_managed_value:"
|
|
12
|
+
],
|
|
13
|
+
"replace": [" existed, value = get_nested(settings, path)", " if existed and value == last_managed_value:"],
|
|
14
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
15
|
+
"timeoutSeconds": 300,
|
|
16
|
+
"signature": "[QK:META-RETIRE-COMPARE-TYPE]",
|
|
17
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"claim": "META-RETIRE-MALFORMED-SILENT",
|
|
21
|
+
"title": "skipping a malformed retired entry instead of failing discards the only evidence that entwurf owns the key, leaving a still-dangerous value looking operator-clean (#71)",
|
|
22
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
23
|
+
"find": [
|
|
24
|
+
" die(f\"retired scalar state entry {name} is malformed; refusing to discard ownership evidence\")"
|
|
25
|
+
],
|
|
26
|
+
"replace": [" return"],
|
|
27
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
28
|
+
"timeoutSeconds": 300,
|
|
29
|
+
"signature": "[QK:META-RETIRE-MALFORMED-SILENT]",
|
|
30
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"claim": "META-RETIRE-FRESH-TOUCH",
|
|
34
|
+
"title": "synthesising provenance when install-state carries none lets relinquishment rewrite a value the operator set themselves, the exact ownership violation #71 exists to stop",
|
|
35
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
36
|
+
"find": [" if entry is None:", " return"],
|
|
37
|
+
"replace": [
|
|
38
|
+
" if entry is None:",
|
|
39
|
+
" entry = {\"kind\": \"scalar\", \"path\": path, \"original\": {\"existed\": False, \"value\": None}}"
|
|
40
|
+
],
|
|
41
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
42
|
+
"timeoutSeconds": 300,
|
|
43
|
+
"signature": "[QK:META-RETIRE-FRESH-TOUCH]",
|
|
44
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -78,15 +78,59 @@
|
|
|
78
78
|
"claim": "FRESHCALL-RECEIPT-WITHOUT-CORRELATION",
|
|
79
79
|
"title": "the launch receipt grows a sibling garden-id field, making an async correlation look like a synchronous return",
|
|
80
80
|
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
81
|
-
"find": ["\
|
|
82
|
-
"replace": [
|
|
83
|
-
"\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"
|
|
84
|
-
],
|
|
81
|
+
"find": ["\truntimePath: string;\n\tnonce: string;\n}"],
|
|
82
|
+
"replace": ["\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"],
|
|
85
83
|
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
86
84
|
"timeoutSeconds": 120,
|
|
87
85
|
"signature": "[QK:FRESHCALL-RECEIPT-WITHOUT-CORRELATION]",
|
|
88
86
|
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
89
87
|
},
|
|
88
|
+
{
|
|
89
|
+
"claim": "FRESHCALL-CWD-ARGV",
|
|
90
|
+
"title": "the requested cwd stops reaching tmux, so a cross-repo fresh sibling silently opens in the CALLER's repo and the launch looks successful",
|
|
91
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
92
|
+
"find": ["\t\t...(cwd === undefined ? [] : [\"-c\", cwd]),\n"],
|
|
93
|
+
"replace": [""],
|
|
94
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
95
|
+
"timeoutSeconds": 120,
|
|
96
|
+
"signature": "[QK:FRESHCALL-CWD-ARGV]",
|
|
97
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"claim": "FRESHCALL-CWD-REFUSED-PREMUTATION",
|
|
101
|
+
"title": "the pre-mutation cwd classification is ignored, so a relative/'#'/deleted/file path rides on toward tmux instead of refusing by name",
|
|
102
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
103
|
+
"find": ["\t\tif (badCwd) return { ok: false, reason: badCwd };"],
|
|
104
|
+
"replace": ["\t\tvoid badCwd;"],
|
|
105
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
106
|
+
"timeoutSeconds": 120,
|
|
107
|
+
"signature": "[QK:FRESHCALL-CWD-REFUSED-PREMUTATION]",
|
|
108
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"claim": "FRESHCALL-CWD-RECEIPT-REQUESTED",
|
|
112
|
+
"title": "production freshCall stops assembling the requested cwd into its receipt, so the operator-visible answer silently loses the one placement fact the caller asked for",
|
|
113
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
114
|
+
"find": ["\t\t\t...(cwd === undefined ? {} : { cwd }),\n"],
|
|
115
|
+
"replace": [""],
|
|
116
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
117
|
+
"timeoutSeconds": 120,
|
|
118
|
+
"signature": "[QK:FRESHCALL-CWD-RECEIPT-REQUESTED]",
|
|
119
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"claim": "FRESHCALL-CWD-SURFACE-PARITY",
|
|
123
|
+
"title": "one surface drops the optional cwd from its schema, so cross-repo fresh works from pi and silently cannot exist from the MCP bridge",
|
|
124
|
+
"subject": "mcp/entwurf-bridge/src/index.ts",
|
|
125
|
+
"find": [
|
|
126
|
+
"\t\tcwd: z\n\t\t\t.string()\n\t\t\t.optional()\n\t\t\t.describe(\n\t\t\t\t\"Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \\\"\\\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.\",\n\t\t\t),\n"
|
|
127
|
+
],
|
|
128
|
+
"replace": [""],
|
|
129
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
130
|
+
"timeoutSeconds": 120,
|
|
131
|
+
"signature": "[QK:FRESHCALL-CWD-SURFACE-PARITY]",
|
|
132
|
+
"signatureSource": "test/fresh-call-surfaces.contract.test.ts"
|
|
133
|
+
},
|
|
90
134
|
{
|
|
91
135
|
"claim": "FRESHCALL-PI-SURFACE-IDENTITY",
|
|
92
136
|
"title": "native pi takes the caller garden id from the environment instead of its own resident closure — the uuidv7 confusion, reintroduced",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
{
|
|
6
6
|
"claim": "MUXRESUME-CWD-MISSING-REFUSED",
|
|
7
7
|
"title": "a deleted recorded cwd is accepted — tmux then opens the window in $HOME and the wrong-project resume looks successful",
|
|
8
|
-
"subject": "pi-extensions/lib/
|
|
8
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
9
9
|
"find": ["\t\treturn \"cwd-missing\";"],
|
|
10
10
|
"replace": ["\t\treturn null;"],
|
|
11
11
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"claim": "MUXRESUME-CWD-FORMAT-REFUSED",
|
|
18
18
|
"title": "the '#' refusal stops matching, so tmux format-expands the path it was handed",
|
|
19
|
-
"subject": "pi-extensions/lib/
|
|
19
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
20
20
|
"find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
|
|
21
21
|
"replace": ["\tif (cwd.includes(\"\\u0000\")) return \"cwd-format-token\";"],
|
|
22
22
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"claim": "MUXRESUME-CWD-WHITESPACE-OK",
|
|
29
29
|
"title": "a quoting fear is added back as a whitespace refusal, which would reject real project directories tmux handles fine",
|
|
30
|
-
"subject": "pi-extensions/lib/
|
|
30
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
31
31
|
"find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
|
|
32
32
|
"replace": ["\tif (cwd.includes(\"#\") || /\\s/.test(cwd)) return \"cwd-format-token\";"],
|
|
33
33
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|
|
@@ -135,6 +135,19 @@
|
|
|
135
135
|
"timeoutSeconds": 180,
|
|
136
136
|
"signature": "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST]",
|
|
137
137
|
"signatureSource": "scripts/check-release-gate-outcomes.ts"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"claim": "PI-DOCTOR-IS-RELEASE-MUST",
|
|
141
|
+
"title": "the pi-provider boot doctor is quietly dropped from the gate — the operator's CONFIGURED bridge invocation is never booted, so a 127 launcher (the #81 relocated cmd-shim class) stays invisible until smoke-acp-bundled-mcp-live sixteen LIVE steps and real model spend later",
|
|
142
|
+
"subject": "run.sh",
|
|
143
|
+
"find": [
|
|
144
|
+
" run_step \"doctor-pi-provider (#81: the operator's CONFIGURED bridge invocation actually boots)\" gate bash \"$self\" doctor-pi-provider"
|
|
145
|
+
],
|
|
146
|
+
"replace": [" : # pi doctor quietly dropped — the configured invocation is never booted"],
|
|
147
|
+
"gate": ["bash", "run.sh", "check-release-gate-outcomes"],
|
|
148
|
+
"timeoutSeconds": 180,
|
|
149
|
+
"signature": "[QK:PI-DOCTOR-IS-RELEASE-MUST]",
|
|
150
|
+
"signatureSource": "scripts/check-release-gate-outcomes.ts"
|
|
138
151
|
}
|
|
139
152
|
]
|
|
140
153
|
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// probe-bridge-command — does the EFFECTIVE bridge command actually BOOT and serve MCP?
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS (#81): every doctor that judged the bridge wiring asked only whether the
|
|
5
|
+
// configured command RESOLVES — `command -v <bare name>` for pi (doctor-pi-provider) and agy
|
|
6
|
+
// (agy-bridge.sh). Resolvability is not bootability, and the gap is not theoretical: on the
|
|
7
|
+
// reference host `~/.local/bin/entwurf-bridge` was a symlink to the canonical pnpm shim, and
|
|
8
|
+
// that shim derives its target from `$0`. Invoked through the relocated link the derived
|
|
9
|
+
// `../global/...` path does not exist, so the launcher exits 127 and the MCP child never boots
|
|
10
|
+
// — while `command -v` kept answering yes and both doctors kept printing ok. The model in a
|
|
11
|
+
// Pi-hosted ACP turn then had no `mcp__entwurf-bridge__*` tool at all, which is a direct
|
|
12
|
+
// violation of #81's minimum core value (explicit Entwurf bridge connectivity).
|
|
13
|
+
//
|
|
14
|
+
// So this leaf asks the only question that carries the claim: run the command, speak MCP to it,
|
|
15
|
+
// and require the entwurf tool surface back. A green here is about the command the harness
|
|
16
|
+
// actually execs — never a recomputed desired one (the same rule meta-bridge-doctor's delivery
|
|
17
|
+
// self-diagnostic already states for the Claude lane).
|
|
18
|
+
//
|
|
19
|
+
// SIDE EFFECTS: none on operator state. Only `initialize` + `tools/list` are sent — never a
|
|
20
|
+
// `tools/call` — so no lock is taken, no record is written, and no message is delivered. The
|
|
21
|
+
// child is killed as soon as the frame arrives or the deadline passes. This is what makes the
|
|
22
|
+
// probe affordable inside a doctor an operator runs repeatedly.
|
|
23
|
+
//
|
|
24
|
+
// USAGE
|
|
25
|
+
// library: const r = await probeBridgeCommand({ command, args }); // doctor-pi-provider.ts
|
|
26
|
+
// CLI: node probe-bridge-command.ts <command> [args...]
|
|
27
|
+
// node probe-bridge-command.ts --invocation-json '{"command":"…","args":[],"env":{}}'
|
|
28
|
+
// exit 0 = booted and served the entwurf tool surface; 1 = did not (reason on stdout).
|
|
29
|
+
import { spawn } from "node:child_process";
|
|
30
|
+
import { basename } from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Why a probe failed. Each value is a DISTINCT operator situation, never a generic "broken". */
|
|
33
|
+
export type BridgeProbeReason =
|
|
34
|
+
| "ok"
|
|
35
|
+
| "spawn-failed" // the command could not be executed at all (ENOENT / EACCES)
|
|
36
|
+
| "exited-before-tools-list" // it ran and DIED — the relocated-shim 127 class lands here
|
|
37
|
+
| "initialize-failed" // it did not complete the MCP initialize handshake before tools/list
|
|
38
|
+
| "timeout" // it stayed up but never answered — a hung/wrong binary
|
|
39
|
+
| "no-tools-array" // it answered id:2 without result.tools — not an MCP server
|
|
40
|
+
| "tool-set-mismatch"; // an MCP server, but not THIS bridge (foreign binary, or a stale build)
|
|
41
|
+
|
|
42
|
+
export interface BridgeProbeResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
reason: BridgeProbeReason;
|
|
45
|
+
/** One line, safe to print in a doctor verdict. Carries rc/signal/stderr head when relevant. */
|
|
46
|
+
detail: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BridgeProbeOptions {
|
|
50
|
+
command: string;
|
|
51
|
+
args?: string[];
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The EXACT public verb set of the current bridge. Identity is the whole set, not one member:
|
|
57
|
+
// the #81 symptom was a session whose schema lacked `entwurf_self` specifically, and a probe that
|
|
58
|
+
// only looked for `entwurf_v2` would have called that host healthy. Exactness also catches the
|
|
59
|
+
// other direction — a STALE build still serving a retired verb, or a foreign binary that happens
|
|
60
|
+
// to expose an `entwurf_v2`. check-entwurf-bridge-boot binds this list to the real runtime
|
|
61
|
+
// tools/list, so a verb added or retired without updating it turns that gate red.
|
|
62
|
+
export const EXPECTED_TOOLS = [
|
|
63
|
+
"entwurf_v2",
|
|
64
|
+
"entwurf_self",
|
|
65
|
+
"entwurf_peers",
|
|
66
|
+
"entwurf_inbox_read",
|
|
67
|
+
"entwurf_register_native",
|
|
68
|
+
"entwurf_fresh_call",
|
|
69
|
+
"entwurf_resume_call",
|
|
70
|
+
] as const;
|
|
71
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
72
|
+
/** How long a probed child gets to die politely before it is killed outright. */
|
|
73
|
+
const CLEANUP_GRACE_MS = 500;
|
|
74
|
+
|
|
75
|
+
/** Collapse captured stderr into one printable line — a doctor verdict must stay one line. */
|
|
76
|
+
function head(text: string, limit = 200): string {
|
|
77
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
78
|
+
return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function probeBridgeCommand(opts: BridgeProbeOptions): Promise<BridgeProbeResult> {
|
|
82
|
+
const { command, args = [], env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
// No shell: a bare name goes through the normal PATH lookup the harness itself does,
|
|
85
|
+
// and an argument can never be reinterpreted as shell syntax.
|
|
86
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
87
|
+
// `write()` reports a synchronous closed pipe by throwing, but a launcher that dies on exec
|
|
88
|
+
// closes stdin under us and the resulting EPIPE arrives ASYNCHRONOUSLY, as an 'error' event on
|
|
89
|
+
// the stream. Unlistened, Node turns that into an uncaught exception, so the probe dies
|
|
90
|
+
// printing its own stack trace INSTEAD of the launcher's stderr: precisely the diagnosis #81
|
|
91
|
+
// exists to surface, destroyed in the one cell that needs it. The window is real, not
|
|
92
|
+
// theoretical — under CPU contention the child can exec, write its stderr and exit between
|
|
93
|
+
// uv_spawn and our first write, which is how CI saw a doctor verdict with no launcher stderr
|
|
94
|
+
// in it. The `close` handler owns the verdict for that child (with captured stderr), so this
|
|
95
|
+
// listener is installed before anything can write and stays deliberately silent — one reason
|
|
96
|
+
// per failure, never an unhandled probe crash.
|
|
97
|
+
child.stdin.on("error", () => {});
|
|
98
|
+
let stdoutPending = "";
|
|
99
|
+
let stderr = "";
|
|
100
|
+
let settled = false;
|
|
101
|
+
let initialized = false;
|
|
102
|
+
let exited: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
|
103
|
+
let stderrEnded = false;
|
|
104
|
+
|
|
105
|
+
// This probe deliberately runs whatever command the operator configured — including a
|
|
106
|
+
// foreign or broken one — so it owns closing what it opened. Escalate SIGTERM → SIGKILL
|
|
107
|
+
// once, bounded: cleanup for THIS child, not a supervisor (no retries, no watching, no
|
|
108
|
+
// process-tree walking).
|
|
109
|
+
//
|
|
110
|
+
// The reap is AWAITED before the result resolves, and that ordering is load-bearing: the
|
|
111
|
+
// callers are doctors that print a verdict and `process.exit()` immediately, which tears
|
|
112
|
+
// down any still-pending timer. A fire-and-forget SIGTERM plus a deferred SIGKILL would
|
|
113
|
+
// therefore leave a TERM-ignoring launcher running after the doctor is gone — the probe
|
|
114
|
+
// would leak exactly the kind of broken process it exists to detect.
|
|
115
|
+
const reap = (): Promise<void> =>
|
|
116
|
+
new Promise((finished) => {
|
|
117
|
+
if (child.exitCode !== null || child.signalCode !== null) return finished();
|
|
118
|
+
let reaped = false;
|
|
119
|
+
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
const finish = (): void => {
|
|
121
|
+
if (reaped) return;
|
|
122
|
+
reaped = true;
|
|
123
|
+
if (hard) clearTimeout(hard);
|
|
124
|
+
finished();
|
|
125
|
+
};
|
|
126
|
+
child.once("close", finish);
|
|
127
|
+
try {
|
|
128
|
+
child.kill("SIGTERM");
|
|
129
|
+
} catch {
|
|
130
|
+
return finish(); // already gone
|
|
131
|
+
}
|
|
132
|
+
hard = setTimeout(() => {
|
|
133
|
+
try {
|
|
134
|
+
child.kill("SIGKILL");
|
|
135
|
+
} catch {}
|
|
136
|
+
// SIGKILL cannot be trapped, so `close` follows. The outer bound exists only so a
|
|
137
|
+
// child already reaped by someone else can never hang this promise forever.
|
|
138
|
+
setTimeout(finish, CLEANUP_GRACE_MS);
|
|
139
|
+
}, CLEANUP_GRACE_MS);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const done = (reason: BridgeProbeReason, detail: string): void => {
|
|
143
|
+
if (settled) return;
|
|
144
|
+
settled = true;
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
void reap().then(() => resolve({ ok: reason === "ok", reason, detail }));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
const waitingFor = initialized ? "tools/list" : "initialize";
|
|
151
|
+
done(
|
|
152
|
+
"timeout",
|
|
153
|
+
`'${command}' stayed up but never answered ${waitingFor} within ${timeoutMs}ms` +
|
|
154
|
+
(stderr.trim() ? ` — stderr: ${head(stderr)}` : ""),
|
|
155
|
+
);
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
|
|
158
|
+
child.on("error", (err) => {
|
|
159
|
+
done("spawn-failed", `'${command}' could not be executed: ${String(err)}`);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// `ChildProcess` close is normally after the stdio pipes close, but under nested shell
|
|
163
|
+
// capture that ordering has raced on this host. Do not mint a no-stderr diagnosis until the
|
|
164
|
+
// stderr reader ended: the launcher's own final diagnostic is part of the operator verdict.
|
|
165
|
+
const reportExited = (): void => {
|
|
166
|
+
if (!exited || !stderrEnded) return;
|
|
167
|
+
// Reaching here un-settled means the process died before answering id:2. This is the
|
|
168
|
+
// cell the relocated pnpm shim lands in (exit 127), and the stderr head is what tells
|
|
169
|
+
// the operator WHICH launcher hop broke.
|
|
170
|
+
done(
|
|
171
|
+
"exited-before-tools-list",
|
|
172
|
+
`'${command}' exited before answering tools/list (code=${exited.code} signal=${String(exited.signal)})` +
|
|
173
|
+
(stderr.trim() ? ` — stderr: ${head(stderr)}` : " — no stderr"),
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
child.on("close", (code, signal) => {
|
|
177
|
+
exited = { code, signal };
|
|
178
|
+
reportExited();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
child.stderr.on("data", (d) => {
|
|
182
|
+
stderr += String(d);
|
|
183
|
+
});
|
|
184
|
+
child.stderr.on("end", () => {
|
|
185
|
+
stderrEnded = true;
|
|
186
|
+
reportExited();
|
|
187
|
+
});
|
|
188
|
+
child.stdout.on("data", (d) => {
|
|
189
|
+
const chunk = String(d);
|
|
190
|
+
stdoutPending += chunk;
|
|
191
|
+
if (settled) return;
|
|
192
|
+
// MCP frames are newline-delimited JSON-RPC. Consume each complete frame exactly once:
|
|
193
|
+
// replaying the accumulated id:1 response on a later data event would send a second
|
|
194
|
+
// tools/list before the first reply and stop being a sequential handshake.
|
|
195
|
+
const frames = stdoutPending.split("\n");
|
|
196
|
+
stdoutPending = frames.pop() ?? "";
|
|
197
|
+
for (const line of frames) {
|
|
198
|
+
const trimmed = line.trim();
|
|
199
|
+
if (!trimmed) continue;
|
|
200
|
+
let msg: {
|
|
201
|
+
id?: unknown;
|
|
202
|
+
error?: unknown;
|
|
203
|
+
result?: { protocolVersion?: unknown; capabilities?: unknown; serverInfo?: unknown; tools?: unknown };
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
msg = JSON.parse(trimmed);
|
|
207
|
+
} catch {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (msg?.id === 1) {
|
|
211
|
+
const result = msg.result;
|
|
212
|
+
const valid =
|
|
213
|
+
msg.error === undefined &&
|
|
214
|
+
result?.protocolVersion === "2024-11-05" &&
|
|
215
|
+
typeof result.capabilities === "object" &&
|
|
216
|
+
result.capabilities !== null &&
|
|
217
|
+
typeof result.serverInfo === "object" &&
|
|
218
|
+
result.serverInfo !== null;
|
|
219
|
+
if (!valid) {
|
|
220
|
+
done("initialize-failed", `'${command}' returned an invalid MCP initialize response`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
initialized = true;
|
|
224
|
+
sendReadyFrames();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (msg?.id !== 2) continue;
|
|
228
|
+
if (!initialized) {
|
|
229
|
+
done("initialize-failed", `'${command}' answered tools/list before MCP initialize completed`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const tools = msg?.result?.tools;
|
|
233
|
+
if (!Array.isArray(tools)) {
|
|
234
|
+
done("no-tools-array", `'${command}' answered tools/list without result.tools — not an MCP server`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const names = tools
|
|
238
|
+
.map((t) => (t as { name?: unknown })?.name)
|
|
239
|
+
.filter((n): n is string => typeof n === "string");
|
|
240
|
+
const served = new Set(names);
|
|
241
|
+
const missing = EXPECTED_TOOLS.filter((t) => !served.has(t));
|
|
242
|
+
const unexpected = names.filter((n) => !(EXPECTED_TOOLS as readonly string[]).includes(n));
|
|
243
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
244
|
+
// Name BOTH directions: a missing verb is the #81 symptom (the model had no
|
|
245
|
+
// entwurf_self), an unexpected one means this is not the bridge we ship.
|
|
246
|
+
const parts: string[] = [];
|
|
247
|
+
if (missing.length > 0) parts.push(`missing ${missing.join(", ")}`);
|
|
248
|
+
if (unexpected.length > 0) parts.push(`unexpected ${unexpected.join(", ")}`);
|
|
249
|
+
done(
|
|
250
|
+
"tool-set-mismatch",
|
|
251
|
+
`'${command}' is an MCP server but does not serve this bridge's verb set — ${parts.join("; ")} (served: ${names.join(", ") || "none"})`,
|
|
252
|
+
);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
done("ok", `'${command}' booted and served the exact entwurf verb set (${EXPECTED_TOOLS.length} tools)`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const send = (obj: unknown): void => {
|
|
261
|
+
try {
|
|
262
|
+
child.stdin.write(`${JSON.stringify(obj)}\n`);
|
|
263
|
+
} catch {
|
|
264
|
+
// Synchronous throws (a destroyed stream) land here; async EPIPE lands on the
|
|
265
|
+
// listener above. Both are the same verdict-free silence.
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const sendReadyFrames = (): void => {
|
|
269
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
270
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
271
|
+
};
|
|
272
|
+
send({
|
|
273
|
+
jsonrpc: "2.0",
|
|
274
|
+
id: 1,
|
|
275
|
+
method: "initialize",
|
|
276
|
+
params: {
|
|
277
|
+
protocolVersion: "2024-11-05",
|
|
278
|
+
capabilities: {},
|
|
279
|
+
clientInfo: { name: "probe-bridge-command", version: "0" },
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// CLI entrypoint. Kept argv-driven (no env carrier) so `./run.sh probe-bridge-command entwurf-bridge`
|
|
286
|
+
// asks about exactly the name an operator typed, and a doctor can shell out to the same verdict.
|
|
287
|
+
// Match the BASENAME exactly. A suffix test would also fire for `check-probe-bridge-command.ts`,
|
|
288
|
+
// which imports this module — the CLI arm would then hijack that gate's argv and exit 2.
|
|
289
|
+
if (process.argv[1] && /^probe-bridge-command\.(ts|js)$/.test(basename(process.argv[1]))) {
|
|
290
|
+
const argv = process.argv.slice(2);
|
|
291
|
+
let options: BridgeProbeOptions;
|
|
292
|
+
if (argv[0] === "--invocation-json") {
|
|
293
|
+
let raw: unknown;
|
|
294
|
+
try {
|
|
295
|
+
raw = JSON.parse(argv[1] ?? "");
|
|
296
|
+
} catch {
|
|
297
|
+
console.error("probe-bridge-command: --invocation-json must be valid JSON");
|
|
298
|
+
process.exit(2);
|
|
299
|
+
}
|
|
300
|
+
const obj = raw as { command?: unknown; args?: unknown; env?: unknown };
|
|
301
|
+
if (
|
|
302
|
+
typeof obj?.command !== "string" ||
|
|
303
|
+
!obj.command ||
|
|
304
|
+
!Array.isArray(obj.args) ||
|
|
305
|
+
!obj.args.every((arg) => typeof arg === "string") ||
|
|
306
|
+
typeof obj.env !== "object" ||
|
|
307
|
+
obj.env === null ||
|
|
308
|
+
Array.isArray(obj.env) ||
|
|
309
|
+
!Object.values(obj.env).every((value) => typeof value === "string")
|
|
310
|
+
) {
|
|
311
|
+
console.error("probe-bridge-command: invocation JSON requires string command, string[] args, string-map env");
|
|
312
|
+
process.exit(2);
|
|
313
|
+
}
|
|
314
|
+
options = {
|
|
315
|
+
command: obj.command,
|
|
316
|
+
args: obj.args as string[],
|
|
317
|
+
env: { ...process.env, ...(obj.env as Record<string, string>) },
|
|
318
|
+
};
|
|
319
|
+
} else {
|
|
320
|
+
const [command, ...args] = argv;
|
|
321
|
+
if (!command) {
|
|
322
|
+
console.error("usage: probe-bridge-command <command> [args...]");
|
|
323
|
+
process.exit(2);
|
|
324
|
+
}
|
|
325
|
+
options = { command, args };
|
|
326
|
+
}
|
|
327
|
+
const result = await probeBridgeCommand(options);
|
|
328
|
+
console.log(`[probe-bridge-command] ${result.reason}: ${result.detail}`);
|
|
329
|
+
process.exit(result.ok ? 0 : 1);
|
|
330
|
+
}
|