@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,216 +0,0 @@
1
- /**
2
- * entwurf-v2-spawn — the 5c-3a spawn-bg RESUME watcher hand (0.11 Stage 0 step 5c-3a).
3
- * It WIRES the real spawn + socket-observe IO onto the pure release reducer (5c-1):
4
- * launch the resume child, watch for the FIRST observable liveness transition, feed
5
- * that observation to `reduceRelease`, and release the held lock EXACTLY ONCE — or, if
6
- * NO observation can be obtained, RETAIN the lock and surface the evidence rather than
7
- * release blind. Every IO seam is an injected dep (the gate fakes it with controlled
8
- * promises), so the spawn→observe→release ORDERING is gate-provable without a real
9
- * child, socket, or timer — the same "pure-before-IO, IO-via-dep" discipline 5b/5c-2
10
- * used.
11
- *
12
- * The load-bearing contract (Fable 3, the whole reason 5c is sliced pure-before-IO):
13
- * TIMEOUT IS NOT A RELEASE EVENT. A spawn-bg dispatch holds its per-gid lock until an
14
- * OBSERVED transition — `socket-alive` (the resumed child stood its control socket up)
15
- * or `child-exited` (any code, incl. null = killed by signal). A bare `observeTimeoutMs`
16
- * expiry proves NOTHING (the child may stand its socket up a moment later), so releasing
17
- * on it would reopen the exact double-spawn window 5a's lock exists to close. Instead the
18
- * timeout RESOLVES BY OBSERVATION: kill the child, then wait a BOUNDED `killGraceMs` for
19
- * the kill to produce a real `child-exited` (or a racing `socket-alive`). Only THAT
20
- * observation releases. If even the grace elapses with no observation, the hand does NOT
21
- * release — it returns a `lock-retained` diagnostic (released:false, with pid / socket /
22
- * lockPath / timeouts) so an operator can SEE the long-held lock, exactly as F2-P2
23
- * ("관측 가능해야 수용") demands. The function always returns BOUNDED — it never hangs.
24
- *
25
- * Post-spawn unexpected dep failure follows the SAME rule (GPT 5c-3a correction): once a
26
- * child exists, an observation-less release is forbidden. A watch/timer/kill dep that
27
- * throws is handled by best-effort kill → bounded attempt to OBSERVE the exit → release
28
- * if observed, else `lock-retained` fail-closed. There is NO direct-release escape hatch:
29
- * `deps.releaseLock` is reached ONLY through `reduceRelease` on a real observation event.
30
- *
31
- * Release authority is ALWAYS the LockClaim the decider handed over; the watcher reads
32
- * ONLY `plan.expectedSocketPath` and never re-derives a socket path or a lock by gid.
33
- */
34
- import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
35
- function tagSocket(p) {
36
- return p.then(() => ({ tag: "socket" }));
37
- }
38
- function tagExit(p) {
39
- return p.then((code) => ({ tag: "exit", code }));
40
- }
41
- function tagTimeout(p) {
42
- return p.then(() => ({ tag: "timeout" }));
43
- }
44
- function errMsg(err) {
45
- return err instanceof Error ? err.message : String(err);
46
- }
47
- /** Swallow a watcher promise's eventual rejection when it is abandoned without a race
48
- * handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
49
- * loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
50
- function defuse(p) {
51
- if (p)
52
- void p.catch(() => { });
53
- }
54
- /**
55
- * Launch a spawn-bg resume child and watch for the first observable liveness transition,
56
- * releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
57
- * surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
58
- * the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
59
- * wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
60
- *
61
- * A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
62
- * happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
63
- * 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
64
- * state is a returned `SpawnBgResumeResult`.
65
- */
66
- export async function executeSpawnBgResume(plan, lock, deps) {
67
- // Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
68
- // policy is release-after-spawn-observation and the lock is non-null.
69
- const policy = decideReleasePolicy(plan, lock);
70
- const held = lock;
71
- let state = initialReleaseState();
72
- // The ONLY path to deps.releaseLock: fold a real observation event through the reducer
73
- // and release iff (and exactly once) the reducer says so. No direct-release hatch.
74
- const fire = (event) => {
75
- const r = reduceRelease(policy, state, event);
76
- state = r.state;
77
- if (r.shouldRelease)
78
- deps.releaseLock(held);
79
- };
80
- // Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
81
- // watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
82
- let child;
83
- try {
84
- child = await deps.spawnChild(plan);
85
- }
86
- catch (err) {
87
- fire({ kind: "spawn-start-failed", error: errMsg(err) });
88
- return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
89
- }
90
- const controller = new AbortController();
91
- const { signal } = controller;
92
- // Created ONCE and reused across the primary race and the kill-grace race; in the
93
- // timeout branch both are still pending (timeout won), so re-racing them is safe.
94
- //
95
- // S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
96
- // SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
97
- // the send hand's case-13 backstop covers). The child already exists, so this is a
98
- // post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
99
- // Best-effort kill, defuse any watcher that DID start (so its later rejection is not
100
- // unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
101
- // observe the exit — awaitChildExit may itself be the thrower; retained is the honest
102
- // floor. (The async-rejection path is handled later by the race catch → backstop.)
103
- let socketP;
104
- let exitP;
105
- try {
106
- socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
107
- exitP = tagExit(deps.awaitChildExit(child, signal));
108
- }
109
- catch (err) {
110
- try {
111
- deps.killChild(child);
112
- }
113
- catch {
114
- // best-effort
115
- }
116
- controller.abort();
117
- defuse(socketP);
118
- defuse(exitP);
119
- return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
120
- }
121
- try {
122
- // Primary observation race. `timeout` participates but is NEVER a release event.
123
- let first;
124
- try {
125
- const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
126
- first = await Promise.race([socketP, exitP, timeoutP]);
127
- }
128
- catch (err) {
129
- // A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
130
- return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
131
- }
132
- if (first.tag === "socket") {
133
- fire({ kind: "socket-alive" });
134
- return { kind: "socket-alive", released: true, pid: child.pid };
135
- }
136
- if (first.tag === "exit") {
137
- fire({ kind: "child-exited", code: first.code });
138
- return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
139
- }
140
- // first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
141
- // grace for the kill to produce a real child-exited (or a racing socket-alive).
142
- try {
143
- deps.killChild(child);
144
- }
145
- catch {
146
- // kill itself threw — still try to observe within the grace; if nothing, retain.
147
- }
148
- let second;
149
- try {
150
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
151
- second = await Promise.race([socketP, exitP, graceP]);
152
- }
153
- catch (err) {
154
- // A dep rejected during the grace → cannot confirm; retain the lock, surface it.
155
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
156
- }
157
- if (second.tag === "socket") {
158
- fire({ kind: "socket-alive" });
159
- return { kind: "socket-alive", released: true, pid: child.pid };
160
- }
161
- if (second.tag === "exit") {
162
- fire({ kind: "child-exited", code: second.code });
163
- return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
164
- }
165
- // Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
166
- // retain the lock and surface the evidence (bounded return, never a hang).
167
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
168
- }
169
- finally {
170
- // Tear down the losing watchers (their timers / FS watches) on every exit.
171
- controller.abort();
172
- }
173
- }
174
- /**
175
- * Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
176
- * before any observation. An observation-less release is forbidden (it reopens the
177
- * double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
178
- * release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
179
- * already-suspect socket watcher) plus a fresh grace timer.
180
- */
181
- async function backstop(plan, deps, child, lockPath, exitP, signal, fire, originalError) {
182
- try {
183
- deps.killChild(child);
184
- }
185
- catch {
186
- // best-effort
187
- }
188
- try {
189
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
190
- const obs = await Promise.race([exitP, graceP]);
191
- if (obs.tag === "exit") {
192
- fire({ kind: "child-exited", code: obs.code });
193
- return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
194
- }
195
- }
196
- catch {
197
- // exit watcher itself was the failing dep (or threw again) → fall through to retain.
198
- }
199
- return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
200
- }
201
- function retained(plan, child, reason, killGraceMs, lockPath, error) {
202
- return {
203
- kind: "lock-retained",
204
- released: false,
205
- reason,
206
- error,
207
- diagnostic: {
208
- targetGardenId: plan.targetGardenId,
209
- pid: child.pid,
210
- expectedSocketPath: plan.expectedSocketPath,
211
- lockPath,
212
- observeTimeoutMs: plan.observeTimeoutMs,
213
- killGraceMs,
214
- },
215
- };
216
- }
@@ -1,373 +0,0 @@
1
- /**
2
- * entwurf-v2-spawn-production — the PRODUCTION `SpawnBgResumeDeps` factory for the 5c-3a
3
- * spawn-bg watcher (0.11 Stage 0 step 5c-3c). It wires the six injected IO seams the pure
4
- * watcher (`executeSpawnBgResume`) declares onto the real world: spawn a resume child as a
5
- * resident citizen, observe its control socket by CONNECTABILITY (not mere existence),
6
- * watch its exit, time the observe window, and kill on timeout. The watcher's release
7
- * policy is untouched — this module only supplies the IO.
8
- *
9
- * Every seam stays injectable (defaults = the real fns) so a DETERMINISTIC gate drives
10
- * spawnChild's argv, the exit/timeout/kill wiring, and the socket-alive verdict WITHOUT a
11
- * real pi spawn or socket — the heavy live path (a real `pi --entwurf-control` resident
12
- * child + a real unix-socket connect) is proven once by a separate opt-in smoke
13
- * (`smoke-entwurf-v2-spawn-live`), kept OUT of `pnpm check` so the every-commit loop stays
14
- * fast and deterministic (D5: chain = deterministic gate; live smoke = phase gate before 5d).
15
- *
16
- * The factory does NOT capture `plan` or `lock` (D3): `SpawnBgResumeDeps.spawnChild(plan)`
17
- * already takes the plan, and the lock is the watcher's authority — it flows to
18
- * `deps.releaseLock` from the watcher, never from here. A captured plan/lock would be a
19
- * second authority that could drift from the one the watcher holds.
20
- *
21
- * socket-alive = CONNECTABLE, never file-exists (GPT 5c-3c, Q5): inspect the EXACT
22
- * `plan.expectedSocketPath` (via the path-addressed `inspectControlSocketPath` — no gid
23
- * re-derivation), map to liveness through the shared `mapInspectionToLiveness`, then apply
24
- * `socketWatchVerdict`: a connectable socket is alive; a forged address (symlink / not a
25
- * socket) is rejected IMMEDIATELY (time does not heal a forged path → the watcher's backstop
26
- * kills and fail-closes to a retained lock); a dead/indeterminate socket keeps waiting (the
27
- * dormant citizen's stale socket file, or a stall, is "not up yet", not a failure).
28
- */
29
-
30
- import { type ChildProcess, spawn } from "node:child_process";
31
- import { existsSync } from "node:fs";
32
- import * as fs from "node:fs/promises";
33
- import { getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity } from "./entwurf-core.ts";
34
- import { buildResumePiArgs } from "./entwurf-resume-args.ts";
35
- import type { LockClaim } from "./entwurf-v2-lock.ts";
36
- import { releaseLock } from "./entwurf-v2-lock.ts";
37
- import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "./entwurf-v2-spawn.ts";
38
- import { readAddressableMetaIdentity } from "./meta-session.ts";
39
- import { inspectControlSocketPath, type LstatLike, mapInspectionToLiveness } from "./socket-discovery.ts";
40
- import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
41
-
42
- // ── socketWatchVerdict (pure — the R2 watch policy, gate-pinned) ──────────────
43
- export type SocketWatchVerdict = "alive" | "wait" | "forged";
44
-
45
- /**
46
- * Translate a mapped socket observation into the watcher's poll decision.
47
- * - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
48
- * state: time cannot turn a forged path into a live socket, so the poll must REJECT
49
- * immediately (→ watcher backstop → kill → retained), never keep waiting.
50
- * - alive — a connectable socket. Resolve: the resumed citizen is up.
51
- * - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
52
- * indeterminate (a stall). Keep polling until the socket connects, or the observe
53
- * timeout / abort ends the wait.
54
- */
55
- export function socketWatchVerdict(
56
- mapped: { liveness: SocketLiveness; socketPath: string } | { addressConflict: true },
57
- ): SocketWatchVerdict {
58
- if ("addressConflict" in mapped) return "forged";
59
- return mapped.liveness === "alive" ? "alive" : "wait";
60
- }
61
-
62
- // ── the spawned child handle (D4) ────────────────────────────────────────────
63
- // A minimal structural view of the process the deps share. The real ChildProcess
64
- // satisfies it; the gate fakes it. The watcher sees only `pid` (SpawnedChild); the
65
- // production deps carry `proc` so awaitChildExit / killChild act on the SAME process.
66
- export interface SpawnedProcHandle {
67
- pid?: number;
68
- kill(signal?: NodeJS.Signals | number): boolean;
69
- on(event: string, listener: (...args: unknown[]) => void): unknown;
70
- removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
71
- }
72
-
73
- interface ProductionSpawnedChild extends SpawnedChild {
74
- proc: SpawnedProcHandle;
75
- /** Exit captured EAGERLY at proc creation (B2): the `exit` listener is installed the
76
- * instant the proc exists, BEFORE the watcher's awaitChildExit runs, so a child that
77
- * exits in the micro-gap between spawnChild resolving and awaitChildExit registering is
78
- * never missed (which would pin the lock through timeout→kill→retained). Resolve-ONLY
79
- * (never rejects) so it is safe to leave racing an abort in awaitChildExit. */
80
- exitPromise: Promise<number | null>;
81
- }
82
-
83
- /** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
84
- * NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
85
- function requireProductionChild(child: SpawnedChild): ProductionSpawnedChild {
86
- const c = child as Partial<ProductionSpawnedChild>;
87
- if (!c.proc || !c.exitPromise) {
88
- throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
89
- }
90
- return c as ProductionSpawnedChild;
91
- }
92
-
93
- // ── launch identity (the spawnChild preamble, one injectable seam) ───────────
94
- /** The launch-time facts buildResumePiArgs needs, resolved from the meta-record + the
95
- * recorded transcript (record authority, #50 C2/C3); the gate injects a fake so
96
- * spawnChild's argv is provable without a real session file. */
97
- export interface LaunchIdentity {
98
- /** The EXACT session JSONL to resume — `pi --session <path>`. */
99
- sessionFile: string;
100
- cwd: string;
101
- explicitExtensionArgs: readonly string[];
102
- provider: string | null | undefined;
103
- model: string;
104
- }
105
-
106
- /**
107
- * Resolve launch identity for a resume. The TARGET is now resolved through the
108
- * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
109
- * header scan for a JSONL whose header id equalled the garden id — which only worked
110
- * while entwurf forced pi's session id to BE the garden id. With the record minting the
111
- * address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
112
- * keeping it would not have been a "smaller change", it would have been a broken one.
113
- *
114
- * The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
115
- * (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
116
- * exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
117
- * citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
118
- * with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
119
- * file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
120
- * record remembers whose it is — a mismatch means the transcriptPath is stale or
121
- * foreign, and resuming it would put a turn into a different being's session).
122
- *
123
- * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
124
- * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
125
- * integrity check and resume the SAME transcript concurrently under two different locks.
126
- * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
127
- * a resume is exactly the moment a record stops being data and becomes an address.
128
- *
129
- * Everything else is unchanged authority: readSessionIdentity (first model_change) for
130
- * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
131
- * Throws on anything that makes a resume impossible; each throw becomes the watcher's
132
- * `spawn-start-failed` (no child to watch → release), never a silent no-op.
133
- */
134
- export function resolveResumeLaunchIdentity(plan: SpawnBgPlan): LaunchIdentity {
135
- const record = readAddressableMetaIdentity(plan.sessionId);
136
- if (record.backend !== "pi") {
137
- throw new Error(
138
- `entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
139
- `spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
140
- `This is a relaunch capability, not the control-socket rail and not citizen rank.`,
141
- );
142
- }
143
- const sessionFile = record.transcriptPath;
144
- if (!sessionFile) {
145
- throw new Error(
146
- `entwurf-v2-spawn-production: ${plan.sessionId} has no recorded transcriptPath — ` +
147
- `the citizen never wrote a session file (no turn yet), so there is nothing to resume.`,
148
- );
149
- }
150
- // A recorded path is only a resume target while the file is actually on disk.
151
- // Without this check a missing transcript falls through readSessionIdentity's
152
- // ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
153
- // the transcript was deleted, or the record carries a phantom path minted
154
- // before birth guarded on file existence.
155
- if (!existsSync(sessionFile)) {
156
- throw new Error(
157
- `entwurf-v2-spawn-production: ${plan.sessionId} recorded transcriptPath "${sessionFile}" ` +
158
- `does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
159
- `path from a pre-guard birth; nothing to resume.`,
160
- );
161
- }
162
- const identity = readSessionIdentity(sessionFile);
163
- const resumeModel = identity?.modelId ?? null;
164
- if (!identity || !resumeModel) {
165
- throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
166
- }
167
- if (identity.sessionId !== record.nativeSessionId) {
168
- throw new Error(
169
- `entwurf-v2-spawn-production: ${plan.sessionId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
170
- `does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
171
- `is stale or points at a foreign session file; refusing to resume another being's transcript.`,
172
- );
173
- }
174
- const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
175
- if (explicitExtensions.unresolvedAcpIntent) {
176
- throw new Error(
177
- `entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
178
- `extension could not be resolved — refusing to resume with an unknown provider (#29).`,
179
- );
180
- }
181
- if (!identity.cwd) {
182
- throw new Error(
183
- `entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`,
184
- );
185
- }
186
- return {
187
- sessionFile,
188
- cwd: identity.cwd,
189
- explicitExtensionArgs: explicitExtensions.args,
190
- provider: explicitExtensions.provider ?? identity.provider,
191
- model: explicitExtensions.modelOverride ?? resumeModel,
192
- };
193
- }
194
-
195
- // ── the injectable seams (defaults = the real world) ─────────────────────────
196
- export interface ProductionSpawnOpts {
197
- /** Poll interval for awaitSocketAlive's wait loop (ms). */
198
- pollIntervalMs?: number;
199
- /** Bounded wait, after a kill, for the resulting child-exited (the watcher's killGraceMs). */
200
- killGraceMs?: number;
201
- /** Connect-probe timeout for a socket-file inspection (ms). */
202
- probeTimeoutMs?: number;
203
- /** Resolve launch identity (default = resolveResumeLaunchIdentity). */
204
- resolveIdentity?: (plan: SpawnBgPlan) => LaunchIdentity;
205
- /** Spawn the resume child and return its handle (default = real `pi` spawn + unref +
206
- * mirrorChildStderr). The child inherits this process's env untouched — the resume-marker
207
- * env the factory used to plant died with the name-mirror guard it authorized (#50 C3).
208
- * A throw becomes the watcher's spawn-start-failed. */
209
- spawnChild?: (cmd: string, args: readonly string[], cwd: string) => SpawnedProcHandle;
210
- /** lstat for socket inspection (default = fs.lstat). */
211
- lstatFn?: (p: string) => Promise<LstatLike>;
212
- /** Connect probe for a socket-file (default = probeSocketLiveness). */
213
- probeFn?: (socketPath: string) => Promise<SocketLiveness>;
214
- /** Release the held lock (default = the lock primitive's releaseLock). */
215
- releaseFn?: (lock: LockClaim) => void;
216
- /** Timer primitives (default = global setTimeout/clearTimeout) — injected so the gate
217
- * proves scheduling + abort-clear deterministically. */
218
- setTimeoutFn?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
219
- clearTimeoutFn?: (t: ReturnType<typeof setTimeout>) => void;
220
- }
221
-
222
- const DEFAULT_POLL_INTERVAL_MS = 150;
223
- const DEFAULT_KILL_GRACE_MS = 5_000;
224
-
225
- /** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
226
- * the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
227
- * from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
228
- function defaultSpawnChild(cmd: string, args: readonly string[], cwd: string): SpawnedProcHandle {
229
- const proc: ChildProcess = spawn(cmd, [...args], {
230
- cwd,
231
- shell: false,
232
- detached: true,
233
- stdio: ["ignore", "ignore", "pipe"],
234
- });
235
- proc.unref();
236
- mirrorChildStderr(proc);
237
- return proc;
238
- }
239
-
240
- /**
241
- * Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
242
- * NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
243
- * the deterministic gate; the defaults are the real IO.
244
- */
245
- export function makeProductionSpawnBgResumeDeps(opts: ProductionSpawnOpts = {}): SpawnBgResumeDeps {
246
- const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
247
- const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
248
- const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
249
- const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
250
- const lstatFn = opts.lstatFn ?? ((p: string) => fs.lstat(p));
251
- const probeFn =
252
- opts.probeFn ?? ((socketPath: string) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
253
- const releaseFn = opts.releaseFn ?? ((lock: LockClaim) => void releaseLock(lock));
254
- const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
255
- const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
256
-
257
- // An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
258
- // by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
259
- const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
260
- new Promise<void>((resolve, reject) => {
261
- if (signal.aborted) {
262
- reject(new Error("aborted"));
263
- return;
264
- }
265
- const timer = setTimeoutFn(() => {
266
- signal.removeEventListener("abort", onAbort);
267
- resolve();
268
- }, ms);
269
- const onAbort = (): void => {
270
- clearTimeoutFn(timer);
271
- reject(new Error("aborted"));
272
- };
273
- signal.addEventListener("abort", onAbort, { once: true });
274
- });
275
-
276
- return {
277
- killGraceMs,
278
-
279
- spawnChild: async (plan: SpawnBgPlan): Promise<SpawnedChild> => {
280
- const identity = resolveIdentity(plan);
281
- const args = buildResumePiArgs({
282
- variant: "v2-control",
283
- sessionFile: identity.sessionFile,
284
- explicitExtensionArgs: identity.explicitExtensionArgs,
285
- provider: identity.provider,
286
- model: identity.model,
287
- prompt: plan.prompt,
288
- launchArgs: plan.launchArgs,
289
- });
290
- const proc = spawnChildFn("pi", args, identity.cwd);
291
-
292
- // B2: capture exit EAGERLY — the instant the proc exists, before we even await the
293
- // spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
294
- // only (a signal kill reports null); it never rejects, so racing it against abort is safe.
295
- const exitPromise = new Promise<number | null>((resolve) => {
296
- proc.on("exit", (code: unknown) => resolve(typeof code === "number" ? code : null));
297
- });
298
-
299
- // B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
300
- // failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
301
- // for the `spawn` event to confirm a real start; an `error` before it rejects, which the
302
- // watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
303
- // spawn-started that later stalls into a wrongful retained lock.
304
- await new Promise<void>((resolve, reject) => {
305
- const onSpawn = (): void => {
306
- cleanup();
307
- resolve();
308
- };
309
- const onError = (err: unknown): void => {
310
- cleanup();
311
- reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
312
- };
313
- const cleanup = (): void => {
314
- proc.removeListener("spawn", onSpawn);
315
- proc.removeListener("error", onError);
316
- };
317
- proc.on("spawn", onSpawn);
318
- proc.on("error", onError);
319
- });
320
-
321
- const child: ProductionSpawnedChild = { pid: proc.pid, proc, exitPromise };
322
- return child;
323
- },
324
-
325
- awaitSocketAlive: async (socketPath: string, signal: AbortSignal): Promise<void> => {
326
- // Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
327
- // reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
328
- while (!signal.aborted) {
329
- const inspection = await inspectControlSocketPath(socketPath, lstatFn);
330
- const mapped = await mapInspectionToLiveness(inspection, probeFn);
331
- const verdict = socketWatchVerdict(mapped);
332
- if (verdict === "alive") return;
333
- if (verdict === "forged") {
334
- throw new Error(
335
- `entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`,
336
- );
337
- }
338
- // wait — sleep one interval, honoring abort (rejects → loop exits below).
339
- await abortableDelay(pollIntervalMs, signal);
340
- }
341
- throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
342
- },
343
-
344
- awaitChildExit: (child: SpawnedChild, signal: AbortSignal): Promise<number | null> =>
345
- // Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
346
- // B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
347
- // unhandled rejection; only the abort branch rejects.
348
- new Promise<number | null>((resolve, reject) => {
349
- const { exitPromise } = requireProductionChild(child);
350
- if (signal.aborted) {
351
- reject(new Error("aborted"));
352
- return;
353
- }
354
- const onAbort = (): void => reject(new Error("aborted"));
355
- signal.addEventListener("abort", onAbort, { once: true });
356
- exitPromise.then((code) => {
357
- signal.removeEventListener("abort", onAbort);
358
- resolve(code);
359
- });
360
- }),
361
-
362
- awaitTimeout: (ms: number, signal: AbortSignal): Promise<void> => abortableDelay(ms, signal),
363
-
364
- killChild: (child: SpawnedChild): void => {
365
- // SIGTERM only this slice — the watcher's killGrace then waits for the resulting
366
- // child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
367
- // separate policy + smoke). Best-effort: the watcher catches a throw here.
368
- requireProductionChild(child).proc.kill("SIGTERM");
369
- },
370
-
371
- releaseLock: (lock: LockClaim): void => releaseFn(lock),
372
- };
373
- }