@junghanacs/entwurf 0.13.0 → 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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  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 +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -1,323 +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
-
35
- import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
36
- import type { LockClaim } from "./entwurf-v2-lock.ts";
37
- import {
38
- decideReleasePolicy,
39
- initialReleaseState,
40
- type ReleaseEvent,
41
- type ReleasePolicy,
42
- type ReleaseState,
43
- reduceRelease,
44
- } from "./entwurf-v2-release.ts";
45
-
46
- /** The spawn-bg plan shape, narrowed from the decider's ExecutionPlan union. */
47
- export type SpawnBgPlan = Extract<ExecutionPlan, { transport: "spawn-bg" }>;
48
-
49
- /** A started resume child. `pid` is diagnostic-only (surfaced on a retained lock); the
50
- * watcher tracks the child by this opaque handle, never by gid. */
51
- export interface SpawnedChild {
52
- pid?: number;
53
- }
54
-
55
- /**
56
- * Every IO seam is a REQUIRED dep — the hand performs ZERO IO of its own so the gate can
57
- * drive every observation order without a real spawn/socket/timer.
58
- * - spawnChild — launch the resume child. Resolves a handle on a STARTED process;
59
- * THROWS on a spawn-time failure (→ `spawn-start-failed`: no child to watch).
60
- * - awaitSocketAlive — resolve when `expectedSocketPath` becomes observable (the child
61
- * stood its control socket up). NEVER resolves if the socket never appears — the
62
- * timeout path handles that. Honors the abort signal so a loser can be torn down.
63
- * - awaitChildExit — resolve with the child's exit code (null = killed by signal).
64
- * Honors the abort signal.
65
- * - awaitTimeout — resolve after `ms` (the observe deadline, and again the kill
66
- * grace). Honors the abort signal. NOTE: a timeout is NEVER fed to the reducer.
67
- * - killChild — terminate the started child. Used ONLY to escalate a timeout (or
68
- * a post-spawn dep failure) into a real `child-exited`; it is never a release itself.
69
- * - releaseLock — release the held claim; reached ONLY via reduceRelease, at most
70
- * once, only on a real observation event.
71
- * - killGraceMs — bounded wait, after a kill, for the resulting observation. Keeps
72
- * the watcher from hanging on a zombie: if it elapses, the lock is RETAINED (surfaced),
73
- * not released. A watcher-policy constant (NOT a routing decision — so it lives on the
74
- * dep, not the decider's plan).
75
- */
76
- export interface SpawnBgResumeDeps {
77
- spawnChild: (plan: SpawnBgPlan) => Promise<SpawnedChild>;
78
- awaitSocketAlive: (socketPath: string, signal: AbortSignal) => Promise<void>;
79
- awaitChildExit: (child: SpawnedChild, signal: AbortSignal) => Promise<number | null>;
80
- awaitTimeout: (ms: number, signal: AbortSignal) => Promise<void>;
81
- killChild: (child: SpawnedChild) => void;
82
- releaseLock: (lock: LockClaim) => void;
83
- killGraceMs: number;
84
- }
85
-
86
- /** Evidence carried on a `lock-retained` outcome so an operator can SEE (and clear) the
87
- * lock that no observation released. */
88
- export interface SpawnRetainedDiagnostic {
89
- targetGardenId: string;
90
- pid?: number;
91
- expectedSocketPath: string;
92
- lockPath: string;
93
- observeTimeoutMs: number;
94
- killGraceMs: number;
95
- }
96
-
97
- /**
98
- * The terminal outcome of a spawn-bg resume watch. The three RELEASED outcomes each fed
99
- * exactly one observation event to the reducer; the `lock-retained` outcome fed NONE (it
100
- * is the observation-less fail-closed — `released:false`, lock left for the operator).
101
- * - socket-alive — the resumed child stood its socket up.
102
- * - child-exited — the child exited (any code; null = signal/kill).
103
- * - spawn-start-failed — the child never started (spawnChild threw); nothing to watch.
104
- * - lock-retained — no observation was obtainable (kill grace elapsed, or a
105
- * post-spawn dep failed and the exit could not be observed). Lock NOT released.
106
- */
107
- export type SpawnBgResumeResult =
108
- | { kind: "socket-alive"; released: true; pid?: number }
109
- | { kind: "child-exited"; released: true; exitCode: number | null; pid?: number }
110
- | { kind: "spawn-start-failed"; released: true; error: string }
111
- | {
112
- kind: "lock-retained";
113
- released: false;
114
- reason: "kill-unconfirmed" | "observe-failed";
115
- error?: string;
116
- diagnostic: SpawnRetainedDiagnostic;
117
- };
118
-
119
- // An internal tag for the observation race: which source settled first.
120
- type Observation = { tag: "socket" } | { tag: "exit"; code: number | null } | { tag: "timeout" };
121
-
122
- function tagSocket(p: Promise<void>): Promise<Observation> {
123
- return p.then(() => ({ tag: "socket" }) as const);
124
- }
125
- function tagExit(p: Promise<number | null>): Promise<Observation> {
126
- return p.then((code) => ({ tag: "exit", code }) as const);
127
- }
128
- function tagTimeout(p: Promise<void>): Promise<Observation> {
129
- return p.then(() => ({ tag: "timeout" }) as const);
130
- }
131
-
132
- function errMsg(err: unknown): string {
133
- return err instanceof Error ? err.message : String(err);
134
- }
135
-
136
- /** Swallow a watcher promise's eventual rejection when it is abandoned without a race
137
- * handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
138
- * loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
139
- function defuse(p: Promise<unknown> | undefined): void {
140
- if (p) void p.catch(() => {});
141
- }
142
-
143
- /**
144
- * Launch a spawn-bg resume child and watch for the first observable liveness transition,
145
- * releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
146
- * surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
147
- * the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
148
- * wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
149
- *
150
- * A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
151
- * happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
152
- * 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
153
- * state is a returned `SpawnBgResumeResult`.
154
- */
155
- export async function executeSpawnBgResume(
156
- plan: SpawnBgPlan,
157
- lock: LockClaim | null,
158
- deps: SpawnBgResumeDeps,
159
- ): Promise<SpawnBgResumeResult> {
160
- // Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
161
- // policy is release-after-spawn-observation and the lock is non-null.
162
- const policy: ReleasePolicy = decideReleasePolicy(plan, lock);
163
- const held = lock as LockClaim;
164
-
165
- let state: ReleaseState = initialReleaseState();
166
- // The ONLY path to deps.releaseLock: fold a real observation event through the reducer
167
- // and release iff (and exactly once) the reducer says so. No direct-release hatch.
168
- const fire = (event: ReleaseEvent): void => {
169
- const r = reduceRelease(policy, state, event);
170
- state = r.state;
171
- if (r.shouldRelease) deps.releaseLock(held);
172
- };
173
-
174
- // Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
175
- // watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
176
- let child: SpawnedChild;
177
- try {
178
- child = await deps.spawnChild(plan);
179
- } catch (err) {
180
- fire({ kind: "spawn-start-failed", error: errMsg(err) });
181
- return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
182
- }
183
-
184
- const controller = new AbortController();
185
- const { signal } = controller;
186
- // Created ONCE and reused across the primary race and the kill-grace race; in the
187
- // timeout branch both are still pending (timeout won), so re-racing them is safe.
188
- //
189
- // S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
190
- // SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
191
- // the send hand's case-13 backstop covers). The child already exists, so this is a
192
- // post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
193
- // Best-effort kill, defuse any watcher that DID start (so its later rejection is not
194
- // unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
195
- // observe the exit — awaitChildExit may itself be the thrower; retained is the honest
196
- // floor. (The async-rejection path is handled later by the race catch → backstop.)
197
- let socketP!: Promise<Observation>;
198
- let exitP!: Promise<Observation>;
199
- try {
200
- socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
201
- exitP = tagExit(deps.awaitChildExit(child, signal));
202
- } catch (err) {
203
- try {
204
- deps.killChild(child);
205
- } catch {
206
- // best-effort
207
- }
208
- controller.abort();
209
- defuse(socketP);
210
- defuse(exitP);
211
- return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
212
- }
213
-
214
- try {
215
- // Primary observation race. `timeout` participates but is NEVER a release event.
216
- let first: Observation;
217
- try {
218
- const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
219
- first = await Promise.race([socketP, exitP, timeoutP]);
220
- } catch (err) {
221
- // A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
222
- return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
223
- }
224
-
225
- if (first.tag === "socket") {
226
- fire({ kind: "socket-alive" });
227
- return { kind: "socket-alive", released: true, pid: child.pid };
228
- }
229
- if (first.tag === "exit") {
230
- fire({ kind: "child-exited", code: first.code });
231
- return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
232
- }
233
-
234
- // first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
235
- // grace for the kill to produce a real child-exited (or a racing socket-alive).
236
- try {
237
- deps.killChild(child);
238
- } catch {
239
- // kill itself threw — still try to observe within the grace; if nothing, retain.
240
- }
241
- let second: Observation;
242
- try {
243
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
244
- second = await Promise.race([socketP, exitP, graceP]);
245
- } catch (err) {
246
- // A dep rejected during the grace → cannot confirm; retain the lock, surface it.
247
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
248
- }
249
- if (second.tag === "socket") {
250
- fire({ kind: "socket-alive" });
251
- return { kind: "socket-alive", released: true, pid: child.pid };
252
- }
253
- if (second.tag === "exit") {
254
- fire({ kind: "child-exited", code: second.code });
255
- return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
256
- }
257
- // Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
258
- // retain the lock and surface the evidence (bounded return, never a hang).
259
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
260
- } finally {
261
- // Tear down the losing watchers (their timers / FS watches) on every exit.
262
- controller.abort();
263
- }
264
- }
265
-
266
- /**
267
- * Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
268
- * before any observation. An observation-less release is forbidden (it reopens the
269
- * double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
270
- * release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
271
- * already-suspect socket watcher) plus a fresh grace timer.
272
- */
273
- async function backstop(
274
- plan: SpawnBgPlan,
275
- deps: SpawnBgResumeDeps,
276
- child: SpawnedChild,
277
- lockPath: string,
278
- exitP: Promise<Observation>,
279
- signal: AbortSignal,
280
- fire: (event: ReleaseEvent) => void,
281
- originalError: string,
282
- ): Promise<SpawnBgResumeResult> {
283
- try {
284
- deps.killChild(child);
285
- } catch {
286
- // best-effort
287
- }
288
- try {
289
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
290
- const obs = await Promise.race([exitP, graceP]);
291
- if (obs.tag === "exit") {
292
- fire({ kind: "child-exited", code: obs.code });
293
- return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
294
- }
295
- } catch {
296
- // exit watcher itself was the failing dep (or threw again) → fall through to retain.
297
- }
298
- return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
299
- }
300
-
301
- function retained(
302
- plan: SpawnBgPlan,
303
- child: SpawnedChild,
304
- reason: "kill-unconfirmed" | "observe-failed",
305
- killGraceMs: number,
306
- lockPath: string,
307
- error: string | undefined,
308
- ): SpawnBgResumeResult {
309
- return {
310
- kind: "lock-retained",
311
- released: false,
312
- reason,
313
- error,
314
- diagnostic: {
315
- targetGardenId: plan.targetGardenId,
316
- pid: child.pid,
317
- expectedSocketPath: plan.expectedSocketPath,
318
- lockPath,
319
- observeTimeoutMs: plan.observeTimeoutMs,
320
- killGraceMs,
321
- },
322
- };
323
- }
@@ -1,236 +0,0 @@
1
- // Deterministic gate for the S2a ACP SDK dependency surface.
2
- //
3
- // Pins the ACP runtime deps to the current behavior-oracle versions and locks
4
- // the peer-resolution that makes the Claude ACP adapter satisfiable:
5
- //
6
- // @agentclientprotocol/sdk 1.3.0 wire SDK (acp-bridge import source)
7
- // @agentclientprotocol/claude-agent-acp 0.62.0 Claude adapter (spawn binary)
8
- // @anthropic-ai/sdk 0.100.1 peer-resolution pin ONLY (see below)
9
- //
10
- // 2026-07-27 bump 0.61.0 → 0.62.0: the adapter's own `dist/` is byte-identical
11
- // across the two releases; 0.62.0 moves only its declared deps
12
- // (claude-agent-sdk 0.3.217 → 0.3.219; its devDep anthropic sdk, which does not
13
- // reach us). So this bump is a dependency refresh that carries NO adapter-code
14
- // change — and specifically NOT a fix for the bundled-MCP readiness race
15
- // (ROADMAP 🔴): 0.62.0 adds no explicit readiness fence. Effective runtime
16
- // behavior is NOT claimed identical: the transitive SDK moved, and MCP startup
17
- // lives inside it, so timing may differ — what is established is only that no
18
- // new fence was added.
19
- //
20
- // The anthropic SDK is NOT an API client / auth surface here. It is a direct
21
- // dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.219's peer floor
22
- // (>=0.93.0 — re-measured at 0.3.219, unchanged from 0.3.217, so the 0.100.1
23
- // pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
24
- // unmet — a failure that would only surface at the first raw turn. The
25
- // lockfile proves the same shape. Source-level import / API-client
26
- // instantiation / credential use stays forbidden — asserted in layer (4).
27
- // (GPT hard constraint 2, revised 2026-06-18: direct dep allowed ONLY as an
28
- // exact peer-resolution pin; source-level use remains forbidden.)
29
- //
30
- // Layers:
31
- // (1) package.json exact pins for all three deps;
32
- // (2) pnpm-lock peer-resolution lock (adapter + claude-agent-sdk → 0.100.1);
33
- // (2b) runtime peer-resolution probe (claude-agent-sdk context → 0.100.1);
34
- // (3) @agentclientprotocol/sdk value-export surface (silent-rename gate);
35
- // (4) no tracked source imports the anthropic SDK or builds an API client.
36
-
37
- import { strict as assert } from "node:assert";
38
- import { execFileSync } from "node:child_process";
39
- import { existsSync, readFileSync } from "node:fs";
40
- import { createRequire } from "node:module";
41
- import { dirname, resolve } from "node:path";
42
-
43
- // Split literals so this gate's own regexes never self-match in layer (4).
44
- const ANTHROPIC_SDK = `@anthropic-ai/${"sdk"}`;
45
- const API_CLIENT_CLASS = `${"Anthropic"}`;
46
-
47
- const repoRoot = resolve(import.meta.dirname, "..");
48
- const read = (p: string): string => readFileSync(resolve(repoRoot, p), "utf8");
49
-
50
- // ---------------------------------------------------------------------------
51
- // (1) package.json — exact pins (no caret/range; S2a freezes the oracle set)
52
- // ---------------------------------------------------------------------------
53
- const pkg = JSON.parse(read("package.json")) as { dependencies?: Record<string, string> };
54
- const deps = pkg.dependencies ?? {};
55
- const PINS: Record<string, string> = {
56
- "@agentclientprotocol/sdk": "1.3.0",
57
- "@agentclientprotocol/claude-agent-acp": "0.62.0",
58
- [ANTHROPIC_SDK]: "0.100.1",
59
- };
60
- for (const [name, ver] of Object.entries(PINS)) {
61
- assert.equal(
62
- deps[name],
63
- ver,
64
- `package.json dependencies["${name}"] must be exact "${ver}" (got "${deps[name]}") — S2a pins the current oracle versions`,
65
- );
66
- }
67
-
68
- // ---------------------------------------------------------------------------
69
- // (2) pnpm-lock — peer-resolution lock
70
- // The adapter and claude-agent-sdk MUST peer-resolve the anthropic SDK to
71
- // 0.100.1; a 0.91.1 resolution means the >=0.93.0 peer is unmet (the bug
72
- // this whole pin exists to prevent).
73
- // ---------------------------------------------------------------------------
74
- const lock = read("pnpm-lock.yaml");
75
- assert.match(
76
- lock,
77
- /@agentclientprotocol\/claude-agent-acp@0\.62\.0\(@anthropic-ai\/sdk@0\.100\.1/,
78
- "pnpm-lock: claude-agent-acp@0.62.0 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
79
- );
80
- assert.match(
81
- lock,
82
- /@anthropic-ai\/claude-agent-sdk@0\.3\.219\(@anthropic-ai\/sdk@0\.100\.1/,
83
- "pnpm-lock: claude-agent-sdk@0.3.219 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
84
- );
85
-
86
- // ---------------------------------------------------------------------------
87
- // (2b) runtime peer-resolution probe — the actual Node resolver, not lock text.
88
- // Layer (2) freezes the publish/install floor; this probes that the
89
- // claude-agent-sdk → @anthropic-ai/sdk peer edge really resolves to
90
- // 0.100.1 in a live module graph. Two different failures, both needed.
91
- // Note: a top-level/adapter-context resolve of the anthropic SDK may see
92
- // 0.91.1 (pi's own transitive) — that is normal. The edge that must be
93
- // 0.100.1 is the one *inside* claude-agent-sdk's context.
94
- // ---------------------------------------------------------------------------
95
- const pkgInfoFromEntry = (entryPath: string): { name: string; version: string; dir: string } => {
96
- // A `<pkg>/package.json` subpath resolve can fail under "exports"; walk up
97
- // from the resolved entry to the nearest package.json instead.
98
- let dir = dirname(entryPath);
99
- for (;;) {
100
- try {
101
- const pj = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as {
102
- name?: string;
103
- version?: string;
104
- };
105
- if (pj.name && pj.version) return { name: pj.name, version: pj.version, dir };
106
- } catch {
107
- // no package.json here (or unreadable) — keep walking up.
108
- }
109
- const parent = dirname(dir);
110
- if (parent === dir) throw new Error(`no package.json found walking up from ${entryPath}`);
111
- dir = parent;
112
- }
113
- };
114
-
115
- const rootRequire = createRequire(resolve(repoRoot, "package.json"));
116
- const adapterPkgJson = rootRequire.resolve("@agentclientprotocol/claude-agent-acp/package.json");
117
- const adapterRequire = createRequire(adapterPkgJson);
118
- const casEntry = adapterRequire.resolve("@anthropic-ai/claude-agent-sdk");
119
- const casInfo = pkgInfoFromEntry(casEntry);
120
- assert.equal(
121
- casInfo.version,
122
- "0.3.219",
123
- `@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.219 from the adapter context (got ${casInfo.version})`,
124
- );
125
- const casRequire = createRequire(resolve(casInfo.dir, "package.json"));
126
- const sdkEntry = casRequire.resolve(ANTHROPIC_SDK);
127
- const sdkInfo = pkgInfoFromEntry(sdkEntry);
128
- assert.equal(
129
- sdkInfo.version,
130
- "0.100.1",
131
- `${ANTHROPIC_SDK} must peer-resolve to 0.100.1 from the claude-agent-sdk context (got ${sdkInfo.version}) — a 0.91.1 here means the >=0.93.0 peer is unmet and the raw turn would break`,
132
- );
133
-
134
- // ---------------------------------------------------------------------------
135
- // (2c) adapter-context wire-SDK + MCP-SDK runtime resolve — the ACP dep bump's
136
- // real fault surface. Lock text (layer 2) freezes the publish floor; these probe
137
- // the LIVE module graph the adapter actually traverses: the adapter must SEE
138
- // the same wire SDK the backend imports at root (1.3.0), and claude-agent-sdk
139
- // must SEE its declared MCP peer (1.29.x). Cheap edges, both newly relevant
140
- // after the 0.54→0.61 / 1.1→1.3 bump.
141
- // ---------------------------------------------------------------------------
142
- const wireEntry = adapterRequire.resolve("@agentclientprotocol/sdk");
143
- const wireInfo = pkgInfoFromEntry(wireEntry);
144
- assert.equal(
145
- wireInfo.version,
146
- "1.3.0",
147
- `@agentclientprotocol/sdk must runtime-resolve to 1.3.0 from the adapter context (got ${wireInfo.version}) — the adapter and the backend must share one wire SDK`,
148
- );
149
- // @modelcontextprotocol/sdk gates its bare specifier behind "exports", so a
150
- // require.resolve of the package ROOT throws (no resolvable entry) — resolve a
151
- // real SUBPATH instead and walk up to its package.json.
152
- //
153
- // This used to read the repo's own hoisted copy and justify it with "pnpm
154
- // hoists ONE mcp instance". That is true of today's lockfile, which is exactly
155
- // why it asserted nothing: the read never traversed the edge it claimed to
156
- // verify, so a nested @modelcontextprotocol/sdk under claude-agent-sdk would
157
- // leave this gate GREEN while the adapter loaded a peer no gate had seen. A
158
- // probe whose subject is "whatever the root happens to hoist" is a coincidence,
159
- // not a check. Resolve FROM the claude-agent-sdk context so the assertion binds
160
- // the real adapter → claude-agent-sdk → MCP edge, the same way the anthropic
161
- // SDK peer above is bound.
162
- const mcpEntry = casRequire.resolve("@modelcontextprotocol/sdk/server/index.js");
163
- const mcpInfo = pkgInfoFromEntry(mcpEntry);
164
- assert.ok(
165
- mcpInfo.version.startsWith("1.29."),
166
- `@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.219 declares a ^1.29.0 peer`,
167
- );
168
-
169
- // ---------------------------------------------------------------------------
170
- // (3) @agentclientprotocol/sdk value-export surface (silent-rename gate)
171
- // The ACP bridge imports these from the wire SDK; a silent upstream
172
- // rename would not fail typecheck (type-only erasure) but would break the
173
- // raw turn. Assert the *value* exports exist at runtime.
174
- // ---------------------------------------------------------------------------
175
- // These are the value imports the real ACP code uses: the connectAcpClient
176
- // adapter (acp-client.ts) drives `client` + the `AGENT_METHODS`/`CLIENT_METHODS`
177
- // method tables; the backend + raw-turn smoke value-import `ndJsonStream` (the
178
- // stdio transport) and `PROTOCOL_VERSION`. The deprecated `ClientSideConnection`
179
- // is no longer used, so it is no longer gated. (Per GPT: gate only the value
180
- // imports the real code uses — type-only imports are erased.)
181
- const acpSdk = (await import("@agentclientprotocol/sdk")) as Record<string, unknown>;
182
- for (const sym of ["client", "ndJsonStream", "PROTOCOL_VERSION", "AGENT_METHODS", "CLIENT_METHODS"]) {
183
- assert.ok(
184
- sym in acpSdk,
185
- `@agentclientprotocol/sdk lost value export "${sym}" — silent upstream rename; the raw ACP turn would break`,
186
- );
187
- }
188
-
189
- // ---------------------------------------------------------------------------
190
- // (4) no source-level anthropic SDK import / API client
191
- // peer-pin only. The credential boundary (AGENTS §Operating boundaries)
192
- // forbids the bridge from importing the SDK or instantiating an API client.
193
- // Line-based: only real `import ... from` / `require(...)` statements count,
194
- // so this gate's own split-literal regexes above are never offenders.
195
- // ---------------------------------------------------------------------------
196
- const tracked = execFileSync("git", ["ls-files", "*.ts", "*.js", "*.mjs", "*.cjs"], {
197
- cwd: repoRoot,
198
- encoding: "utf8",
199
- })
200
- .split("\n")
201
- .filter(Boolean)
202
- // `git ls-files` still names an UNSTAGED deletion (same contract as
203
- // check-install-surface / the run.sh floor sweeps): a release-surface
204
- // migration deletes tracked files before the commit workflow stages them;
205
- // absence cannot import the SDK and must not crash this read-only sweep.
206
- .filter((f) => existsSync(resolve(repoRoot, f)));
207
-
208
- // Specifier-shaped: any module binding to the anthropic SDK, in any of the
209
- // forms a source file could reach it — static `from`, `export ... from`,
210
- // side-effect `import "X"`, dynamic `import("X")`, and `require("X")`.
211
- const spec = `["']${ANTHROPIC_SDK.replace("/", "\\/")}["']`;
212
- const specifierPatterns: ReadonlyArray<{ re: RegExp; kind: string }> = [
213
- { re: new RegExp(String.raw`\bfrom\s+${spec}`), kind: `import/export from ${ANTHROPIC_SDK}` },
214
- { re: new RegExp(String.raw`^\s*import\s+${spec}`), kind: `side-effect import ${ANTHROPIC_SDK}` },
215
- { re: new RegExp(String.raw`\bimport\(\s*${spec}\s*\)`), kind: `dynamic import(${ANTHROPIC_SDK})` },
216
- { re: new RegExp(String.raw`\brequire\(\s*${spec}\s*\)`), kind: `require(${ANTHROPIC_SDK})` },
217
- ];
218
- const clientRe = new RegExp(String.raw`\bnew\s+${API_CLIENT_CLASS}\s*\(`);
219
-
220
- const offenders: string[] = [];
221
- for (const f of tracked) {
222
- const src = read(f);
223
- for (const line of src.split("\n")) {
224
- for (const { re, kind } of specifierPatterns) {
225
- if (re.test(line)) offenders.push(`${f}: ${kind}`);
226
- }
227
- if (clientRe.test(line)) offenders.push(`${f}: new ${API_CLIENT_CLASS}() API client`);
228
- }
229
- }
230
- assert.equal(
231
- offenders.length,
232
- 0,
233
- `${ANTHROPIC_SDK} is a peer-resolution pin ONLY — no source import / API client allowed:\n ${offenders.join("\n ")}`,
234
- );
235
-
236
- console.log("[check-acp-sdk-surface] ACP dep pins + peer-resolution lock + wire-SDK export surface + no-client-use ok");