@junghanacs/entwurf 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -1,551 +0,0 @@
1
- /**
2
- * check-entwurf-v2-spawn-production — DETERMINISTic gate for the 5c-3c production
3
- * SpawnBgResumeDeps factory. It proves every injectable seam WITHOUT a real pi spawn,
4
- * socket, or timer (the heavy live path is a separate opt-in smoke,
5
- * smoke-entwurf-v2-spawn-live, kept OUT of pnpm check — D5):
6
- *
7
- * 1. socketWatchVerdict (the R2 watch policy, pure): address-conflict → forged (reject,
8
- * never wait); alive → alive (resolve); dead / indeterminate → wait (keep polling).
9
- * 2. spawnChild: resolves identity (injected) → buildResumePiArgs(v2-control) → spawnFn.
10
- * The captured argv carries --entwurf-control, NO --no-extensions, -p + prompt final,
11
- * plan.launchArgs (--approve), the ext args, provider/model, and the header cwd.
12
- * 3. awaitSocketAlive: connectable → resolve; forged (symlink) → reject WITHOUT connecting
13
- * (probe never called); dead → wait one interval, re-poll → alive resolves; abort
14
- * during the wait → reject.
15
- * 4. awaitChildExit: a child 'exit' resolves the code (null on signal); abort rejects and
16
- * removes the exit listener (no leak).
17
- * 5. awaitTimeout: schedules via the injected timer; abort clears it and rejects.
18
- * 6. killChild: SIGTERM on the shared proc.
19
- * 7. releaseLock: delegates to the injected release fn.
20
- * 8. mis-wire: a child with no `proc` (not one we spawned) fails loud in killChild /
21
- * awaitChildExit.
22
- *
23
- * All IO is injected; a controllable scheduler makes the timer/poll paths deterministic.
24
- */
25
-
26
- import assert from "node:assert/strict";
27
- import * as fs from "node:fs";
28
- import * as os from "node:os";
29
- import * as path from "node:path";
30
- import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
31
- import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "../pi-extensions/lib/entwurf-v2-spawn.ts";
32
- import {
33
- type LaunchIdentity,
34
- makeProductionSpawnBgResumeDeps,
35
- type ProductionSpawnOpts,
36
- resolveResumeLaunchIdentity,
37
- type SpawnedProcHandle,
38
- socketWatchVerdict,
39
- } from "../pi-extensions/lib/entwurf-v2-spawn-production.ts";
40
- import { upsertMetaSession } from "../pi-extensions/lib/meta-session.ts";
41
- import type { LstatLike } from "../pi-extensions/lib/socket-discovery.ts";
42
- import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
43
-
44
- let passed = 0;
45
- function ok(label: string, cond: boolean): void {
46
- assert.ok(cond, label);
47
- console.log(` ok ${label}`);
48
- passed++;
49
- }
50
-
51
- const GID = "20260613T091000-98363c";
52
- const SOCK = `/fake/entwurf-control/${GID}.sock`;
53
- const flush = (): Promise<void> => new Promise((res) => setImmediate(res));
54
-
55
- const SESSION_FILE = "/home/test/.pi/agent/sessions/-home-test-repo/2026-06-13T09-10-00-000Z_019e8faa-04ea.jsonl";
56
-
57
- const IDENTITY: LaunchIdentity = {
58
- sessionFile: SESSION_FILE,
59
- cwd: "/home/test/repo",
60
- explicitExtensionArgs: ["-e", "/path/to/entwurf/index.ts"],
61
- provider: "entwurf",
62
- model: "claude-opus-5",
63
- };
64
-
65
- // True if `p` has NOT settled after a macrotask tick (all pending microtasks drained).
66
- // Attaches a settled-handler (so a later rejection is never unhandled), then checks.
67
- async function notSettled(p: Promise<unknown>): Promise<boolean> {
68
- let settled = false;
69
- void p.then(
70
- () => {
71
- settled = true;
72
- },
73
- () => {
74
- settled = true;
75
- },
76
- );
77
- await flush();
78
- return !settled;
79
- }
80
-
81
- function spawnBgPlan(): SpawnBgPlan {
82
- return {
83
- transport: "spawn-bg",
84
- action: "resume",
85
- targetGardenId: GID,
86
- sessionId: GID,
87
- cwd: "/home/test/repo",
88
- prompt: "continue the task",
89
- wantsReply: false,
90
- launchArgs: ["--approve"],
91
- expectedSocketPath: SOCK,
92
- observeTimeoutMs: 30_000,
93
- releaseWhen: "socket-alive-or-child-exited",
94
- };
95
- }
96
-
97
- function lockClaim(): LockClaim {
98
- return {
99
- gardenId: GID,
100
- pid: 4242,
101
- hostname: "test-host",
102
- createdAt: "2026-06-13T00:00:00.000Z",
103
- nonce: "deadbeefcafef00d",
104
- owner: "entwurf_v2",
105
- lockPath: `/fake/locks/${GID}.lock`,
106
- };
107
- }
108
-
109
- // A controllable scheduler: setTimeoutFn captures callbacks; the gate fires them by hand.
110
- function makeScheduler() {
111
- let nextId = 1;
112
- const timers = new Map<number, () => void>();
113
- return {
114
- setTimeoutFn: ((cb: () => void, _ms: number) => {
115
- const id = nextId++;
116
- timers.set(id, cb);
117
- return id as unknown as ReturnType<typeof setTimeout>;
118
- }) as (cb: () => void, ms: number) => ReturnType<typeof setTimeout>,
119
- clearTimeoutFn: ((t: ReturnType<typeof setTimeout>) => {
120
- timers.delete(t as unknown as number);
121
- }) as (t: ReturnType<typeof setTimeout>) => void,
122
- fireNext: (): void => {
123
- const first = [...timers.entries()][0];
124
- if (first) {
125
- timers.delete(first[0]);
126
- first[1]();
127
- }
128
- },
129
- pending: (): number => timers.size,
130
- };
131
- }
132
-
133
- // A fake proc handle (EventEmitter-lite) recording kills and listener churn.
134
- function fakeProc() {
135
- const listeners = new Map<string, Set<(...a: unknown[]) => void>>();
136
- const calls = { kills: [] as string[] };
137
- const proc: SpawnedProcHandle = {
138
- pid: 9191,
139
- kill: (sig) => {
140
- calls.kills.push(String(sig));
141
- return true;
142
- },
143
- on: (ev, l) => {
144
- let set = listeners.get(ev);
145
- if (!set) {
146
- set = new Set();
147
- listeners.set(ev, set);
148
- }
149
- set.add(l);
150
- return proc;
151
- },
152
- removeListener: (ev, l) => {
153
- listeners.get(ev)?.delete(l);
154
- return proc;
155
- },
156
- };
157
- return {
158
- proc,
159
- calls,
160
- emit: (ev: string, ...args: unknown[]): void => {
161
- for (const l of [...(listeners.get(ev) ?? [])]) l(...args);
162
- },
163
- listenerCount: (ev: string): number => listeners.get(ev)?.size ?? 0,
164
- };
165
- }
166
-
167
- function statLike(kind: "socket" | "symlink" | "other"): LstatLike {
168
- return {
169
- isSymbolicLink: () => kind === "symlink",
170
- isSocket: () => kind === "socket",
171
- };
172
- }
173
-
174
- // Spawn a child through the factory (so it carries the eager exitPromise), driving the
175
- // 'spawn' event so spawnChild resolves. Returns the deps + the started child.
176
- async function spawnedChild(
177
- fp: ReturnType<typeof fakeProc>,
178
- opts: ProductionSpawnOpts = {},
179
- ): Promise<{ deps: SpawnBgResumeDeps; child: SpawnedChild }> {
180
- const deps = makeProductionSpawnBgResumeDeps({ resolveIdentity: () => IDENTITY, spawnChild: () => fp.proc, ...opts });
181
- const p = deps.spawnChild(spawnBgPlan());
182
- await flush();
183
- fp.emit("spawn");
184
- const child = await p;
185
- return { deps, child };
186
- }
187
-
188
- async function main(): Promise<void> {
189
- // ── 1. socketWatchVerdict (pure R2 policy) ───────────────────────────────────────
190
- ok("1 verdict: address-conflict → forged", socketWatchVerdict({ addressConflict: true }) === "forged");
191
- ok("1 verdict: alive → alive", socketWatchVerdict({ liveness: "alive", socketPath: SOCK }) === "alive");
192
- ok("1 verdict: dead → wait", socketWatchVerdict({ liveness: "dead", socketPath: SOCK }) === "wait");
193
- ok("1 verdict: indeterminate → wait", socketWatchVerdict({ liveness: "indeterminate", socketPath: SOCK }) === "wait");
194
-
195
- // ── 2. spawnChild builds the v2-control argv, then WAITS for the 'spawn' event (B1) ─
196
- {
197
- // A holder (not a closure-assigned `let`) so flow analysis sees the capture.
198
- const cap: { value: { cmd: string; args: readonly string[]; cwd: string } | null } = {
199
- value: null,
200
- };
201
- const fp = fakeProc();
202
- const deps = makeProductionSpawnBgResumeDeps({
203
- resolveIdentity: () => IDENTITY,
204
- spawnChild: (cmd, args, cwd) => {
205
- cap.value = { cmd, args, cwd };
206
- return fp.proc;
207
- },
208
- });
209
- const childP = deps.spawnChild(spawnBgPlan());
210
- await flush(); // spawnChild has installed exit+spawn+error listeners and is awaiting 'spawn'
211
- ok("2 spawnChild does NOT resolve before the 'spawn' event (B1)", await notSettled(childP));
212
- fp.emit("spawn"); // a real start is confirmed → spawnChild resolves
213
- const child = await childP;
214
- ok("2 child carries pid from proc", child.pid === 9191);
215
- const captured = cap.value;
216
- assert.ok(captured, "spawnChild invoked the injected spawnFn");
217
- const args = captured.args;
218
- ok("2 spawns the pi binary", captured.cmd === "pi");
219
- ok("2 cwd = header authority", captured.cwd === "/home/test/repo");
220
- ok("2 argv has --entwurf-control", args.includes("--entwurf-control"));
221
- ok("2 argv has NO --no-extensions", !args.includes("--no-extensions"));
222
- ok("2 argv keeps -p", args.includes("-p"));
223
- ok("2 argv prompt is final positional", args[args.length - 1] === "continue the task");
224
- ok("2 argv carries plan.launchArgs (--approve)", args.includes("--approve"));
225
- ok("2 argv carries the ext args", args.includes("-e"));
226
- ok("2 argv carries provider", args[args.indexOf("--provider") + 1] === "entwurf");
227
- ok("2 argv carries model", args[args.indexOf("--model") + 1] === "claude-opus-5");
228
- // #50 C2: argv names the exact transcript FILE, never a garden id. `--session-id`
229
- // would CREATE a session at that id when it is missing — post-cut the garden id is
230
- // never a pi session id, so the old flag would have minted an empty session and
231
- // called it a resume.
232
- ok("2 argv resumes by exact file", args[args.indexOf("--session") + 1] === SESSION_FILE);
233
- ok("2 argv carries NO --session-id", !args.includes("--session-id"));
234
- // #50 C3: the sessionId-bound resume-marker env is GONE — the name-mirror guard it
235
- // authorized died in C2, so the seam no longer takes an env at all (the child
236
- // inherits process.env). The compiler enforces the narrowed signature; what remains
237
- // to assert is that authorization now lives in the RECORD (section 9 below).
238
- }
239
-
240
- // ── 2b. B1: an 'error' before 'spawn' (ENOENT pi / exec failure) → spawnChild REJECTS ─
241
- // so the watcher turns it into spawn-start-failed (release), not a stalled spawn-started.
242
- {
243
- const fp = fakeProc();
244
- const deps = makeProductionSpawnBgResumeDeps({ resolveIdentity: () => IDENTITY, spawnChild: () => fp.proc });
245
- const childP = deps.spawnChild(spawnBgPlan());
246
- await flush();
247
- fp.emit("error", new Error("ENOENT: pi not found"));
248
- let threw = false;
249
- try {
250
- await childP;
251
- } catch (e) {
252
- threw = e instanceof Error && e.message.includes("ENOENT");
253
- }
254
- ok("2b spawn-time 'error' before 'spawn' → spawnChild rejects", threw);
255
- }
256
-
257
- // ── 3. awaitSocketAlive: connectable resolves; forged rejects without connecting ──
258
- {
259
- // connectable: socket-file + probe alive → resolve, probe called once.
260
- let probes = 0;
261
- const deps = makeProductionSpawnBgResumeDeps({
262
- lstatFn: async () => statLike("socket"),
263
- probeFn: async () => {
264
- probes++;
265
- return "alive";
266
- },
267
- });
268
- await deps.awaitSocketAlive(SOCK, new AbortController().signal);
269
- ok("3 connectable socket resolves", true);
270
- ok("3 connectable probed once", probes === 1);
271
- }
272
- {
273
- // forged: a symlink is an address-conflict — reject WITHOUT ever connecting.
274
- let probes = 0;
275
- const deps = makeProductionSpawnBgResumeDeps({
276
- lstatFn: async () => statLike("symlink"),
277
- probeFn: async () => {
278
- probes++;
279
- return "alive";
280
- },
281
- });
282
- let threw = false;
283
- try {
284
- await deps.awaitSocketAlive(SOCK, new AbortController().signal);
285
- } catch (e) {
286
- threw = e instanceof Error && e.message.includes("forged");
287
- }
288
- ok("3 forged (symlink) rejects", threw);
289
- ok("3 forged never connects (probe not called)", probes === 0);
290
- }
291
- {
292
- // dead → wait one interval → re-poll alive resolves. The scheduler fires the sleep.
293
- const sched = makeScheduler();
294
- const seq: SocketLiveness[] = ["dead", "alive"];
295
- let i = 0;
296
- const deps = makeProductionSpawnBgResumeDeps({
297
- lstatFn: async () => statLike("socket"),
298
- probeFn: async () => seq[i++] ?? "alive",
299
- setTimeoutFn: sched.setTimeoutFn,
300
- clearTimeoutFn: sched.clearTimeoutFn,
301
- });
302
- const p = deps.awaitSocketAlive(SOCK, new AbortController().signal);
303
- await flush(); // poll 1 (dead) → schedules the inter-poll sleep
304
- ok("3 dead → waits (a sleep is scheduled)", sched.pending() === 1);
305
- sched.fireNext(); // sleep elapses → poll 2 (alive)
306
- await p;
307
- ok("3 dead → wait → alive resolves", true);
308
- }
309
- {
310
- // abort during the wait → reject, scheduled sleep cleared.
311
- const sched = makeScheduler();
312
- const ctrl = new AbortController();
313
- const deps = makeProductionSpawnBgResumeDeps({
314
- lstatFn: async () => statLike("socket"),
315
- probeFn: async () => "dead",
316
- setTimeoutFn: sched.setTimeoutFn,
317
- clearTimeoutFn: sched.clearTimeoutFn,
318
- });
319
- const p = deps.awaitSocketAlive(SOCK, ctrl.signal);
320
- await flush(); // poll 1 (dead) → sleeping
321
- ctrl.abort();
322
- let threw = false;
323
- try {
324
- await p;
325
- } catch {
326
- threw = true;
327
- }
328
- ok("3 abort during wait → rejects", threw);
329
- ok("3 abort clears the scheduled sleep", sched.pending() === 0);
330
- }
331
-
332
- // ── 4. awaitChildExit reads the EAGER exitPromise (installed at spawn) ────────────
333
- {
334
- // 4a: exit AFTER awaitChildExit is waiting → resolves the code.
335
- const fp = fakeProc();
336
- const { deps, child } = await spawnedChild(fp);
337
- const p = deps.awaitChildExit(child, new AbortController().signal);
338
- fp.emit("exit", 137);
339
- ok("4 exit resolves the code", (await p) === 137);
340
- }
341
- {
342
- // 4b: abort → rejects.
343
- const fp = fakeProc();
344
- const { deps, child } = await spawnedChild(fp);
345
- const ctrl = new AbortController();
346
- const p = deps.awaitChildExit(child, ctrl.signal);
347
- await flush();
348
- ctrl.abort();
349
- let threw = false;
350
- try {
351
- await p;
352
- } catch {
353
- threw = true;
354
- }
355
- ok("4 abort → rejects", threw);
356
- }
357
- {
358
- // 4c: a signal kill (null code) → null.
359
- const fp = fakeProc();
360
- const { deps, child } = await spawnedChild(fp);
361
- const p = deps.awaitChildExit(child, new AbortController().signal);
362
- fp.emit("exit", null);
363
- ok("4 signal exit → null code", (await p) === null);
364
- }
365
- {
366
- // 4d (B2): the child exits BEFORE awaitChildExit is even called. The eager exitPromise
367
- // captured it at spawn, so awaitChildExit resolves immediately — it does NOT hang into a
368
- // timeout→kill→wrongful-retained.
369
- const fp = fakeProc();
370
- const { deps, child } = await spawnedChild(fp);
371
- fp.emit("exit", 0); // exit in the gap, before awaitChildExit
372
- const code = await deps.awaitChildExit(child, new AbortController().signal);
373
- ok("4d B2: fast exit before awaitChildExit is NOT missed", code === 0);
374
- }
375
-
376
- // ── 5. awaitTimeout: schedules; abort clears + rejects ───────────────────────────
377
- {
378
- const sched = makeScheduler();
379
- const ctrl = new AbortController();
380
- const deps = makeProductionSpawnBgResumeDeps({
381
- setTimeoutFn: sched.setTimeoutFn,
382
- clearTimeoutFn: sched.clearTimeoutFn,
383
- });
384
- const p = deps.awaitTimeout(30_000, ctrl.signal);
385
- await flush();
386
- ok("5 awaitTimeout schedules a timer", sched.pending() === 1);
387
- // fire it → resolves
388
- sched.fireNext();
389
- await p;
390
- ok("5 awaitTimeout resolves when fired", true);
391
-
392
- // abort path clears the timer.
393
- const sched2 = makeScheduler();
394
- const ctrl2 = new AbortController();
395
- const deps2 = makeProductionSpawnBgResumeDeps({
396
- setTimeoutFn: sched2.setTimeoutFn,
397
- clearTimeoutFn: sched2.clearTimeoutFn,
398
- });
399
- const q = deps2.awaitTimeout(30_000, ctrl2.signal);
400
- await flush();
401
- ctrl2.abort();
402
- let threw = false;
403
- try {
404
- await q;
405
- } catch {
406
- threw = true;
407
- }
408
- ok("5 abort → awaitTimeout rejects", threw);
409
- ok("5 abort clears the timer", sched2.pending() === 0);
410
- }
411
-
412
- // ── 6 & 7. killChild SIGTERM; releaseLock delegates ──────────────────────────────
413
- {
414
- const fp = fakeProc();
415
- const holder: { released: LockClaim | null } = { released: null };
416
- const { deps, child } = await spawnedChild(fp, { releaseFn: (lock) => (holder.released = lock) });
417
- deps.killChild(child);
418
- ok("6 killChild sends SIGTERM", fp.calls.kills.length === 1 && fp.calls.kills[0] === "SIGTERM");
419
- const lock = lockClaim();
420
- deps.releaseLock(lock);
421
- ok("7 releaseLock delegates to the injected release fn", holder.released === lock);
422
- }
423
-
424
- // ── 8. mis-wire: a child with no proc fails loud ─────────────────────────────────
425
- {
426
- const deps = makeProductionSpawnBgResumeDeps({});
427
- let killThrew = false;
428
- try {
429
- deps.killChild({ pid: 1 });
430
- } catch (e) {
431
- killThrew = e instanceof Error && e.message.includes("no proc");
432
- }
433
- ok("8 killChild on a proc-less child fails loud", killThrew);
434
-
435
- let exitThrew = false;
436
- try {
437
- await deps.awaitChildExit({ pid: 1 }, new AbortController().signal);
438
- } catch (e) {
439
- exitThrew = e instanceof Error && e.message.includes("no proc");
440
- }
441
- ok("8 awaitChildExit on a proc-less child fails loud", exitThrew);
442
- }
443
- // ── 9. resolveResumeLaunchIdentity — the RECORD is the resume authority (#50 C2/C3) ──
444
- // Fixture-driven through a temp ENTWURF_META_SESSIONS_DIR: the record names the
445
- // transcript (gardenId → record.transcriptPath), and the transcript must prove it is
446
- // the citizen's own (header id === record.nativeSessionId — the C3 integrity check
447
- // that replaced the marker/name-tag authorization).
448
- {
449
- const world = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-prod-resolve-"));
450
- const storeDir = path.join(world, "meta-sessions");
451
- fs.mkdirSync(storeDir, { recursive: true });
452
- const prevStore = process.env.ENTWURF_META_SESSIONS_DIR;
453
- process.env.ENTWURF_META_SESSIONS_DIR = storeDir;
454
- try {
455
- const cwd = path.join(world, "repo");
456
- fs.mkdirSync(cwd, { recursive: true });
457
- const sessionLine = (id: string) => `${JSON.stringify({ type: "session", id, cwd })}\n`;
458
- const modelLine = `${JSON.stringify({ type: "model_change", provider: "openai-codex", modelId: "gpt-5.4" })}\n`;
459
- const writeTranscript = (name: string, content: string): string => {
460
- const p = path.join(world, name);
461
- fs.writeFileSync(p, content);
462
- return p;
463
- };
464
- const mintRecord = (nativeSessionId: string, transcriptPath: string | null, backend = "pi"): string =>
465
- upsertMetaSession({
466
- input: { backend: backend as "pi", nativeSessionId, cwd, model: "gpt-5.4", transcriptPath },
467
- dir: storeDir,
468
- }).record.gardenId;
469
- const planFor = (gid: string): SpawnBgPlan => ({ ...spawnBgPlan(), targetGardenId: gid, sessionId: gid });
470
- const rejects = (gid: string, needle: string, label: string): void => {
471
- let msg = "";
472
- try {
473
- resolveResumeLaunchIdentity(planFor(gid));
474
- } catch (e) {
475
- msg = e instanceof Error ? e.message : String(e);
476
- }
477
- ok(label, msg.includes(needle));
478
- };
479
-
480
- // happy: record → transcriptPath → header id matches nativeSessionId → LaunchIdentity.
481
- const nativeOk = "0199aaaa-1111-4222-8333-444455556666";
482
- const fileOk = writeTranscript("own.jsonl", sessionLine(nativeOk) + modelLine);
483
- const gidOk = mintRecord(nativeOk, fileOk);
484
- const launch = resolveResumeLaunchIdentity(planFor(gidOk));
485
- ok("9 record-backed resume resolves the recorded transcript", launch.sessionFile === fileOk);
486
- ok("9 resume cwd = header authority", launch.cwd === cwd);
487
- ok("9 resume model = first model_change", launch.model === "gpt-5.4" && launch.provider === "openai-codex");
488
-
489
- // C3 integrity: a transcript whose header id is NOT the citizen's nativeSessionId
490
- // (stale/foreign transcriptPath) must be refused, never resumed.
491
- const nativeMine = "0199bbbb-1111-4222-8333-444455556666";
492
- const foreignFile = writeTranscript(
493
- "foreign.jsonl",
494
- sessionLine("0199cccc-9999-4999-8999-999999999999") + modelLine,
495
- );
496
- const gidForeign = mintRecord(nativeMine, foreignFile);
497
- rejects(gidForeign, "does not match the record's nativeSessionId", "9 header ≠ nativeSessionId → refused (C3)");
498
-
499
- // missing record → not a garden citizen (readMetaIdentityByGardenId fail-fast).
500
- rejects("20260101T000000-facade", "not a garden citizen", "9 recordless gid → not a citizen");
501
-
502
- // A citizen outside the spawn-bg capability domain cannot use that rail.
503
- const gidClaude = mintRecord("claude-native-1", null, "claude-code");
504
- rejects(
505
- gidClaude,
506
- "spawn-bg resume is a host-adapter capability",
507
- "9 out-of-domain citizen → refused (spawn-bg rail)",
508
- );
509
-
510
- // pi citizen with no recorded transcript (no turn yet) → nothing to resume.
511
- const gidNoFile = mintRecord("0199dddd-1111-4222-8333-444455556666", null);
512
- rejects(gidNoFile, "no recorded transcriptPath", "9 transcriptPath null → nothing to resume");
513
-
514
- // transcriptPath recorded but the file is GONE (deleted transcript, or a
515
- // phantom path from a pre-guard birth). The refusal must name THIS cause —
516
- // before the existence check it fell through readSessionIdentity's ENOENT
517
- // swallow and lied "no recorded model" (F7).
518
- const gidGhost = mintRecord("0199abcd-1111-4222-8333-444455556666", path.join(world, "never-written.jsonl"));
519
- rejects(
520
- gidGhost,
521
- "does not exist on disk",
522
- "9 transcriptPath names a missing file → refused as missing, not as no-model",
523
- );
524
-
525
- // transcript with no model_change → no recorded model.
526
- const nativeNoModel = "0199eeee-1111-4222-8333-444455556666";
527
- const fileNoModel = writeTranscript("no-model.jsonl", sessionLine(nativeNoModel));
528
- const gidNoModel = mintRecord(nativeNoModel, fileNoModel);
529
- rejects(gidNoModel, "no recorded model", "9 no model_change → refused");
530
-
531
- // transcript with a model_change but NO session header line: the header id is
532
- // undefined, which can never equal the record's nativeSessionId — the C3
533
- // integrity check refuses it as "(none)" rather than resuming a headerless file.
534
- const nativeNoHeader = "0199ffff-1111-4222-8333-444455556666";
535
- const fileNoHeader = writeTranscript("no-header.jsonl", modelLine);
536
- const gidNoHeader = mintRecord(nativeNoHeader, fileNoHeader);
537
- rejects(gidNoHeader, '"(none)"', "9 headerless transcript → refused as (none), never resumed");
538
- } finally {
539
- if (prevStore === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
540
- else process.env.ENTWURF_META_SESSIONS_DIR = prevStore;
541
- fs.rmSync(world, { recursive: true, force: true });
542
- }
543
- }
544
-
545
- console.log(`\ncheck-entwurf-v2-spawn-production: ${passed} checks passed`);
546
- }
547
-
548
- main().catch((err) => {
549
- console.error(err);
550
- process.exit(1);
551
- });