@junghanacs/entwurf 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +34 -0
- package/DELIVERY.md +57 -0
- package/README.md +1 -1
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junghanacs/entwurf",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -74,23 +74,23 @@
|
|
|
74
74
|
"claudeCodeFloor": ">=2.1.217"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
77
|
+
"@agentclientprotocol/claude-agent-acp": "0.70.0",
|
|
78
78
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
79
79
|
"@anthropic-ai/sdk": "0.100.1",
|
|
80
80
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
81
81
|
"zod": "^3.25.0 || ^4.0.0"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
|
-
"@earendil-works/pi-ai": ">=0.84.
|
|
85
|
-
"@earendil-works/pi-coding-agent": ">=0.84.
|
|
86
|
-
"@earendil-works/pi-tui": ">=0.84.
|
|
84
|
+
"@earendil-works/pi-ai": ">=0.84.2 <0.85",
|
|
85
|
+
"@earendil-works/pi-coding-agent": ">=0.84.2 <0.85",
|
|
86
|
+
"@earendil-works/pi-tui": ">=0.84.2 <0.85",
|
|
87
87
|
"typebox": "*"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@biomejs/biome": "^2.4.13",
|
|
91
|
-
"@earendil-works/pi-ai": "0.84.
|
|
92
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
93
|
-
"@earendil-works/pi-tui": "0.84.
|
|
91
|
+
"@earendil-works/pi-ai": "0.84.2",
|
|
92
|
+
"@earendil-works/pi-coding-agent": "0.84.2",
|
|
93
|
+
"@earendil-works/pi-tui": "0.84.2",
|
|
94
94
|
"@types/node": "^24.3.0",
|
|
95
95
|
"husky": "^9.1.7",
|
|
96
96
|
"rregex": "1.13.1",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"check:toolchain": "pnpm lint && pnpm typecheck",
|
|
146
146
|
"check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface",
|
|
147
147
|
"check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-resume-args && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment",
|
|
148
|
-
"check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
|
|
148
|
+
"check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
|
|
149
149
|
"check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
|
|
150
150
|
},
|
|
151
151
|
"pi": {
|
|
@@ -161,6 +161,35 @@ interface AcpBridgeEvent {
|
|
|
161
161
|
decision?: "approved" | "cancelled";
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A one-shot latch for "the child has ended", created at spawn and settled by
|
|
166
|
+
* `onChildGone`. `settled` answers the question with no waiting at all; the
|
|
167
|
+
* promise is only for the bounded post-mortem window (settleChildEnd).
|
|
168
|
+
*/
|
|
169
|
+
interface ChildEndLatch {
|
|
170
|
+
settled: boolean;
|
|
171
|
+
promise: Promise<void>;
|
|
172
|
+
/** Called exactly once by onChildGone; cleared so a second end is a no-op. */
|
|
173
|
+
settle?: () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function makeChildEndLatch(): ChildEndLatch {
|
|
177
|
+
let settle!: () => void;
|
|
178
|
+
const promise = new Promise<void>((resolve) => {
|
|
179
|
+
settle = resolve;
|
|
180
|
+
});
|
|
181
|
+
const latch: ChildEndLatch = {
|
|
182
|
+
settled: false,
|
|
183
|
+
promise,
|
|
184
|
+
settle: () => {
|
|
185
|
+
latch.settled = true;
|
|
186
|
+
latch.settle = undefined;
|
|
187
|
+
settle();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
return latch;
|
|
191
|
+
}
|
|
192
|
+
|
|
164
193
|
interface BridgeSession {
|
|
165
194
|
key: string;
|
|
166
195
|
cwd: string;
|
|
@@ -183,8 +212,36 @@ interface BridgeSession {
|
|
|
183
212
|
stderrTail: string[];
|
|
184
213
|
/** How the child ended, once it has — folded into the prompt-phase error. */
|
|
185
214
|
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
215
|
+
/**
|
|
216
|
+
* LATCH for the child's end, readable at ANY time — deliberately not a
|
|
217
|
+
* callback.
|
|
218
|
+
*
|
|
219
|
+
* `notifyChildGone` lives only between the two lines of
|
|
220
|
+
* `awaitAcpPromptTurn`'s try/finally, so it can carry the exit status only
|
|
221
|
+
* when the child's `exit` event wins the race against the transport — and on
|
|
222
|
+
* the shape this backend actually runs, it does not. With piped stdio on
|
|
223
|
+
* Linux the child's stdout EOF was measured landing about a millisecond BEFORE
|
|
224
|
+
* node emits `exit`, for a clean exit and for SIGKILL alike (issue #72). The
|
|
225
|
+
* SDK's generic "ACP connection closed" therefore settles the race first,
|
|
226
|
+
* `finally` clears the callback, and the exit status arriving one tick later
|
|
227
|
+
* has nowhere to go — which is how #72's field sample reached the operator
|
|
228
|
+
* naming neither exit code nor signal. The opposite order stays possible and
|
|
229
|
+
* is still handled (notifyChildGone), so both are covered by the gate.
|
|
230
|
+
*
|
|
231
|
+
* The latch outlives the race: `settled` is the durable fact and `promise`
|
|
232
|
+
* lets a failing turn wait a BOUNDED moment for a late end (settleChildEnd).
|
|
233
|
+
*/
|
|
234
|
+
childEnd: ChildEndLatch;
|
|
186
235
|
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
187
236
|
notifyChildGone?: (err: Error) => void;
|
|
237
|
+
/**
|
|
238
|
+
* This TURN owns reporting the child's end, so the next turn must not also
|
|
239
|
+
* announce it. Raised at the top of a failure path — BEFORE the bounded
|
|
240
|
+
* settle and before any teardown — so an end observed during that window is
|
|
241
|
+
* still a natural one (we have not signalled anything yet) but is reported
|
|
242
|
+
* exactly once, by the turn that failed on it.
|
|
243
|
+
*/
|
|
244
|
+
reporting?: boolean;
|
|
188
245
|
/**
|
|
189
246
|
* We are tearing this child down ON PURPOSE (turn-scoped teardown, config
|
|
190
247
|
* drift, error/abort cleanup). Its exit is then expected, not news: without
|
|
@@ -229,10 +286,13 @@ function onChildGone(session: BridgeSession, exit?: { code: number | null; signa
|
|
|
229
286
|
if (exit) session.exit = exit;
|
|
230
287
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
231
288
|
retainedChildren.delete(session.child);
|
|
289
|
+
// Close the latch FIRST and unconditionally: it is the durable fact, and a
|
|
290
|
+
// failing turn may already be waiting on it inside its bounded settle window.
|
|
291
|
+
session.childEnd.settle?.();
|
|
232
292
|
if (session.notifyChildGone) {
|
|
233
293
|
// A turn was waiting on this child — it reports the death itself.
|
|
234
294
|
session.notifyChildGone(childEndedError(session));
|
|
235
|
-
} else if (!session.retiring) {
|
|
295
|
+
} else if (!session.retiring && !session.reporting) {
|
|
236
296
|
// Died BETWEEN turns with no turn to fail and nobody tearing it down, so
|
|
237
297
|
// nobody has seen it. Without this the next turn would silently open a
|
|
238
298
|
// fresh child and read as an ordinary cold start, hiding that the backend
|
|
@@ -281,6 +341,139 @@ function childEndedError(session: BridgeSession): Error {
|
|
|
281
341
|
);
|
|
282
342
|
}
|
|
283
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The BOUNDED post-mortem window a turn that ALREADY failed may wait for the
|
|
346
|
+
* child's exit status.
|
|
347
|
+
*
|
|
348
|
+
* This is NOT a prompt deadline and must never become one: nothing here can end
|
|
349
|
+
* a running turn. It opens only after the prompt has already settled as a
|
|
350
|
+
* transport closure, and it closes on the child's own `exit` — which follows the
|
|
351
|
+
* stdout EOF by about a millisecond, so this is headroom, not a felt wait.
|
|
352
|
+
*/
|
|
353
|
+
const CHILD_END_SETTLE_MS = 500;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The SDK's exact words for "the transport ended under a pending request"
|
|
357
|
+
* (`@agentclientprotocol/sdk` jsonrpc.js / acp.js:
|
|
358
|
+
* `closeSignal.reason ?? new Error("ACP connection closed")`).
|
|
359
|
+
*
|
|
360
|
+
* Matched EXACTLY, not by substring: this text appears only when the close
|
|
361
|
+
* carried NO reason — a clean stdout EOF rather than an errored stream — which
|
|
362
|
+
* is the one failure shape that arrives with no lifecycle facts of its own. A
|
|
363
|
+
* close that DID carry a reason already explains itself and must not be given a
|
|
364
|
+
* settle delay, and our own `childEndedError` already names the exit status.
|
|
365
|
+
* Widening this to a substring test would put that delay on errors that do not
|
|
366
|
+
* need it.
|
|
367
|
+
*/
|
|
368
|
+
const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
|
|
369
|
+
|
|
370
|
+
function isAcpConnectionClosure(err: unknown): boolean {
|
|
371
|
+
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
372
|
+
return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Wait a BOUNDED moment for a child end that the transport already implied.
|
|
377
|
+
*
|
|
378
|
+
* Resolves immediately when the latch is already closed (the common case once
|
|
379
|
+
* the ~1ms gap has passed) and never rejects. The timer is deliberately NOT
|
|
380
|
+
* unref'd: it is the only thing that ends this wait, so letting the loop drain
|
|
381
|
+
* past it would leave a failing turn unsealed — the opposite of the honesty this
|
|
382
|
+
* exists for. It is cleared on both exits, so it holds nothing open.
|
|
383
|
+
*/
|
|
384
|
+
async function settleChildEnd(latch: ChildEndLatch, ms: number): Promise<void> {
|
|
385
|
+
if (latch.settled) return;
|
|
386
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
+
try {
|
|
388
|
+
await Promise.race([
|
|
389
|
+
latch.promise,
|
|
390
|
+
new Promise<void>((resolve) => {
|
|
391
|
+
timer = setTimeout(resolve, ms);
|
|
392
|
+
}),
|
|
393
|
+
]);
|
|
394
|
+
} finally {
|
|
395
|
+
if (timer) clearTimeout(timer);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The lifecycle line appended to a transport-closure failure, so the operator
|
|
401
|
+
* reads WHICH phase died and HOW the child ended.
|
|
402
|
+
*
|
|
403
|
+
* Wording is load-bearing for the same reason `childEndedError`'s is: pi
|
|
404
|
+
* classifies a failed assistant message against `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
405
|
+
* (@earendil-works/pi-ai `utils/retry`), whose terms include "timed out",
|
|
406
|
+
* "timeout", "terminated", "connection lost" and "ended without". None of those
|
|
407
|
+
* may appear here, or a dead child would put a full cold prompt replay back on
|
|
408
|
+
* the table — with the tool side effects this turn already produced.
|
|
409
|
+
*
|
|
410
|
+
* That pattern is a bare substring alternation (`new RegExp(terms.join("|"), "i")`
|
|
411
|
+
* — NO word boundaries) and its terms enumerate the HTTP statuses "429", "500",
|
|
412
|
+
* "502", "503", "504" and "524". So the settle bound is deliberately NOT interpolated: rendering
|
|
413
|
+
* CHILD_END_SETTLE_MS made this line say "within 500ms", which pi read as an
|
|
414
|
+
* HTTP 500 and classified as transient. Naming the window in prose keeps a later
|
|
415
|
+
* change of that constant from silently re-arming the replay — do not "improve"
|
|
416
|
+
* this by putting the number back; the bound belongs in the code and the gate.
|
|
417
|
+
*
|
|
418
|
+
* The three endings are kept DISTINCT on purpose (issue #72 Done-when): an
|
|
419
|
+
* exit code, a signal, and "we waited and it never said" are three different
|
|
420
|
+
* facts, and collapsing them is what made the original sample unreadable.
|
|
421
|
+
*/
|
|
422
|
+
function childEndLifecycleLine(opts: {
|
|
423
|
+
/** "pre-prompt" covers a new turn's bootstrap AND a reuse turn's pre-send steps. */
|
|
424
|
+
phase: "pre-prompt" | "prompt";
|
|
425
|
+
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
426
|
+
ended: boolean;
|
|
427
|
+
}): string {
|
|
428
|
+
const where =
|
|
429
|
+
opts.phase === "prompt"
|
|
430
|
+
? "the ACP backend connection closed while the prompt was still in flight"
|
|
431
|
+
: "the ACP backend connection closed before this turn's prompt was sent";
|
|
432
|
+
const how = opts.ended
|
|
433
|
+
? `the child ended (${describeChildEnd(opts.exit)})`
|
|
434
|
+
: "the child reported no exit status within the bounded post-mortem window";
|
|
435
|
+
return `[acp] lifecycle: ${where} — ${how}; this turn has no answer`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The failure path's post-mortem: enrich ONLY the transport-closure shape, and
|
|
440
|
+
* only after waiting a bounded moment for the exit status the close implies.
|
|
441
|
+
*
|
|
442
|
+
* Returns `undefined` for every other failure — an abort, a bootstrap throw, or
|
|
443
|
+
* a child death our own `notifyChildGone` already diagnosed — so no other error
|
|
444
|
+
* pays a delay and none is double-reported.
|
|
445
|
+
*
|
|
446
|
+
* Callers MUST run this BEFORE tearing the child down: our teardown SIGTERMs the
|
|
447
|
+
* process group, so an exit read after it would be OUR signal recorded as the
|
|
448
|
+
* child's cause of death.
|
|
449
|
+
*
|
|
450
|
+
* The stderr claim is deliberately NARROW. Running before teardown means the tail
|
|
451
|
+
* already collected is not cut short by our own cleanup, and stderr arriving
|
|
452
|
+
* during this window still lands in it. It does NOT promise the child's last
|
|
453
|
+
* words: node's `exit` can precede the stdio drain, and nothing here waits for
|
|
454
|
+
* the stderr pipe to close. Draining it is a separate lever, not this one.
|
|
455
|
+
*/
|
|
456
|
+
async function diagnoseTransportClosure(opts: {
|
|
457
|
+
err: unknown;
|
|
458
|
+
session: BridgeSession | undefined;
|
|
459
|
+
phase: "pre-prompt" | "prompt";
|
|
460
|
+
aborted: boolean;
|
|
461
|
+
}): Promise<string | undefined> {
|
|
462
|
+
if (opts.aborted || !opts.session) return undefined;
|
|
463
|
+
if (!isAcpConnectionClosure(opts.err)) return undefined;
|
|
464
|
+
const session = opts.session;
|
|
465
|
+
// This turn owns the announcement from here on, so the next turn must not
|
|
466
|
+
// repeat it. Raised BEFORE the wait and before any signal of ours, so an end
|
|
467
|
+
// observed inside the window is still a natural one.
|
|
468
|
+
session.reporting = true;
|
|
469
|
+
await settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);
|
|
470
|
+
return childEndLifecycleLine({
|
|
471
|
+
phase: opts.phase,
|
|
472
|
+
exit: session.exit,
|
|
473
|
+
ended: session.childEnd.settled,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
284
477
|
// ---------------------------------------------------------------------------
|
|
285
478
|
// timeout / launch / permission / stopReason / teardown helpers
|
|
286
479
|
// ---------------------------------------------------------------------------
|
|
@@ -728,12 +921,16 @@ export function streamAcpTurn(
|
|
|
728
921
|
stream.end();
|
|
729
922
|
}
|
|
730
923
|
|
|
731
|
-
function finishError(err: unknown, aborted: boolean, stderrTail?: string[]): void {
|
|
924
|
+
function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
|
|
732
925
|
finalizeAcpStreamState(state);
|
|
733
926
|
state.output.stopReason = aborted ? "aborted" : "error";
|
|
734
927
|
const base = err instanceof Error ? err.message : String(err);
|
|
928
|
+
// The FIRST failure stays first and verbatim (it is what the backend
|
|
929
|
+
// actually said); the lifecycle line is added, never substituted, so a
|
|
930
|
+
// reader can still match the transport's own text.
|
|
931
|
+
const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
|
|
735
932
|
const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
|
|
736
|
-
const full = tail ? `${
|
|
933
|
+
const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
|
|
737
934
|
// A-c: a real failure (not an abort) that looks like a context-window
|
|
738
935
|
// overflow gets an actionable hint appended, so "API Error" stops hiding
|
|
739
936
|
// the turn-scoped full-transcript-replay cause.
|
|
@@ -924,6 +1121,8 @@ export function streamAcpTurn(
|
|
|
924
1121
|
let child: AcpChildLike | undefined;
|
|
925
1122
|
let session: BridgeSession | undefined;
|
|
926
1123
|
let onAbort: (() => void) | undefined;
|
|
1124
|
+
/** Which phase a failure belongs to — flipped once the prompt is on the wire. */
|
|
1125
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
927
1126
|
const stderrTail: string[] = [];
|
|
928
1127
|
const sessionKey = resolveSessionKey(opts, cwd);
|
|
929
1128
|
try {
|
|
@@ -1012,6 +1211,9 @@ export function streamAcpTurn(
|
|
|
1012
1211
|
// this turn with the session, so a later reuse turn can still report
|
|
1013
1212
|
// the child's dying words.
|
|
1014
1213
|
stderrTail,
|
|
1214
|
+
// Armed at spawn, before ANY turn can fail on this child — the latch
|
|
1215
|
+
// must already exist when the `exit` listener below can fire.
|
|
1216
|
+
childEnd: makeChildEndLatch(),
|
|
1015
1217
|
};
|
|
1016
1218
|
const sess = session;
|
|
1017
1219
|
spawned.once("exit", (...args: unknown[]) =>
|
|
@@ -1102,6 +1304,9 @@ export function streamAcpTurn(
|
|
|
1102
1304
|
signal.removeEventListener("abort", onAbort);
|
|
1103
1305
|
onAbort = undefined;
|
|
1104
1306
|
}
|
|
1307
|
+
// From here the prompt is on the wire: a transport closure now is a
|
|
1308
|
+
// PROMPT-phase failure, and the catch says so.
|
|
1309
|
+
phase = "prompt";
|
|
1105
1310
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1106
1311
|
signal,
|
|
1107
1312
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1134,6 +1339,13 @@ export function streamAcpTurn(
|
|
|
1134
1339
|
session.busy = false;
|
|
1135
1340
|
if (bridgeSessions.get(sessionKey) === session) bridgeSessions.delete(sessionKey);
|
|
1136
1341
|
}
|
|
1342
|
+
// ORDER IS THE CONTRACT (#72): natural settle → seal → teardown.
|
|
1343
|
+
// The bounded wait for the child's own exit status runs BEFORE we signal
|
|
1344
|
+
// anything, so what we report is how the child actually ended and not our
|
|
1345
|
+
// own SIGTERM; the tail collected by then is sealed before our cleanup
|
|
1346
|
+
// touches the stderr pipe.
|
|
1347
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1348
|
+
finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
|
|
1137
1349
|
// error/abort → drop the (uncertain) session and close its child; an
|
|
1138
1350
|
// uncertain connection must never be reused (GPT ④).
|
|
1139
1351
|
if (child) {
|
|
@@ -1142,7 +1354,6 @@ export function streamAcpTurn(
|
|
|
1142
1354
|
session?.connection.close?.(err);
|
|
1143
1355
|
teardownChild(child);
|
|
1144
1356
|
}
|
|
1145
|
-
finishError(err, aborted, session?.stderrTail ?? stderrTail);
|
|
1146
1357
|
} finally {
|
|
1147
1358
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
1148
1359
|
}
|
|
@@ -1150,6 +1361,8 @@ export function streamAcpTurn(
|
|
|
1150
1361
|
|
|
1151
1362
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1152
1363
|
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
1364
|
+
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1365
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1153
1366
|
try {
|
|
1154
1367
|
if (signal?.aborted) throw new Error("aborted before prompt");
|
|
1155
1368
|
|
|
@@ -1171,6 +1384,7 @@ export function streamAcpTurn(
|
|
|
1171
1384
|
// ahead of the wire write; the prompt driver then owns the abort surface.
|
|
1172
1385
|
const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
|
|
1173
1386
|
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
1387
|
+
phase = "prompt";
|
|
1174
1388
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1175
1389
|
signal,
|
|
1176
1390
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1188,16 +1402,22 @@ export function streamAcpTurn(
|
|
|
1188
1402
|
const aborted = Boolean(signal?.aborted);
|
|
1189
1403
|
session.activePromptHandler = undefined;
|
|
1190
1404
|
session.busy = false;
|
|
1191
|
-
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1192
1405
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
1406
|
+
// The SAME diagnostics a new turn reports, in the SAME order (#72):
|
|
1407
|
+
// natural settle → seal → teardown. This is the path the field sample
|
|
1408
|
+
// took — a retained child that died after its tool phase — so the
|
|
1409
|
+
// ordering matters most here: teardown first would have overwritten the
|
|
1410
|
+
// child's own exit status with our SIGTERM.
|
|
1411
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1412
|
+
// Without the session-scoped tail a mid-turn child death on a resident
|
|
1413
|
+
// session surfaced as a bare "ACP connection closed" with nothing to read
|
|
1414
|
+
// it by.
|
|
1415
|
+
finishError(err, aborted, session.stderrTail, lifecycle);
|
|
1416
|
+
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1193
1417
|
session.retiring = true;
|
|
1194
1418
|
retainedChildren.delete(session.child);
|
|
1195
1419
|
session.connection.close?.(err);
|
|
1196
1420
|
teardownChild(session.child);
|
|
1197
|
-
// The SAME diagnostics a new turn reports. Without the session-scoped
|
|
1198
|
-
// tail a mid-turn child death on a resident session surfaced as a bare
|
|
1199
|
-
// "ACP connection closed" with nothing to read it by.
|
|
1200
|
-
finishError(err, aborted, session.stderrTail);
|
|
1201
1421
|
}
|
|
1202
1422
|
}
|
|
1203
1423
|
|
package/run.sh
CHANGED
|
@@ -120,7 +120,7 @@ usage() {
|
|
|
120
120
|
cat <<'EOF'
|
|
121
121
|
Usage:
|
|
122
122
|
./run.sh setup [project-dir] # ONE confident install: pnpm install + install + meta-bridge (if native harness) + v2 install smoke (LIVE substrate = release-gate)
|
|
123
|
-
./run.sh release-gate [project-dir] [--cut] [--allow-skip-gemini] # SINGLE release gate: full static (pnpm run check:full) + the v2-native live gates (v2 matrix-live, check-bridge, RGG) + the ACP plugin acceptance floor (12 LIVE smokes: socket-citizen/raw-turn/overlay/provider/session-reuse/carrier-augment/memory-containment/rgg/mcp/skill/bundled-mcp/v2-send) + the one surviving axis the aggregate used to omit silently (claude-native-resume; Cortex stays a documented on-demand direct call) + the cross-harness delivery chain (smoke-entwurf-chain-live). TWO-TIER summary: MUST (release-blocking, owns the exit code — "green" applies here) + BEHAVIOR (advisory, non-blocking: RGG positives model-in-loop turn). STEP OUTCOME protocol: every step is INVOKED and reports its own PASS / SKIP (exit 97, a prerequisite it does not have) / FAIL — a skip is never counted as a pass. Without --cut this is the unattended diagnostic (SKIPs reported, exit 0). WITH --cut it is read as release acceptance and ANY MUST SKIP is red, which is what makes "a CUT needs LIVE=1, SKIP=0" executable instead of prose. --allow-skip-gemini accepted-but-ignored (back-compat). final cut authorization is GLG's.
|
|
123
|
+
./run.sh release-gate [project-dir] [--cut] [--allow-skip-gemini] # SINGLE release gate: full static (pnpm run check:full) + the v2-native live gates (v2 matrix-live, check-bridge, doctor-pi-provider, RGG) + the ACP plugin acceptance floor (12 LIVE smokes: socket-citizen/raw-turn/overlay/provider/session-reuse/carrier-augment/memory-containment/rgg/mcp/skill/bundled-mcp/v2-send) + the one surviving axis the aggregate used to omit silently (claude-native-resume; Cortex stays a documented on-demand direct call) + the cross-harness delivery chain (smoke-entwurf-chain-live). TWO-TIER summary: MUST (release-blocking, owns the exit code — "green" applies here) + BEHAVIOR (advisory, non-blocking: RGG positives model-in-loop turn). STEP OUTCOME protocol: every step is INVOKED and reports its own PASS / SKIP (exit 97, a prerequisite it does not have) / FAIL — a skip is never counted as a pass. Without --cut this is the unattended diagnostic (SKIPs reported, exit 0). WITH --cut it is read as release acceptance and ANY MUST SKIP is red, which is what makes "a CUT needs LIVE=1, SKIP=0" executable instead of prose. --allow-skip-gemini accepted-but-ignored (back-compat). final cut authorization is GLG's.
|
|
124
124
|
./run.sh check-bridge # entwurf-bridge direct MCP smoke + protocol/negative-path test.sh (live substrate = v2 live smokes)
|
|
125
125
|
./run.sh check-entwurf-bridge-boot # deterministic gate (5d-5-pre, G1a/G1b/G1e/G1f, IN pnpm run check:full): boot start.sh under strip-types + assert v2 fence graph loads + entwurf_v2 and entwurf_resume_call registered/schema + the tools/list surface is EXACTLY the seven shipped garden verbs; tools/list only, no auth/side-effect
|
|
126
126
|
./run.sh check-entwurf-bridge-pi-free # deterministic gate (0.12.1 A, IN pnpm check): static — bridge index eager value-import closure must carry no @earendil-works/pi-* (type-only + dynamic import excluded); proves the meta-bridge boots pi-free
|
|
@@ -156,8 +156,8 @@ Usage:
|
|
|
156
156
|
./run.sh check-mux-launcher-fence # deterministic gate for the shared operator-launcher fence (issue #67): scripts/lib/claude-launcher-fence.ts + its wiring into BOTH mux LIVE smokes. Replants the observed install-destruction shape (real HOME + fixture XDG_DATA_HOME → self-update retargets the real `claude` launcher into the fixture tree, teardown deletes it) wholly inside disposable mkdtemp roots — the real launcher is never inspected. Pins fail-closed preflight, retarget/content-change detection before cleanup, removal BLOCKED on fixture reference / unproven safety / surviving tracked panes, exact operator-parity XDG restore (absent = DELETED, not canonical defaults), the lifecycle cell-branch topology, and one shared helper consumed by both smokes
|
|
157
157
|
./run.sh check-entwurf-v2-visible-resume # deterministic gate: S1 visible-resume COMPOSITION (entwurf-v2-visible-resume.ts) with every seam injected — the whole state machine incl. the timeout branch runs with no tmux/lock/socket/clock. Pins lock BEFORE liveness, identity under the lock and before any window (no-transcript citizen fails loud, opens nothing), live/indeterminate/address-conflict refused unlaunched, observation as a BOUNDED WAIT (measured: socket answers ~2–4s after launch, so one immediate probe would call a successful resume unobserved), exactly ONE launch on every path, timeout → lock released + window left open + nothing retried/killed, failed release throws, and the two receipts staying separate in type and text
|
|
158
158
|
./run.sh check-resume-launch-identity # deterministic gate for resume-launch-identity.ts, the record-authoritative launch-identity leaf preserved through the visible-first cut (spawn-bg and all its callers are gone; this leaf answers "which being is this, and which conversation is theirs"). Temp meta-store fixture: gardenId→record.transcriptPath happy path with header cwd/provider/model; C3 integrity (header id ≠ record.nativeSessionId → refused, never resumed); #52 ADDRESSABLE read (a gid that no longer holds its nativeSessionId alone is refused from EITHER side — the plain targeted read would resume one transcript twice under two locks); cause fidelity per impossible resume incl. the F7 pin (recorded-but-deleted transcript → MISSING, not "no recorded model"); header↔gate SSOT. No spawn/socket/timer
|
|
159
|
-
./run.sh smoke-entwurf-v2-matrix-live # LIVE sentinel (0.11 Stage 0 step 5d-5, D4-b) — OUT of pnpm check, needs LIVE=1. Drives REAL production runEntwurfV2 deps over REAL OS objects, 4 cells: C1 control-socket (real pi --entwurf-control resident → RPC send → lock acquire→release ×1), C1b record-less socket (#50 C4: live record-less pi → EVERY intent rejected pre-probe record-less-socket, no lock, rendered hint names record authority + fresh-cut), C2 meta-mailbox deliverable (armed self-fetch citizen → real .msg enqueue, lock-free), C3 meta-mailbox guard (no armed receiver → reject, no garbage). Model-in-loop OUT (transport/lock/enqueue gate, GPT Q2); negative/timeout stay deterministic. Model: ENTWURF_LIVE_TARGET=<provider>/<model> (default openai-codex/gpt-5.
|
|
160
|
-
./run.sh smoke-agy-native-push-live # 봉인 8 LIVE acceptance for the native-push (agy) rail — OUT of pnpm check, needs LIVE=1 + AGY_CONVERSATION_ID (a live agy conversation). Drives the REAL antigravity adapter + register core + runEntwurfV2 (production deps): doctor-static preflight (dangling→FAIL, the ③ gate), probe route, register create/attach idempotency, fire→native-push delivered, post-send re-probe (D7 partial), bogus-conv→native-push-probe-indeterminate. Meta-store isolated to a temp dir (only the agy round-trip is real; no real-store residue). LIVE=1 AGY_CONVERSATION_ID=<convId> ./run.sh smoke-agy-native-push-live
|
|
159
|
+
./run.sh smoke-entwurf-v2-matrix-live # LIVE sentinel (0.11 Stage 0 step 5d-5, D4-b) — OUT of pnpm check, needs LIVE=1. Drives REAL production runEntwurfV2 deps over REAL OS objects, 4 cells: C1 control-socket (real pi --entwurf-control resident → RPC send → lock acquire→release ×1), C1b record-less socket (#50 C4: live record-less pi → EVERY intent rejected pre-probe record-less-socket, no lock, rendered hint names record authority + fresh-cut), C2 meta-mailbox deliverable (armed self-fetch citizen → real .msg enqueue, lock-free), C3 meta-mailbox guard (no armed receiver → reject, no garbage). Model-in-loop OUT (transport/lock/enqueue gate, GPT Q2); negative/timeout stay deterministic. Model: ENTWURF_LIVE_TARGET=<provider>/<model> (default openai-codex/gpt-5.6-luna). LIVE=1 ./run.sh smoke-entwurf-v2-matrix-live
|
|
160
|
+
./run.sh smoke-agy-native-push-live # 봉인 8 LIVE acceptance for the native-push (agy) rail — OUT of pnpm check, needs LIVE=1 + AGY_CONVERSATION_ID (a live agy conversation). Drives the REAL antigravity adapter + register core + runEntwurfV2 (production deps): doctor-static preflight (dangling→FAIL, the ③ gate), probe route, register create/attach idempotency, fire→native-push delivered, post-send re-probe (D7 partial), bogus-conv→native-push-probe-indeterminate. Meta-store isolated to a temp dir (only the agy round-trip is real; no real-store residue). COST FENCE: open that agy conversation on gemini-3.6-flash (free account) — never a Pro tier; entwurf never selects the agy model and no assertion reads it. LIVE=1 AGY_CONVERSATION_ID=<convId> ./run.sh smoke-agy-native-push-live
|
|
161
161
|
./run.sh smoke-mux-lifecycle-live # RELEASE MUST integrated LIVE lifecycle acceptance for mux, through the REAL MCP surface — OUT of pnpm check, needs LIVE=1 and spends model turns (two pi siblings: native + recorded-ACP provider, each resumed once; one Claude Code sibling). tools/call fresh_call -> nonce callback sender envelope -> v2 control send landing in the sibling's own transcript -> resume_call REFUSED while live (window count unchanged) -> stable-handle close (pane gone, socket dead, record kept) -> dormant delivery refused honestly -> public entwurf_resume_call with LAUNCH and OBSERVATION receipts kept apart, same-gid socket alive, zero new citizens, zero lock residue, resumed pane_start_path == RECORD cwd (separate tmux query), transcript byte-identical across the resume -> v2 recall of the pre-close fact. claude-code resume refused target-not-pi, no window opened and no lock residue. LIVE=1 ./run.sh smoke-mux-lifecycle-live
|
|
162
162
|
./run.sh check-entwurf-facts # deterministic gate (0.11 Stage 0 step 4, fact-provider slice 1+2): PURE PeerFact core + resolveFactList union — R1 out-of-domain→unsupported, R3b socket-domain 4-value, facts-only keyset; union: PeerFact + RecordLessSocketFact by gardenId (#50 C4: record-less socket = diagnostic subject, gid+liveness only), dormant→dead, F3 indeterminate preserved, out-of-socket-domain+socket fail-loud; pure, no IO
|
|
163
163
|
./run.sh check-socket-discovery # deterministic gate (0.11 Stage 0 step 4, fact-provider slice 3): SOCKET-axis scanSocketProbes — probes (dir sockets) ∪ (in-domain citizen canonical paths) 3-valued; dormant citizen no-file → dead (resumable, not unprobed), stall → indeterminate (F3), dir hygiene/dedup/missing-dir + e2e → resolveFactList; readdir/probe injected, no IO
|
|
@@ -197,6 +197,7 @@ Usage:
|
|
|
197
197
|
./run.sh doctor-meta-bridge # THE RELEASE ORACLE (#51, Linux-certified repair axis). exit 0 = every required layer was MEASURED on this Linux host: toolchain + state + plugin/MCP + resolved-artifact launch-form classification (all 3 owner hooks + doorbell static contract) + synthetic owner join + store scan + hook errors + SessionStart evidence + REQUIRED live MCP↔marker join + writer-version parity. Missing live evidence is NOT CERTIFIED (open a Claude session and re-run), never a pass; Darwin is not yet verified/certified and stays nonzero for this cut (future validation may reopen it). Detection power is held by check-meta-doctor-oracle
|
|
198
198
|
./run.sh install-agy-bridge # 봉인 7: agy MCP install adapter — register ONE entwurf-bridge server in the agy mcp_config (adopt file / create / REFUSE symlink), stable bin command, install-state under $XDG_DATA_HOME/entwurf/agy-bridge/
|
|
199
199
|
./run.sh uninstall-agy-bridge # 봉인 7: honest inverse of install-agy-bridge from install-state (restore preimage / remove key; refuse if config became a symlink)
|
|
200
|
+
./run.sh probe-bridge-command <cmd> [args...] # #81: BOOT the given bridge invocation and require the entwurf MCP tool surface back. `--invocation-json '{"command":"…","args":[],"env":{}}'` preserves a harness config exactly. It waits for a valid initialize response, then sends initialized + tools/list only (no tools/call, lock, record, or delivery). exit 0 = it serves the bridge; 1 = it does not. The pi/agy doctors use this leaf.
|
|
200
201
|
./run.sh doctor-agy-bridge # fail-loud doctor: MCP config + exact permission rule + state + live probe label
|
|
201
202
|
./run.sh install-agy-statusline # own the agy statusLine subtree with bare entwurf-agy-statusline; preserve unrelated settings
|
|
202
203
|
./run.sh uninstall-agy-statusline # honest inverse from statusline install-state
|
|
@@ -1356,7 +1357,7 @@ smoke_acp_cortex_live() {
|
|
|
1356
1357
|
# (ACP rail Cortex verification boundary). Drives one real
|
|
1357
1358
|
# cortex ACP turn through the entwurf provider path (outbound entwurf_v2 +
|
|
1358
1359
|
# dual-HOME overlay facts + process-group reclaim — CP2).
|
|
1359
|
-
# HONEST-SKIP (exit 0) when LIVE!=1 OR `cortex` is not on PATH OR no
|
|
1360
|
+
# HONEST-SKIP (protocol exit 97, never 0) when LIVE!=1 OR `cortex` is not on PATH OR no
|
|
1360
1361
|
# connection is pinned — the live turn needs `cortex` installed with the
|
|
1361
1362
|
# operator's own web-login auth already present (there is no `cortex auth`
|
|
1362
1363
|
# subcommand — CP0 D6), reached through the overlay's narrow credential
|
|
@@ -1389,7 +1390,7 @@ smoke_entwurf_v2_matrix_live() {
|
|
|
1389
1390
|
# reject, no garbage). Model-in-loop is OUT (GPT Q2): "does the sender model call entwurf_v2"
|
|
1390
1391
|
# is a separate behavior test — this is a transport/lock/enqueue gate. Negative/timeout/contention
|
|
1391
1392
|
# stay deterministic. Honest skip when LIVE!=1 so the release-gate is runnable unattended.
|
|
1392
|
-
# Model: ENTWURF_LIVE_TARGET=<provider>/<model> (default openai-codex/gpt-5.
|
|
1393
|
+
# Model: ENTWURF_LIVE_TARGET=<provider>/<model> (default openai-codex/gpt-5.6-luna).
|
|
1393
1394
|
# LIVE=1 ./run.sh smoke-entwurf-v2-matrix-live
|
|
1394
1395
|
entwurf_require_step_outcome
|
|
1395
1396
|
if [ "${LIVE:-}" != "1" ]; then
|
|
@@ -1628,13 +1629,13 @@ assert.equal(peerTui, piAi,
|
|
|
1628
1629
|
// floor tracks the devDep pin so a consumer can't install against a pi lacking
|
|
1629
1630
|
// the public trust exports the bridge imports at the pinned minor, AND an upper
|
|
1630
1631
|
// bound at the next minor stops a fresh install from silently pulling a future
|
|
1631
|
-
// pi (past the declared ceiling — 0.85+ at the current 0.84.
|
|
1632
|
+
// pi (past the declared ceiling — 0.85+ at the current 0.84.2 pin) whose
|
|
1632
1633
|
// internal export surface has drifted from the one we typecheck against.
|
|
1633
1634
|
// pi moves its public surface every minor (the 0.79→0.80 getModels→provider-
|
|
1634
1635
|
// factory churn is exactly this), so an open `>=` floor is exactly how the next
|
|
1635
1636
|
// installer re-acquires the drift. The floor is also the HARD MINIMUM a consumer
|
|
1636
|
-
// install resolves: at `>=0.84.
|
|
1637
|
-
// Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.
|
|
1637
|
+
// install resolves: at `>=0.84.2` an existing 0.83.x host is upgraded, not kept.
|
|
1638
|
+
// Expected shape: `>=<devDep> <0.<minor+1>` (e.g. `>=0.84.2 <0.85`).
|
|
1638
1639
|
const [piMaj, piMin] = piAi.split('.').map(Number);
|
|
1639
1640
|
assert.equal(piMaj, 0,
|
|
1640
1641
|
`pi pin major must stay 0 for the next-minor ceiling rule (got ${piAi}); revisit check-dep-versions when pi reaches 1.x`);
|
|
@@ -2741,6 +2742,9 @@ check_pack() {
|
|
|
2741
2742
|
"mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js"
|
|
2742
2743
|
"mcp/entwurf-bridge/dist/scripts/new-session-id.js"
|
|
2743
2744
|
"mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js"
|
|
2745
|
+
# #81 — the boot probe doctor-pi-provider imports (and `entwurf probe-bridge-command` runs).
|
|
2746
|
+
# Without the twin the installed doctor dies on the import before printing a verdict.
|
|
2747
|
+
"mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js"
|
|
2744
2748
|
# The generation verb. The hosts that need it are installed hosts on a
|
|
2745
2749
|
# previous-generation store; without this twin the prescription every
|
|
2746
2750
|
# v3-only rejection names would be dead exactly where it matters.
|
|
@@ -2940,6 +2944,8 @@ _check_pack_install_impl() {
|
|
|
2940
2944
|
"mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js"
|
|
2941
2945
|
"mcp/entwurf-bridge/dist/scripts/new-session-id.js"
|
|
2942
2946
|
"mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js"
|
|
2947
|
+
# #81 — the boot probe the installed doctor imports (see check-pack).
|
|
2948
|
+
"mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js"
|
|
2943
2949
|
# The generation verb (see check-pack). The installed-command regression below
|
|
2944
2950
|
# opens a fresh generation on a 0-record sandbox through the real bin.
|
|
2945
2951
|
"mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js"
|
|
@@ -3021,7 +3027,7 @@ _check_pack_install_impl() {
|
|
|
3021
3027
|
printf '%s\n' '{ "name": "entwurf-install-smoke", "version": "0.0.0", "private": true }' > "$tmp/package.json"
|
|
3022
3028
|
|
|
3023
3029
|
# pi-agent-core is pinned even though we never import it: pi-coding-agent depends
|
|
3024
|
-
# on it by CARET (`^0.84.
|
|
3030
|
+
# on it by CARET (`^0.84.2`), so with no lockfile in this fresh temp project it
|
|
3025
3031
|
# floats to whatever pi published last — and that newer core then drags a NESTED
|
|
3026
3032
|
# pi-ai of its own. Measured 2026-07-21: pinning only the three we import left
|
|
3027
3033
|
# pi-agent-core@0.80.10 + pi-ai@0.80.10 in the tree while the gate still announced
|
|
@@ -3040,12 +3046,12 @@ _check_pack_install_impl() {
|
|
|
3040
3046
|
local install_log
|
|
3041
3047
|
install_log=$(cd "$tmp" && pnpm add \
|
|
3042
3048
|
"$tgz_path" \
|
|
3043
|
-
"@earendil-works/pi-ai@0.84.
|
|
3044
|
-
"@earendil-works/pi-coding-agent@0.84.
|
|
3045
|
-
"@earendil-works/pi-tui@0.84.
|
|
3046
|
-
"@earendil-works/pi-agent-core@0.84.
|
|
3047
|
-
"@earendil-works/pi-client@0.84.
|
|
3048
|
-
"@earendil-works/pi-protocol@0.84.
|
|
3049
|
+
"@earendil-works/pi-ai@0.84.2" \
|
|
3050
|
+
"@earendil-works/pi-coding-agent@0.84.2" \
|
|
3051
|
+
"@earendil-works/pi-tui@0.84.2" \
|
|
3052
|
+
"@earendil-works/pi-agent-core@0.84.2" \
|
|
3053
|
+
"@earendil-works/pi-client@0.84.2" \
|
|
3054
|
+
"@earendil-works/pi-protocol@0.84.2" \
|
|
3049
3055
|
"typebox@latest" \
|
|
3050
3056
|
--ignore-workspace --ignore-scripts 2>&1) || {
|
|
3051
3057
|
fail "[check-pack-install] pnpm add failed:"
|
|
@@ -3055,17 +3061,17 @@ _check_pack_install_impl() {
|
|
|
3055
3061
|
|
|
3056
3062
|
# A pin is a wish until the resolved tree is read back. Assert it: EVERY
|
|
3057
3063
|
# @earendil-works pi package present — direct or transitive, top level or nested —
|
|
3058
|
-
# must be the pinned 0.84.
|
|
3064
|
+
# must be the pinned 0.84.2. Anything else means an unpinned caret floated and the
|
|
3059
3065
|
# rest of this gate would be exercising a runtime nobody verified, while still
|
|
3060
|
-
# printing "pinned pi 0.84.
|
|
3066
|
+
# printing "pinned pi 0.84.2". Fail loud instead of proving the wrong floor.
|
|
3061
3067
|
local leaked_pi
|
|
3062
|
-
leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | grep '^@earendil-works+pi-' | grep -v '@0\.84\.
|
|
3068
|
+
leaked_pi=$(ls "$tmp/node_modules/.pnpm" 2>/dev/null | grep '^@earendil-works+pi-' | grep -v '@0\.84\.2' || true)
|
|
3063
3069
|
if [ -n "$leaked_pi" ]; then
|
|
3064
|
-
fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.
|
|
3070
|
+
fail "[check-pack-install] UNVERIFIED pi runtime resolved into the install tree (expected only 0.84.2):"
|
|
3065
3071
|
printf '%s\n' "$leaked_pi" | sed 's/^/ /' >&2
|
|
3066
3072
|
return 1
|
|
3067
3073
|
fi
|
|
3068
|
-
echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.
|
|
3074
|
+
echo "[check-pack-install] pi runtime tree pin verified: every @earendil-works pi package is 0.84.2"
|
|
3069
3075
|
|
|
3070
3076
|
# Resolve the installed package.json and confirm pi.extensions
|
|
3071
3077
|
# arrived intact. If pi.extensions is empty or missing, the
|
|
@@ -4463,6 +4469,23 @@ release_gate() {
|
|
|
4463
4469
|
# is gated behind SMOKE_RGG_POSITIVE=1 in the BEHAVIOR lane below.
|
|
4464
4470
|
run_step "smoke-resident-garden-guard (3c citizen: record birth / record-keyed socket / attach-on-reopen, 0-token)" gate env SMOKE_RGG_POSITIVE=0 bash "$self" smoke-resident-garden-guard
|
|
4465
4471
|
run_step "check-bridge" gate bash "$self" check-bridge
|
|
4472
|
+
# check-bridge booted the launcher THIS CHECKOUT ships; this boots the invocation the operator's
|
|
4473
|
+
# pi provider actually EXECS. Two different strings, and only the second one reaches a live ACP
|
|
4474
|
+
# session. 2026-08-19 measured the gap at full price: `~/.local/bin/entwurf-bridge` had been
|
|
4475
|
+
# relocated onto a pnpm cmd-shim (basedir derived from $0, so not relocatable), it exited 127, the
|
|
4476
|
+
# bundled bridge never booted, and the model had no `mcp__entwurf-bridge__*` tool — while
|
|
4477
|
+
# `command -v` answered yes throughout. #81's probe already settles that in under a second and
|
|
4478
|
+
# doctor-pi-provider consumes it; it was simply never a step, so the verdict first surfaced at
|
|
4479
|
+
# smoke-acp-bundled-mcp-live, sixteen LIVE steps and ~20 minutes of real model spend later.
|
|
4480
|
+
# `run_step`, not `run_live_step`: this doctor is not LIVE-gated and never emits 97, so it has no
|
|
4481
|
+
# prerequisite to decline. It exits 1 for a configured invocation that will not boot and for
|
|
4482
|
+
# state-owned drift; a host that never installed the circuit keeps its existing exit 0 note, and
|
|
4483
|
+
# the bundled smokes below still fail loud there. So this tightens nothing — it only moves an
|
|
4484
|
+
# existing red earlier. cwd is REPO_DIR regardless of `gate` (run_ts cds there), so the scope
|
|
4485
|
+
# judged is this checkout's `.pi/settings.json` shadowing the operator's global: deterministic,
|
|
4486
|
+
# but not in principle the smokes' global-only subject if the two ever diverge.
|
|
4487
|
+
# Position and classifier arm are pinned by [QK:PI-DOCTOR-IS-RELEASE-MUST].
|
|
4488
|
+
run_step "doctor-pi-provider (#81: the operator's CONFIGURED bridge invocation actually boots)" gate bash "$self" doctor-pi-provider
|
|
4466
4489
|
# D4-c: the v2 dispatch substrate sentinel (5d-5). A SINGLE run (NOT backend-looped — it proves
|
|
4467
4490
|
# production runEntwurfV2 deps + real pi control-socket RPC + real mailbox enqueue + v2 lock, not
|
|
4468
4491
|
# per-backend model behavior). Placed right after check-bridge: the MCP/protocol substrate must be
|
|
@@ -5103,12 +5126,34 @@ case "$cmd" in
|
|
|
5103
5126
|
# Fail-loud doctor for agy hooks.json imprint wiring.
|
|
5104
5127
|
(cd "$REPO_DIR" && bash scripts/agy-hooks-bridge.sh doctor "$@")
|
|
5105
5128
|
;;
|
|
5129
|
+
check-probe-bridge-command)
|
|
5130
|
+
# #81: contract gate for the boot probe both doctors stake their verdict on — the reason
|
|
5131
|
+
# taxonomy (each value = a different operator repair) plus the one side effect the probe owns,
|
|
5132
|
+
# reaping the child it spawned. Hermetic stubs only; boots no bridge of ours.
|
|
5133
|
+
run_ts scripts/check-probe-bridge-command.ts
|
|
5134
|
+
;;
|
|
5135
|
+
probe-bridge-command)
|
|
5136
|
+
# #81: does a configured bridge command actually BOOT and serve MCP? `command -v` answering
|
|
5137
|
+
# yes is not that claim — a relocated launcher can resolve and still exit 127, which is how a
|
|
5138
|
+
# host ran with NO bridge in its ACP turns while every doctor printed ok. It waits for the
|
|
5139
|
+
# initialize RESPONSE before it sends notifications/initialized + `tools/list` (no tools/call),
|
|
5140
|
+
# so it takes no lock, writes no record, and delivers nothing. Both doctors route their boot
|
|
5141
|
+
# cell here so one leaf owns the verdict — agy for every configured {command,args,env}, pi for
|
|
5142
|
+
# every effective stdio invocation.
|
|
5143
|
+
shift || true
|
|
5144
|
+
run_ts scripts/probe-bridge-command.ts "$@"
|
|
5145
|
+
;;
|
|
5106
5146
|
doctor-pi-provider)
|
|
5107
|
-
# #46 Task 2:
|
|
5108
|
-
# mcpServers.entwurf-bridge). Uses config.ts
|
|
5109
|
-
#
|
|
5110
|
-
#
|
|
5111
|
-
# unowned user override (honest note).
|
|
5147
|
+
# #46 Task 2 + #81: fail-loud doctor for the pi provider ownership (entwurfProvider.
|
|
5148
|
+
# mcpServers.entwurf-bridge). Uses the config.ts SSOT for the EFFECTIVE (project-shadows-user)
|
|
5149
|
+
# entry — per-name merge then ONE normalize, exactly as resolveProviderConfig does; never a
|
|
5150
|
+
# re-implemented merge. Reports user/project/effective and distinguishes state-owned drift
|
|
5151
|
+
# (FAIL) from an unowned user override (honest note).
|
|
5152
|
+
# This doctor no longer merely reads files: it BOOTS every effective stdio invocation and
|
|
5153
|
+
# requires the entwurf verb set back, because `command -v` succeeding was never evidence that
|
|
5154
|
+
# pi gets a bridge. Runtime evidence and ownership are separate: an unowned override is not
|
|
5155
|
+
# repaired, but a dead one is still red. It writes no operator state, but it does exec the
|
|
5156
|
+
# configured command on this host. No agy/pi process is needed.
|
|
5112
5157
|
run_ts scripts/doctor-pi-provider.ts "$@"
|
|
5113
5158
|
;;
|
|
5114
5159
|
wire-agy-statusline)
|