@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,337 @@
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 * as fs from "node:fs/promises";
32
+ import {
33
+ findSessionFileById,
34
+ getEntwurfExplicitExtensions,
35
+ mirrorChildStderr,
36
+ readSessionIdentity,
37
+ } from "./entwurf-core.ts";
38
+ import { buildResumePiArgs } from "./entwurf-resume-args.ts";
39
+ import type { LockClaim } from "./entwurf-v2-lock.ts";
40
+ import { releaseLock } from "./entwurf-v2-lock.ts";
41
+ import { V2_RESUME_RESIDENT_SESSION_ENV } from "./entwurf-v2-resume-marker.ts";
42
+ import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "./entwurf-v2-spawn.ts";
43
+ import { inspectControlSocketPath, type LstatLike, mapInspectionToLiveness } from "./socket-discovery.ts";
44
+ import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
45
+
46
+ // ── socketWatchVerdict (pure — the R2 watch policy, gate-pinned) ──────────────
47
+ export type SocketWatchVerdict = "alive" | "wait" | "forged";
48
+
49
+ /**
50
+ * Translate a mapped socket observation into the watcher's poll decision.
51
+ * - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
52
+ * state: time cannot turn a forged path into a live socket, so the poll must REJECT
53
+ * immediately (→ watcher backstop → kill → retained), never keep waiting.
54
+ * - alive — a connectable socket. Resolve: the resumed citizen is up.
55
+ * - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
56
+ * indeterminate (a stall). Keep polling until the socket connects, or the observe
57
+ * timeout / abort ends the wait.
58
+ */
59
+ export function socketWatchVerdict(
60
+ mapped: { liveness: SocketLiveness; socketPath: string } | { addressConflict: true },
61
+ ): SocketWatchVerdict {
62
+ if ("addressConflict" in mapped) return "forged";
63
+ return mapped.liveness === "alive" ? "alive" : "wait";
64
+ }
65
+
66
+ // ── the spawned child handle (D4) ────────────────────────────────────────────
67
+ // A minimal structural view of the process the deps share. The real ChildProcess
68
+ // satisfies it; the gate fakes it. The watcher sees only `pid` (SpawnedChild); the
69
+ // production deps carry `proc` so awaitChildExit / killChild act on the SAME process.
70
+ export interface SpawnedProcHandle {
71
+ pid?: number;
72
+ kill(signal?: NodeJS.Signals | number): boolean;
73
+ on(event: string, listener: (...args: unknown[]) => void): unknown;
74
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
75
+ }
76
+
77
+ interface ProductionSpawnedChild extends SpawnedChild {
78
+ proc: SpawnedProcHandle;
79
+ /** Exit captured EAGERLY at proc creation (B2): the `exit` listener is installed the
80
+ * instant the proc exists, BEFORE the watcher's awaitChildExit runs, so a child that
81
+ * exits in the micro-gap between spawnChild resolving and awaitChildExit registering is
82
+ * never missed (which would pin the lock through timeout→kill→retained). Resolve-ONLY
83
+ * (never rejects) so it is safe to leave racing an abort in awaitChildExit. */
84
+ exitPromise: Promise<number | null>;
85
+ }
86
+
87
+ /** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
88
+ * NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
89
+ function requireProductionChild(child: SpawnedChild): ProductionSpawnedChild {
90
+ const c = child as Partial<ProductionSpawnedChild>;
91
+ if (!c.proc || !c.exitPromise) {
92
+ throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
93
+ }
94
+ return c as ProductionSpawnedChild;
95
+ }
96
+
97
+ // ── launch identity (the spawnChild preamble, one injectable seam) ───────────
98
+ /** The launch-time facts buildResumePiArgs needs, resolved from the saved session. The
99
+ * default reads them the SAME way the legacy resume launcher does (header authority); the
100
+ * gate injects a fake so spawnChild's argv is provable without a real session file. */
101
+ export interface LaunchIdentity {
102
+ cwd: string;
103
+ explicitExtensionArgs: readonly string[];
104
+ provider: string | null | undefined;
105
+ model: string;
106
+ }
107
+
108
+ /**
109
+ * Resolve launch identity for a resume from the saved session — the same authority the
110
+ * legacy launcher uses (readSessionIdentity = first model_change; getEntwurfExplicitExtensions
111
+ * = bridge re-injection / #29 fail-fast), MINUS the legacy completion-delivery tail (that is
112
+ * 5d). Throws on anything that makes a resume impossible — no session file, no recorded
113
+ * model, an unresolvable ACP bridge, or no header cwd (NEVER falls back to process.cwd, #9).
114
+ * Each throw becomes the watcher's `spawn-start-failed` (no child to watch → release).
115
+ */
116
+ export function resolveResumeLaunchIdentity(plan: SpawnBgPlan): LaunchIdentity {
117
+ const sessionFile = findSessionFileById(plan.sessionId);
118
+ if (!sessionFile) {
119
+ throw new Error(`entwurf-v2-spawn-production: no saved session for ${plan.sessionId} — cannot resume.`);
120
+ }
121
+ const identity = readSessionIdentity(sessionFile, { requireEntwurf: true });
122
+ const resumeModel = identity?.modelId ?? null;
123
+ if (!identity || !resumeModel) {
124
+ throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
125
+ }
126
+ const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
127
+ if (explicitExtensions.unresolvedAcpIntent) {
128
+ throw new Error(
129
+ `entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
130
+ `extension could not be resolved — refusing to resume with an unknown provider (#29).`,
131
+ );
132
+ }
133
+ if (!identity.cwd) {
134
+ throw new Error(
135
+ `entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`,
136
+ );
137
+ }
138
+ return {
139
+ cwd: identity.cwd,
140
+ explicitExtensionArgs: explicitExtensions.args,
141
+ provider: explicitExtensions.provider ?? identity.provider,
142
+ model: explicitExtensions.modelOverride ?? resumeModel,
143
+ };
144
+ }
145
+
146
+ // ── the injectable seams (defaults = the real world) ─────────────────────────
147
+ export interface ProductionSpawnOpts {
148
+ /** Poll interval for awaitSocketAlive's wait loop (ms). */
149
+ pollIntervalMs?: number;
150
+ /** Bounded wait, after a kill, for the resulting child-exited (the watcher's killGraceMs). */
151
+ killGraceMs?: number;
152
+ /** Connect-probe timeout for a socket-file inspection (ms). */
153
+ probeTimeoutMs?: number;
154
+ /** Resolve launch identity (default = resolveResumeLaunchIdentity). */
155
+ resolveIdentity?: (plan: SpawnBgPlan) => LaunchIdentity;
156
+ /** Spawn the resume child and return its handle (default = real `pi` spawn + unref +
157
+ * mirrorChildStderr). `env` carries the v2 spawn-bg resume marker (V2_RESUME_RESIDENT_SESSION_ENV)
158
+ * the factory plants so the resumed `--entwurf-control` resident is an AUTHORIZED Entwurf child,
159
+ * not a corrupt operator resident. A throw becomes the watcher's spawn-start-failed. */
160
+ spawnChild?: (cmd: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv) => SpawnedProcHandle;
161
+ /** lstat for socket inspection (default = fs.lstat). */
162
+ lstatFn?: (p: string) => Promise<LstatLike>;
163
+ /** Connect probe for a socket-file (default = probeSocketLiveness). */
164
+ probeFn?: (socketPath: string) => Promise<SocketLiveness>;
165
+ /** Release the held lock (default = the lock primitive's releaseLock). */
166
+ releaseFn?: (lock: LockClaim) => void;
167
+ /** Timer primitives (default = global setTimeout/clearTimeout) — injected so the gate
168
+ * proves scheduling + abort-clear deterministically. */
169
+ setTimeoutFn?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
170
+ clearTimeoutFn?: (t: ReturnType<typeof setTimeout>) => void;
171
+ }
172
+
173
+ const DEFAULT_POLL_INTERVAL_MS = 150;
174
+ const DEFAULT_KILL_GRACE_MS = 5_000;
175
+
176
+ /** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
177
+ * the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
178
+ * from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
179
+ function defaultSpawnChild(
180
+ cmd: string,
181
+ args: readonly string[],
182
+ cwd: string,
183
+ env: NodeJS.ProcessEnv,
184
+ ): SpawnedProcHandle {
185
+ const proc: ChildProcess = spawn(cmd, [...args], {
186
+ cwd,
187
+ shell: false,
188
+ detached: true,
189
+ stdio: ["ignore", "ignore", "pipe"],
190
+ env,
191
+ });
192
+ proc.unref();
193
+ mirrorChildStderr(proc);
194
+ return proc;
195
+ }
196
+
197
+ /**
198
+ * Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
199
+ * NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
200
+ * the deterministic gate; the defaults are the real IO.
201
+ */
202
+ export function makeProductionSpawnBgResumeDeps(opts: ProductionSpawnOpts = {}): SpawnBgResumeDeps {
203
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
204
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
205
+ const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
206
+ const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
207
+ const lstatFn = opts.lstatFn ?? ((p: string) => fs.lstat(p));
208
+ const probeFn =
209
+ opts.probeFn ?? ((socketPath: string) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
210
+ const releaseFn = opts.releaseFn ?? ((lock: LockClaim) => void releaseLock(lock));
211
+ const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
212
+ const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
213
+
214
+ // An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
215
+ // by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
216
+ const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
217
+ new Promise<void>((resolve, reject) => {
218
+ if (signal.aborted) {
219
+ reject(new Error("aborted"));
220
+ return;
221
+ }
222
+ const timer = setTimeoutFn(() => {
223
+ signal.removeEventListener("abort", onAbort);
224
+ resolve();
225
+ }, ms);
226
+ const onAbort = (): void => {
227
+ clearTimeoutFn(timer);
228
+ reject(new Error("aborted"));
229
+ };
230
+ signal.addEventListener("abort", onAbort, { once: true });
231
+ });
232
+
233
+ return {
234
+ killGraceMs,
235
+
236
+ spawnChild: async (plan: SpawnBgPlan): Promise<SpawnedChild> => {
237
+ const identity = resolveIdentity(plan);
238
+ const args = buildResumePiArgs({
239
+ variant: "v2-control",
240
+ sessionId: plan.sessionId,
241
+ explicitExtensionArgs: identity.explicitExtensionArgs,
242
+ provider: identity.provider,
243
+ model: identity.model,
244
+ prompt: plan.prompt,
245
+ launchArgs: plan.launchArgs,
246
+ });
247
+ // Plant the sessionId-bound authorization marker (QB-resident): this resume promotes a
248
+ // dormant `entwurf`-tagged session to a live `--entwurf-control` resident, which the
249
+ // entwurf-control guard would otherwise crash as a "corrupt resident session name". The
250
+ // marker authorizes ONLY this exact session — a human hand-opening the same session with
251
+ // `--entwurf-control` carries no marker and still crashes (the invariant is narrowed, not
252
+ // dropped). buildResumePiArgs's argv alone can't say "this is a v2 spawn-bg resume".
253
+ const childEnv: NodeJS.ProcessEnv = { ...process.env, [V2_RESUME_RESIDENT_SESSION_ENV]: plan.sessionId };
254
+ const proc = spawnChildFn("pi", args, identity.cwd, childEnv);
255
+
256
+ // B2: capture exit EAGERLY — the instant the proc exists, before we even await the
257
+ // spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
258
+ // only (a signal kill reports null); it never rejects, so racing it against abort is safe.
259
+ const exitPromise = new Promise<number | null>((resolve) => {
260
+ proc.on("exit", (code: unknown) => resolve(typeof code === "number" ? code : null));
261
+ });
262
+
263
+ // B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
264
+ // failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
265
+ // for the `spawn` event to confirm a real start; an `error` before it rejects, which the
266
+ // watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
267
+ // spawn-started that later stalls into a wrongful retained lock.
268
+ await new Promise<void>((resolve, reject) => {
269
+ const onSpawn = (): void => {
270
+ cleanup();
271
+ resolve();
272
+ };
273
+ const onError = (err: unknown): void => {
274
+ cleanup();
275
+ reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
276
+ };
277
+ const cleanup = (): void => {
278
+ proc.removeListener("spawn", onSpawn);
279
+ proc.removeListener("error", onError);
280
+ };
281
+ proc.on("spawn", onSpawn);
282
+ proc.on("error", onError);
283
+ });
284
+
285
+ const child: ProductionSpawnedChild = { pid: proc.pid, proc, exitPromise };
286
+ return child;
287
+ },
288
+
289
+ awaitSocketAlive: async (socketPath: string, signal: AbortSignal): Promise<void> => {
290
+ // Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
291
+ // reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
292
+ while (!signal.aborted) {
293
+ const inspection = await inspectControlSocketPath(socketPath, lstatFn);
294
+ const mapped = await mapInspectionToLiveness(inspection, probeFn);
295
+ const verdict = socketWatchVerdict(mapped);
296
+ if (verdict === "alive") return;
297
+ if (verdict === "forged") {
298
+ throw new Error(
299
+ `entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`,
300
+ );
301
+ }
302
+ // wait — sleep one interval, honoring abort (rejects → loop exits below).
303
+ await abortableDelay(pollIntervalMs, signal);
304
+ }
305
+ throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
306
+ },
307
+
308
+ awaitChildExit: (child: SpawnedChild, signal: AbortSignal): Promise<number | null> =>
309
+ // Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
310
+ // B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
311
+ // unhandled rejection; only the abort branch rejects.
312
+ new Promise<number | null>((resolve, reject) => {
313
+ const { exitPromise } = requireProductionChild(child);
314
+ if (signal.aborted) {
315
+ reject(new Error("aborted"));
316
+ return;
317
+ }
318
+ const onAbort = (): void => reject(new Error("aborted"));
319
+ signal.addEventListener("abort", onAbort, { once: true });
320
+ exitPromise.then((code) => {
321
+ signal.removeEventListener("abort", onAbort);
322
+ resolve(code);
323
+ });
324
+ }),
325
+
326
+ awaitTimeout: (ms: number, signal: AbortSignal): Promise<void> => abortableDelay(ms, signal),
327
+
328
+ killChild: (child: SpawnedChild): void => {
329
+ // SIGTERM only this slice — the watcher's killGrace then waits for the resulting
330
+ // child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
331
+ // separate policy + smoke). Best-effort: the watcher catches a throw here.
332
+ requireProductionChild(child).proc.kill("SIGTERM");
333
+ },
334
+
335
+ releaseLock: (lock: LockClaim): void => releaseFn(lock),
336
+ };
337
+ }
@@ -0,0 +1,323 @@
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
+ }