@junghanacs/entwurf 0.16.0 → 0.17.1
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 +5 -3
- package/CHANGELOG.md +337 -0
- package/README.md +8 -11
- package/VERIFY.md +8 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +25 -14
- package/docs/setup-clean-host.md +24 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +34 -10
- package/package.json +10 -10
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +78 -9
- package/pi-extensions/lib/acp/backend.ts +578 -18
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/run.sh +129 -32
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-gate-qualification.ts +4 -2
- package/scripts/check-omp-fresh-preflight.ts +27 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- package/scripts/mutants/copilot-birth.json +3 -5
- package/scripts/mutants/pack-install.json +2 -2
- package/scripts/mutants/setup-verdict.json +35 -0
- package/scripts/omp-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -0
- package/scripts/omp-tool-surface.py +61 -10
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
- package/scripts/smoke-setup-verdict.sh +48 -3
|
@@ -77,6 +77,7 @@ function deferred<T>(): Deferred<T> {
|
|
|
77
77
|
function makeFakeChild() {
|
|
78
78
|
const exitListeners: Array<(...args: unknown[]) => void> = [];
|
|
79
79
|
const stderrListeners: Array<(chunk: Buffer) => void> = [];
|
|
80
|
+
const stderrCloseListeners: Array<() => void> = [];
|
|
80
81
|
const kills: Array<NodeJS.Signals | number | undefined> = [];
|
|
81
82
|
const pipe = () => ({ destroy() {}, unref() {} });
|
|
82
83
|
const child = {
|
|
@@ -90,6 +91,9 @@ function makeFakeChild() {
|
|
|
90
91
|
on(_event: "data", listener: (chunk: Buffer) => void) {
|
|
91
92
|
stderrListeners.push(listener);
|
|
92
93
|
},
|
|
94
|
+
once(_event: "close", listener: () => void) {
|
|
95
|
+
stderrCloseListeners.push(listener);
|
|
96
|
+
},
|
|
93
97
|
destroy() {},
|
|
94
98
|
unref() {},
|
|
95
99
|
},
|
|
@@ -105,10 +109,11 @@ function makeFakeChild() {
|
|
|
105
109
|
writeStderr(text: string) {
|
|
106
110
|
for (const listener of [...stderrListeners]) listener(Buffer.from(text));
|
|
107
111
|
},
|
|
108
|
-
/** driver: the backend process ends. */
|
|
112
|
+
/** driver: the backend process ends. Its stderr pipe closes with it. */
|
|
109
113
|
die(code: number | null, signal: NodeJS.Signals | null = null) {
|
|
110
114
|
child.exitCode = code;
|
|
111
115
|
child.signalCode = signal;
|
|
116
|
+
for (const listener of stderrCloseListeners.splice(0)) listener();
|
|
112
117
|
for (const listener of exitListeners.splice(0)) listener(code, signal);
|
|
113
118
|
},
|
|
114
119
|
};
|
|
@@ -656,6 +661,141 @@ try {
|
|
|
656
661
|
);
|
|
657
662
|
}
|
|
658
663
|
|
|
664
|
+
// ----------------------------------------------------------------------
|
|
665
|
+
// CELL 12 — a signal the LAUNCHER caught survives the vendor erasing it.
|
|
666
|
+
//
|
|
667
|
+
// #72's whole difficulty: the vendor turns SIGTERM into `dispose(); exit(0)`,
|
|
668
|
+
// so an external kill and a clean vendor shutdown reach us as the SAME facts
|
|
669
|
+
// (code 0, signal null). CELL 10 can only separate a signal that was NOT
|
|
670
|
+
// caught. `claude-acp-launch.js` records the catch before the vendor erases
|
|
671
|
+
// it; this cell holds that the record reaches the operator, on its own line,
|
|
672
|
+
// and that the tail stays vendor-only.
|
|
673
|
+
// ----------------------------------------------------------------------
|
|
674
|
+
{
|
|
675
|
+
const h = makeHarness(recordDir);
|
|
676
|
+
const turn = startTurn(backend, userCtx("reaped from outside"), { sessionId: "life-eof-launchsig" }, h.deps);
|
|
677
|
+
await delay(30);
|
|
678
|
+
h.children[0].writeStderr("VENDOR-TAIL-MARK\n");
|
|
679
|
+
h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM\n");
|
|
680
|
+
h.transportClosed();
|
|
681
|
+
await delay(1);
|
|
682
|
+
h.children[0].die(0, null);
|
|
683
|
+
await turn.done;
|
|
684
|
+
|
|
685
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
686
|
+
assert.ok(
|
|
687
|
+
message.includes("launch observed SIGTERM before child exit"),
|
|
688
|
+
"[QK:LAUNCH-SIGNAL-EVIDENCE-STRUCTURED] a caught SIGTERM must reach the operator even though the vendor " +
|
|
689
|
+
`normalized it to exit 0 — otherwise an external kill is indistinguishable from a clean close. Got: ${JSON.stringify(message)}`,
|
|
690
|
+
);
|
|
691
|
+
assert.ok(
|
|
692
|
+
message.includes("sender not attributed"),
|
|
693
|
+
"the observation must NOT claim who sent the signal — attribution needs the host journal, which this " +
|
|
694
|
+
`process cannot read. Got: ${JSON.stringify(message)}`,
|
|
695
|
+
);
|
|
696
|
+
assert.ok(
|
|
697
|
+
message.includes("exit code 0") && message.includes("VENDOR-TAIL-MARK"),
|
|
698
|
+
`the exit fact and the vendor tail both survive alongside the observation. Got: ${JSON.stringify(message)}`,
|
|
699
|
+
);
|
|
700
|
+
assert.ok(
|
|
701
|
+
!message.includes("ENTWURF_ACP_LAUNCH_SIGNAL="),
|
|
702
|
+
"[QK:LAUNCH-FRAME-NOT-IN-TAIL] the raw control frame must be CONSUMED, not echoed into the vendor tail — " +
|
|
703
|
+
`the tail is vendor evidence and the observation is ours, and #72 was made of confusing the two. Got: ${JSON.stringify(message)}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ----------------------------------------------------------------------
|
|
708
|
+
// CELL 13 — NEGATIVE SIBLING: vendor prose can never manufacture the fact.
|
|
709
|
+
//
|
|
710
|
+
// The oracle for CELL 12's exactness. Vendor stderr is free text and it does
|
|
711
|
+
// mention signals; if a loose test (`includes("SIGTERM")`, or a prefix match
|
|
712
|
+
// without the enum) fed the observation, entwurf would report an entwurf-owned
|
|
713
|
+
// fact it never observed — a worse failure than reporting nothing, because it
|
|
714
|
+
// would be trusted. Both a near-miss frame and prose must produce NOTHING.
|
|
715
|
+
// ----------------------------------------------------------------------
|
|
716
|
+
{
|
|
717
|
+
const h = makeHarness(recordDir);
|
|
718
|
+
const turn = startTurn(backend, userCtx("vendor mentions a signal"), { sessionId: "life-eof-nosig" }, h.deps);
|
|
719
|
+
await delay(30);
|
|
720
|
+
h.children[0].writeStderr("shutting down after SIGTERM; ENTWURF_ACP_LAUNCH_SIGNAL=SIGQUIT\n");
|
|
721
|
+
h.children[0].writeStderr(" ENTWURF_ACP_LAUNCH_SIGNAL=SIGTERM (quoted in prose, not a frame)\n");
|
|
722
|
+
h.transportClosed();
|
|
723
|
+
await delay(1);
|
|
724
|
+
h.children[0].die(0, null);
|
|
725
|
+
await turn.done;
|
|
726
|
+
|
|
727
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
728
|
+
assert.ok(
|
|
729
|
+
!message.includes("launch observed"),
|
|
730
|
+
"[QK:LAUNCH-SIGNAL-EXACT-FRAME-ONLY] neither vendor prose containing 'SIGTERM', an unknown signal value, nor " +
|
|
731
|
+
"an indented near-miss may produce a launch observation — the frame is an exact full line with a fixed " +
|
|
732
|
+
`enum, and anything looser lets vendor text forge our own evidence. Got: ${JSON.stringify(message)}`,
|
|
733
|
+
);
|
|
734
|
+
assert.ok(
|
|
735
|
+
message.includes("SIGQUIT") && message.includes("quoted in prose"),
|
|
736
|
+
`text that is not our frame stays in the vendor tail verbatim. Got: ${JSON.stringify(message)}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ----------------------------------------------------------------------
|
|
741
|
+
// CELL 14 — the child's LAST words survive having no trailing newline.
|
|
742
|
+
//
|
|
743
|
+
// Filtering our control frame out of the tail means reading stderr by LINE,
|
|
744
|
+
// and a process dying mid-write does not finish its line. That fragment is
|
|
745
|
+
// exactly the dying words the tail exists for, so holding it in a line buffer
|
|
746
|
+
// forever would trade #72's diagnosis for a worse blindness. The stream's
|
|
747
|
+
// close must flush it VERBATIM.
|
|
748
|
+
// ----------------------------------------------------------------------
|
|
749
|
+
{
|
|
750
|
+
const h = makeHarness(recordDir);
|
|
751
|
+
const turn = startTurn(backend, userCtx("dies mid-write"), { sessionId: "life-eof-nonl" }, h.deps);
|
|
752
|
+
await delay(30);
|
|
753
|
+
h.children[0].writeStderr("FATAL dying words with no newline");
|
|
754
|
+
h.transportClosed();
|
|
755
|
+
await delay(1);
|
|
756
|
+
h.children[0].die(0, null);
|
|
757
|
+
await turn.done;
|
|
758
|
+
|
|
759
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
760
|
+
assert.ok(
|
|
761
|
+
message.includes("FATAL dying words with no newline"),
|
|
762
|
+
"[QK:STDERR-TAIL-FLUSHES-PARTIAL-LINE] a child that dies mid-write leaves its last line unterminated, and " +
|
|
763
|
+
"that fragment IS the dying words the tail exists for — line-buffering to strip our own frame must not " +
|
|
764
|
+
`swallow it. Got: ${JSON.stringify(message)}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ----------------------------------------------------------------------
|
|
769
|
+
// CELL 15 — a frame split across two reads is still recognised exactly.
|
|
770
|
+
//
|
|
771
|
+
// Pipe chunk boundaries fall wherever the kernel put them, so the frame can
|
|
772
|
+
// arrive in pieces. This is the reason the filter buffers by line at all;
|
|
773
|
+
// without a cell for it, an implementation that matched per-CHUNK would pass
|
|
774
|
+
// every other test here and then miss the real signal in the field.
|
|
775
|
+
// ----------------------------------------------------------------------
|
|
776
|
+
{
|
|
777
|
+
const h = makeHarness(recordDir);
|
|
778
|
+
const turn = startTurn(backend, userCtx("frame arrives split"), { sessionId: "life-eof-split" }, h.deps);
|
|
779
|
+
await delay(30);
|
|
780
|
+
h.children[0].writeStderr("ENTWURF_ACP_LAUNCH_SI");
|
|
781
|
+
h.children[0].writeStderr("GNAL=SIGTERM\n");
|
|
782
|
+
h.transportClosed();
|
|
783
|
+
await delay(1);
|
|
784
|
+
h.children[0].die(0, null);
|
|
785
|
+
await turn.done;
|
|
786
|
+
|
|
787
|
+
const message = String(sealed(turn.events)[0].error.errorMessage);
|
|
788
|
+
assert.ok(
|
|
789
|
+
message.includes("launch observed SIGTERM before child exit"),
|
|
790
|
+
"[QK:LAUNCH-FRAME-SPANS-CHUNKS] the frame must be recognised across a chunk boundary — the kernel, not the " +
|
|
791
|
+
`writer, decides where a read ends. Got: ${JSON.stringify(message)}`,
|
|
792
|
+
);
|
|
793
|
+
assert.ok(
|
|
794
|
+
!message.includes("ENTWURF_ACP_LAUNCH_SI"),
|
|
795
|
+
`neither half of a split frame may leak into the vendor tail. Got: ${JSON.stringify(message)}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
659
799
|
// ----------------------------------------------------------------------
|
|
660
800
|
// CELL 11 — a child that never reports an end says SO, bounded.
|
|
661
801
|
//
|
|
@@ -771,7 +911,10 @@ console.log(
|
|
|
771
911
|
"reported with its exit status AND stderr tail on BOTH the new and the reuse path; a death BETWEEN turns is " +
|
|
772
912
|
"announced once by the next turn while a teardown WE performed stays silent; the child's end survives the " +
|
|
773
913
|
"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;
|
|
914
|
+
"telling a clean exit apart from a signal and reporting silence AS silence within a bounded window; a signal the " +
|
|
915
|
+
"LAUNCHER caught survives the vendor normalizing it to exit 0 and is reported on its own line without claiming " +
|
|
916
|
+
"who sent it, while its raw control frame is consumed out of the vendor tail and vendor prose mentioning a " +
|
|
917
|
+
"signal can never forge that observation; and pi's " +
|
|
775
918
|
"own isRetryableAssistantError refuses to classify any of those failures as transient while still matching " +
|
|
776
919
|
"the retired 600s text",
|
|
777
920
|
);
|
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// WHAT THIS EXISTS TO STOP. `mapPromptStopReason` used to be a bare
|
|
4
4
|
// `switch` returning a StopReason with `default: return "stop"`. The ACP terminal
|
|
5
|
-
// set is closed — `@agentclientprotocol/sdk` 1.
|
|
6
|
-
// `end_turn | max_tokens | max_turn_requests | refusal | cancelled
|
|
5
|
+
// set is closed — `@agentclientprotocol/sdk` 1.4.0 `dist/schema/types.gen.d.ts:3001`
|
|
6
|
+
// declares `end_turn | max_tokens | max_turn_requests | refusal | cancelled`, with
|
|
7
|
+
// no `| string` arm. Re-measured at the 1.3.0 → 1.4.0 bump rather than inherited,
|
|
8
|
+
// because 1.4.0 also ships a SECOND, OPEN union at `dist/v2/schema/types.gen.d.ts:3607`
|
|
9
|
+
// (`… | "cancelled" | string`). That one is reachable only through the
|
|
10
|
+
// `./experimental/v2` export; entwurf imports the BARE specifier, which the package
|
|
11
|
+
// `exports` map sends to `./dist/acp.js` — the closed v1 surface. The claim holds
|
|
12
|
+
// only while that import stays bare, so a move to the v2 export retires it — so that
|
|
7
13
|
// default silently collapsed THREE distinct non-success outcomes into a clean
|
|
8
14
|
// successful turn:
|
|
9
15
|
//
|