@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,196 @@
1
+ /**
2
+ * entwurf-v2-runner — the pure EXECUTE-router (5d-1, `executeDispatch`) AND the top-level
3
+ * decide→execute join (5d-2a, `runEntwurfV2`) for the unified `entwurf_v2` verb, both in
4
+ * this one file because they share the `EntwurfV2RunResult` vocabulary and neither does IO.
5
+ * `executeDispatch` takes a `DispatchDecision` the 5b decider ALREADY produced and routes it
6
+ * to the right 5c transport hand, returning ONE outcome-rich `EntwurfV2RunResult`. It performs
7
+ * ZERO IO of its own and makes ZERO routing decisions — `decideDispatch` chose the plan,
8
+ * this only DISPATCHES it. Each hand is an injected dep (the gate fakes them; 5d-2 wires
9
+ * the production `executeControlSocketSend` / `executeSpawnBgResume` / production
10
+ * `sendViaMailbox`), so the routing + result mapping is gate-provable without a socket,
11
+ * spawn, or timer — the same pure-before-IO, IO-via-dep discipline 5b/5c kept.
12
+ *
13
+ * Why a result type richer than the receipt: the carry-over contracts from 5c demand it.
14
+ * - N3 (5c-2b): a dead-path re-resolve `rejected` carries the resolver's `rejectReason`
15
+ * (dormant-fire-forget-unsupported / mailbox-undeliverable / …). The runner carries it
16
+ * verbatim so the surface distinguishes "in-band refusal" from "no live route".
17
+ * - N1 (5c-2a): a non-`failed` outcome whose `releaseLock` then threw is a
18
+ * `SendDeliveredReleaseFailedError` — the delivery HAPPENED, the lock is dirty, a
19
+ * re-send would double-deliver. The runner surfaces this as `execution-failed` with
20
+ * `finalizedOutcome` + `releaseFailed` so the surface renders "delivered, lock dirty,
21
+ * do NOT retry", never "send failed".
22
+ * - spawn-bg `lock-retained` is a RETURNED result (fail-closed, not a throw): it rides
23
+ * the `executed` branch so 5d's surface can render the retained-lock diagnostic.
24
+ *
25
+ * `retrySafe` is conservatively `false` on EVERY `execution-failed`: a thrown send is
26
+ * never confidently retry-safe (an `indeterminate` connect may have delivered to an
27
+ * alive-but-stalled socket — the exact double-delivery hazard 5c-2a refuses to gamble on).
28
+ * The runner never re-judges the lock — it passes `decision.lock` to the hand verbatim and
29
+ * lets the hand's `decideReleasePolicy` fail loud on a mis-pairing.
30
+ */
31
+
32
+ import type {
33
+ DispatchDecision,
34
+ DispatchInput,
35
+ ExecutionPlan,
36
+ RejectDiagnostic,
37
+ RejectReceipt,
38
+ SuccessReceipt,
39
+ } from "./entwurf-v2-decider.ts";
40
+ import type { LockClaim } from "./entwurf-v2-lock.ts";
41
+ import {
42
+ type ControlSocketPlan,
43
+ type ControlSocketSendResult,
44
+ type MetaMailboxPlan,
45
+ type RpcSendResult,
46
+ SendDeliveredReleaseFailedError,
47
+ type SendFinalOutcome,
48
+ } from "./entwurf-v2-send.ts";
49
+ import type { SpawnBgPlan, SpawnBgResumeResult } from "./entwurf-v2-spawn.ts";
50
+
51
+ /**
52
+ * The three transport hands, each PRE-BOUND with its own deps (production or fake). Lock
53
+ * is typed `LockClaim | null` to mirror the real hands EXACTLY — the runner passes
54
+ * `decision.lock` straight through and the hand fails loud on a null/mis-paired lock
55
+ * (control-socket / spawn-bg get a non-null claim; meta-mailbox gets null — ?7).
56
+ */
57
+ export interface DispatchExecutorDeps {
58
+ sendControl: (plan: ControlSocketPlan, lock: LockClaim | null) => Promise<ControlSocketSendResult>;
59
+ resumeSpawnBg: (plan: SpawnBgPlan, lock: LockClaim | null) => Promise<SpawnBgResumeResult>;
60
+ sendMailbox: (plan: MetaMailboxPlan, lock: LockClaim | null) => Promise<RpcSendResult>;
61
+ }
62
+
63
+ /** The per-transport success outcome, discriminated by transport so the surface renders
64
+ * each without guessing. `control-socket` carries the optional N3 `rejectReason`;
65
+ * `spawn-bg` carries the whole `SpawnBgResumeResult` (incl. the `lock-retained`
66
+ * fail-closed diagnostic); `meta-mailbox` is always `success:true` (enqueue has no
67
+ * in-band refuse — a failure is a throw, handled as `execution-failed`). */
68
+ export type ExecutedOutcome =
69
+ | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string }
70
+ | { transport: "spawn-bg"; result: SpawnBgResumeResult }
71
+ | { transport: "meta-mailbox"; success: true };
72
+
73
+ /** The single outcome-rich result the 5d surface renders. `rejected` = the decider
74
+ * refused (no execution). `executed` = a hand ran to a terminal result. `execution-failed`
75
+ * = a hand THREW (the lock-leak backstop / a transport failure / the N1 delivered+release
76
+ * failed error) — `finalizedOutcome`+`releaseFailed` mark the N1 "delivered, do not retry"
77
+ * case; `retrySafe` is always false (see module header). */
78
+ export type EntwurfV2RunResult =
79
+ | { kind: "rejected"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
80
+ | { kind: "executed"; receipt: SuccessReceipt; transport: ExecutionPlan["transport"]; outcome: ExecutedOutcome }
81
+ | {
82
+ kind: "execution-failed";
83
+ receipt: SuccessReceipt;
84
+ transport: ExecutionPlan["transport"];
85
+ error: string;
86
+ /** Present ONLY for the N1 case: the delivery/refusal reached a terminal outcome
87
+ * but `releaseLock` then threw (lock dirty). A re-send would double-deliver. */
88
+ finalizedOutcome?: Exclude<SendFinalOutcome, "failed">;
89
+ releaseFailed?: true;
90
+ retrySafe: false;
91
+ };
92
+
93
+ function errorMessage(err: unknown): string {
94
+ return err instanceof Error ? err.message : String(err);
95
+ }
96
+
97
+ /**
98
+ * Route an already-decided `DispatchDecision` to its transport hand. The decider owns the
99
+ * routing; this owns ONLY the dispatch + the outcome→result mapping (incl. the N1/N3
100
+ * carry-overs). A `reject` runs no hand. An `execute` switches on `plan.transport`.
101
+ */
102
+ export async function executeDispatch(
103
+ decision: DispatchDecision,
104
+ deps: DispatchExecutorDeps,
105
+ ): Promise<EntwurfV2RunResult> {
106
+ if (decision.kind === "reject") {
107
+ return { kind: "rejected", receipt: decision.receipt, diagnostic: decision.diagnostic };
108
+ }
109
+
110
+ const { receipt, plan, lock } = decision;
111
+ const transport = plan.transport;
112
+
113
+ switch (plan.transport) {
114
+ case "control-socket": {
115
+ try {
116
+ const r = await deps.sendControl(plan, lock);
117
+ return {
118
+ kind: "executed",
119
+ receipt,
120
+ transport,
121
+ outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
122
+ };
123
+ } catch (err) {
124
+ // N1: a delivered/refused send whose release then threw — lock dirty, do NOT retry.
125
+ if (err instanceof SendDeliveredReleaseFailedError) {
126
+ return {
127
+ kind: "execution-failed",
128
+ receipt,
129
+ transport,
130
+ error: errorMessage(err),
131
+ finalizedOutcome: err.finalizedOutcome,
132
+ releaseFailed: true,
133
+ retrySafe: false,
134
+ };
135
+ }
136
+ // A `failed` send rethrows its original transport error (lock already released).
137
+ return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
138
+ }
139
+ }
140
+ case "spawn-bg": {
141
+ try {
142
+ // `lock-retained` is a RETURNED result (fail-closed), not a throw — it rides `executed`.
143
+ const result = await deps.resumeSpawnBg(plan, lock);
144
+ return { kind: "executed", receipt, transport, outcome: { transport: "spawn-bg", result } };
145
+ } catch (err) {
146
+ return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
147
+ }
148
+ }
149
+ case "meta-mailbox": {
150
+ try {
151
+ // lock is null here (?7) — passed verbatim; the production adapter ignores it.
152
+ const r = await deps.sendMailbox(plan, lock);
153
+ // 5c-4 contract: a mailbox enqueue is `{success:true}` OR a throw — there is NO
154
+ // in-band reject (no live receiver to refuse). A `success:false` is therefore a
155
+ // CONTRACT VIOLATION, not a soft failure: fail loud rather than silently render it
156
+ // as a success ("Never warn. Throw."). The catch below folds it to execution-failed.
157
+ if (r.success !== true) {
158
+ throw new Error(
159
+ "entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).",
160
+ );
161
+ }
162
+ return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
163
+ } catch (err) {
164
+ return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 5d-2a: the deps `runEntwurfV2` joins. `decide` is the WHOLE decider as ONE injected
172
+ * function — NOT `DispatchDeciderDeps`. The runner does not re-validate the 5b decider
173
+ * logic (that is `check-entwurf-v2-decider`'s job); it proves only the decide→execute
174
+ * COMPOSITION contract over a fake `decide`. Production wraps the real decider as
175
+ * `decide: (input) => decideDispatch(input, productionDeciderDeps)` (assembled in 5d-2b),
176
+ * so the runner stays gate-provable without any decider IO seam leaking in.
177
+ */
178
+ export interface EntwurfV2RunDeps {
179
+ decide: (input: DispatchInput) => DispatchDecision | Promise<DispatchDecision>;
180
+ executor: DispatchExecutorDeps;
181
+ }
182
+
183
+ /**
184
+ * The top-level `entwurf_v2` runner: decide → execute, joined. It runs the injected
185
+ * decider to a `DispatchDecision`, then routes that decision through `executeDispatch`.
186
+ * That is the WHOLE body — there is no extra branching, lock re-judgement, or transport
187
+ * decision here (the decider owns routing, `executeDispatch` owns dispatch + the N1/N3
188
+ * result mapping). A `decide` THROW PROPAGATES untouched: a decision was never produced,
189
+ * so there is no receipt to wrap and no `EntwurfV2RunResult` to honestly return — the 5d-3
190
+ * surface top-level catch renders it. A reject/execute decision flows straight into
191
+ * `executeDispatch`, whose `EntwurfV2RunResult` is returned verbatim.
192
+ */
193
+ export async function runEntwurfV2(input: DispatchInput, deps: EntwurfV2RunDeps): Promise<EntwurfV2RunResult> {
194
+ const decision = await deps.decide(input);
195
+ return executeDispatch(decision, deps.executor);
196
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * entwurf-v2-send-fallback — the 5c-2b same-lock re-resolve RESOLVER (the brain the
3
+ * 5c-2a send hand calls as `deps.deadFallback` on a `dead` connect). The hand DECIDED
4
+ * nothing about routing; this resolver re-runs the 5b dispatch logic ONCE — minus the
5
+ * lock lifecycle — under the lock the hand still holds, and hands back a fresh plan to
6
+ * EXECUTE or a reject. It NEVER releases the lock (that is the hand's single
7
+ * responsibility) and NEVER calls decideDispatch whole (that would re-enter the
8
+ * acquire/release lifecycle the hand already owns).
9
+ *
10
+ * Why a re-resolve and not just "dead ⇒ reject": the original control-socket plan came
11
+ * from the 5b decider observing the target ALIVE; the send's `dead` connect is a TOCTOU
12
+ * fact about ONE socketPath at send time, not the gid's latest dispatch state. So we
13
+ * re-inspect + re-probe the canonical path under the held lock and let the SAME frozen
14
+ * table decide again.
15
+ *
16
+ * The intent is pinned to `fire-and-forget` (design lock, GPT 5c-2b): a control-socket
17
+ * send plan is ONLY ever produced by the `fire-and-forget + live` cell, so the re-resolve
18
+ * must read the same row. That pin is also what makes "no resume promotion" structural,
19
+ * not just a 5c-2a guard: the `fire-and-forget` row has NO resume cell —
20
+ * live → send / control-socket (the retry)
21
+ * dormant(dead) → reject (dormant-fire-forget-unsupported) ← NOT resume/spawn-bg
22
+ * indeterminate → reject (indeterminate-no-spawn)
23
+ * Only `owned-outcome + dormant` is resume/spawn-bg, and an owned-outcome dispatch never
24
+ * yields a control-socket send plan in the first place. So a dead re-resolve can only
25
+ * retry (alive), reject (dead/indeterminate pi), or — on an UNSUPPORTED backend, via the
26
+ * separate mailbox mini-table — enqueue to a deliverable citizen. It can never spawn.
27
+ *
28
+ * The N2 asymmetry (frozen in entwurf-v2-contract): in-domain `dormant` is a CONFIRMED
29
+ * not-running pi → reject (enqueuing would be a silent pileup; resume is the honest
30
+ * place, and resume is out of a SEND fallback's scope). `unsupported` is UNKNOWN liveness
31
+ * with no socket axis → the mailbox is its honest channel when deliverable.
32
+ */
33
+
34
+ import type { MailboxDeliverabilityResult } from "./entwurf-deliverability.ts";
35
+ import { isLivenessSupported, resolveDispatch } from "./entwurf-v2-contract.ts";
36
+ import type { TargetResolution } from "./entwurf-v2-decider.ts";
37
+ import type { LockClaim } from "./entwurf-v2-lock.ts";
38
+ import type { ControlSocketPlan, DeadFallbackResolution } from "./entwurf-v2-send.ts";
39
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, type MetaIdentity } from "./meta-session.ts";
40
+ import { mapInspectionToLiveness, type TargetSocketInspection } from "./socket-discovery.ts";
41
+ import type { SocketLiveness } from "./socket-probe.ts";
42
+
43
+ /**
44
+ * The resolver's IO seams — the SAME shapes the 5b decider uses, MINUS acquireLock /
45
+ * releaseLock (the resolver runs under a lock it must not touch). Plan-planted dirs keep
46
+ * defaults; mailboxDeliverabilityFor is REQUIRED (SE-2 2d-3 — the same seam the decider
47
+ * takes, no wake-mode-only fallback), as are the IO seams (resolveTarget / inspectSocket /
48
+ * probeSocket), so the gate drives every branch without a filesystem.
49
+ */
50
+ export interface DeadFallbackDeps {
51
+ resolveTarget: (gardenId: string) => TargetResolution | Promise<TargetResolution>;
52
+ inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
53
+ probeSocket: (socketPath: string) => Promise<SocketLiveness>;
54
+ /** SE-2 slice 2d-3: the SAME required mailbox-deliverability seam the 5b decider takes
55
+ * (wake-mode capability AND a live active-receiver marker matching the identity). Required
56
+ * (no default) so the dead-control re-resolve can never fall back to wake-mode-only and
57
+ * re-open the SE-2 gap on its own path. Production injects the SAME closure into both. */
58
+ mailboxDeliverabilityFor: (
59
+ identity: MetaIdentity,
60
+ ) => MailboxDeliverabilityResult | Promise<MailboxDeliverabilityResult>;
61
+ mailboxDir?: string;
62
+ sessionsDir?: string;
63
+ }
64
+
65
+ /**
66
+ * Re-resolve a dead control-socket send ONCE under the held lock. Returns a plan to
67
+ * execute (control-socket retry on a re-observed-alive target, or a meta-mailbox enqueue
68
+ * on a deliverable unsupported citizen) or a reject (dead/indeterminate pi, undeliverable
69
+ * citizen, bad target, address conflict). The returned plan ALWAYS targets the same gid
70
+ * the lock is held for. Inspect/probe throws are left to PROPAGATE — the 5c-2a hand's
71
+ * lock-leak backstop converts them to failed+release; this resolver never releases.
72
+ */
73
+ export async function resolveDeadControlSendFallback(
74
+ plan: ControlSocketPlan,
75
+ lock: LockClaim,
76
+ deps: DeadFallbackDeps,
77
+ ): Promise<DeadFallbackResolution> {
78
+ // Mis-wire fail-loud, same grade as the 5c-2a hand: a plan/lock for different gids
79
+ // would re-resolve B while holding A's lock. (The hand also asserts this on the
80
+ // returned plan; asserting on the INPUT here fails even earlier, before any IO.)
81
+ if (plan.targetGardenId !== lock.gardenId) {
82
+ throw new Error(
83
+ `entwurf-v2-send-fallback: plan target (${plan.targetGardenId}) does not match held lock (${lock.gardenId}) — mis-paired.`,
84
+ );
85
+ }
86
+ const gardenId = plan.targetGardenId;
87
+ const mailboxDir = deps.mailboxDir ?? defaultMetaMailboxDir();
88
+ const sessionsDir = deps.sessionsDir ?? defaultMetaSessionsDir();
89
+
90
+ // Probe-free target resolution first — a vanished target or a quarantined address
91
+ // short-circuits before any inspect/probe (mirrors decideDispatch steps 2). A1 narrow:
92
+ // a record-LESS but live pi control socket (socketOnlyPi, identity null) is NOT a
93
+ // bad-target — it is an in-domain pi endpoint that re-resolves through the inspect/probe
94
+ // path below (fire-and-forget: alive → retry control-send, dead → honest reject). Only a
95
+ // genuinely absent target (identity null AND not socket-only) is bad-target.
96
+ const resolution = await deps.resolveTarget(gardenId);
97
+ if (resolution.identity === null && resolution.socketOnlyPi !== true) {
98
+ return { kind: "reject", reason: "bad-target" };
99
+ }
100
+ if (resolution.preProbeAddressConflict) {
101
+ return { kind: "reject", reason: "target-address-conflict" };
102
+ }
103
+ const identity = resolution.identity;
104
+
105
+ // Unsupported backend (claude-code self-fetch, …) → the mailbox mini-table, keyed on
106
+ // intent alone. This is NOT an in-domain dormant (the N2 asymmetry) — a deliverable
107
+ // citizen's honest channel is its mailbox. No inspect/probe on this axis. A socket-only
108
+ // pi endpoint (identity null) is in-domain pi, so it NEVER takes this branch — only a
109
+ // record-backed unsupported identity does.
110
+ if (identity !== null && !isLivenessSupported(identity.backend)) {
111
+ const deliverability = await deps.mailboxDeliverabilityFor(identity);
112
+ const receipt = resolveDispatch("fire-and-forget", "unsupported", deliverability.deliverable);
113
+ if (!receipt.ok) {
114
+ return { kind: "reject", reason: receipt.reason };
115
+ }
116
+ // The only allow cell here is fire-and-forget → meta-mailbox send. Preserve the
117
+ // original message/wantsReply; mode is meaningless for the mailbox (not carried).
118
+ return {
119
+ kind: "execute",
120
+ plan: {
121
+ transport: "meta-mailbox",
122
+ action: "send",
123
+ targetGardenId: gardenId,
124
+ mailboxDir,
125
+ sessionsDir,
126
+ wantsReply: plan.wantsReply,
127
+ message: plan.message,
128
+ },
129
+ };
130
+ }
131
+
132
+ // In-domain (pi-like) → inspect + probe the canonical path UNDER the held lock. A
133
+ // re-observed `dead`/`indeterminate` is an honest reject (never forced to one or the
134
+ // other); `address-conflict` is a reject too (a symlink planted since dispatch).
135
+ const inspection = await deps.inspectSocket(gardenId);
136
+ const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
137
+ if ("addressConflict" in mapped) {
138
+ return { kind: "reject", reason: "target-address-conflict" };
139
+ }
140
+ const { liveness, socketPath } = mapped;
141
+
142
+ const receipt = resolveDispatch("fire-and-forget", liveness, false);
143
+ if (!receipt.ok) {
144
+ return { kind: "reject", reason: receipt.reason };
145
+ }
146
+ // fire-and-forget + live is the ONLY in-domain allow cell → control-socket send. A
147
+ // resume/spawn-bg or meta-mailbox transport here would be a frozen-table drift, so
148
+ // fail loud rather than mis-route a SEND fallback into a child spawn.
149
+ if (receipt.transport !== "control-socket") {
150
+ throw new Error(
151
+ `entwurf-v2-send-fallback: in-domain fire-and-forget re-resolve yielded unexpected transport (${receipt.transport}) — table drift.`,
152
+ );
153
+ }
154
+ return {
155
+ kind: "execute",
156
+ plan: {
157
+ transport: "control-socket",
158
+ action: "send",
159
+ targetGardenId: gardenId,
160
+ socketPath,
161
+ mode: plan.mode,
162
+ wantsReply: plan.wantsReply,
163
+ message: plan.message,
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * entwurf-v2-send — the 5c-2 control-socket SEND hand (0.11 Stage 0 step 5c-2a).
3
+ * It WIRES real transport IO onto the pure release reducer (5c-1): drive a
4
+ * control-socket send to a FINAL outcome, feed that outcome to `reduceRelease`, and
5
+ * release the held lock EXACTLY ONCE. Every IO seam is an injected dep (the gate
6
+ * fakes it, production wires the real fns), so the send→outcome→release ORDERING is
7
+ * gate-provable without a live socket — the same "pure-before-IO, IO-via-dep"
8
+ * discipline 5b used for the decider.
9
+ *
10
+ * Why a control hand that only EXECUTES, never DECIDES routing: the 5b decider
11
+ * already chose this in-domain control-socket plan AFTER observing the target alive.
12
+ * But a send can race that observation — by send time the socket may be `dead`
13
+ * (TOCTOU). The control hand must NOT decide on its own that a dead socket means
14
+ * "mailbox now" — that would bypass the 5b dispatch table (deliverability / conflict /
15
+ * pi-primary wakeMode). So on a `dead` connect it delegates to `deps.deadFallback`,
16
+ * a SAME-LOCK one-shot re-resolve (the 5c-2b resolver: reuse 5b resolveTarget /
17
+ * inspectSocket / resolveDispatch but DO NOT release) that hands back a fresh plan to
18
+ * EXECUTE or a reject. The hand runs that plan once; it never re-routes by itself.
19
+ *
20
+ * The three load-bearing send-final rules (GPT 5c-2 design, conditional GO):
21
+ * - in-band reject (RPC `success:false`) is FINAL with NO fallback — the receiver
22
+ * was reached and refused; re-resolving would be a second delivery attempt.
23
+ * - a `dead` connect (ECONNREFUSED/ENOENT) proves non-delivery → re-resolve once.
24
+ * - an `indeterminate` connect (timeout/EACCES/stall) proves NOTHING: the socket
25
+ * may be alive-but-stalled, so a fallback would risk a DOUBLE delivery. It is an
26
+ * immediate `failed` + rethrow — deadFallback and the mailbox helper are NOT even
27
+ * called. (This is the exact F3 three-valued liveness distinction, on the send
28
+ * path: dead ⇒ act, indeterminate ⇒ do not.)
29
+ *
30
+ * Release authority is ALWAYS the LockClaim the decider handed over. `reduceRelease`
31
+ * guarantees the release fires at most once across the single send-final event; a
32
+ * `releaseLock` throw never MASKS a send failure — the original error wins (5b).
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 ReleasePolicy,
41
+ type ReleaseState,
42
+ reduceRelease,
43
+ } from "./entwurf-v2-release.ts";
44
+
45
+ // The two send-capable plan shapes, narrowed from the decider's ExecutionPlan union.
46
+ export type ControlSocketPlan = Extract<ExecutionPlan, { transport: "control-socket" }>;
47
+ export type MetaMailboxPlan = Extract<ExecutionPlan, { transport: "meta-mailbox" }>;
48
+
49
+ /** The terminal outcome of a send attempt, mapped 1:1 onto the reducer's
50
+ * `send-final` outcomes. `fallback-sent` is a REAL final outcome (legacy parity: a
51
+ * re-resolved delivery is not a hidden retry). */
52
+ export type SendFinalOutcome = "sent" | "fallback-sent" | "rejected" | "failed";
53
+
54
+ /** What a single RPC / mailbox enqueue reports. `success:false` is an in-band reject
55
+ * (the receiver answered and refused) — distinct from a thrown connect error. */
56
+ export interface RpcSendResult {
57
+ success: boolean;
58
+ error?: string;
59
+ }
60
+
61
+ /** The same-lock one-shot re-resolve result (5c-2b implements the resolver; 5c-2a
62
+ * only consumes its contract). `execute` hands back a fresh plan to run ONCE under the
63
+ * SAME held lock; `reject` means there is no live route — a final `rejected`. The
64
+ * resolver must NOT release the lock — release stays the hand's single responsibility. */
65
+ export type DeadFallbackResolution = { kind: "execute"; plan: ExecutionPlan } | { kind: "reject"; reason: string };
66
+
67
+ /**
68
+ * Every IO seam is a REQUIRED dep — the hand performs ZERO IO of its own, exactly so
69
+ * the gate can drive every send-final path without a socket (5b's "no hidden default
70
+ * that touches the world" rule).
71
+ * - sendOverSocket — the control-socket RPC send (1차 AND the re-resolve retry).
72
+ * Resolves `RpcSendResult` on a completed RPC; THROWS on a connect-time failure
73
+ * (the thrown error's `.code` is what `classifyConnect` reads).
74
+ * - classifyConnect — connect-error code → "dead" | "indeterminate" (the F3 split).
75
+ * - releaseLock — release the held claim; called at most once, only when the
76
+ * reducer says shouldRelease.
77
+ * - deadFallback — the SAME-LOCK re-resolve, called ONLY on a `dead` connect and
78
+ * ONLY while the lock is still held. (5c-2b supplies the real resolver.)
79
+ * - sendViaMailbox — enqueue-only meta-mailbox delivery, used ONLY when the
80
+ * re-resolve hands back a meta-mailbox plan. The hand never decides mailbox
81
+ * routing itself; it just runs the plan the resolver chose. (5c-4 supplies the
82
+ * real enqueue.)
83
+ */
84
+ export interface ControlSocketSendDeps {
85
+ sendOverSocket: (plan: ControlSocketPlan) => Promise<RpcSendResult>;
86
+ classifyConnect: (code: string | undefined) => "dead" | "indeterminate";
87
+ releaseLock: (lock: LockClaim) => void;
88
+ deadFallback: (plan: ControlSocketPlan, lock: LockClaim) => Promise<DeadFallbackResolution>;
89
+ sendViaMailbox: (plan: MetaMailboxPlan, lock: LockClaim) => Promise<RpcSendResult>;
90
+ }
91
+
92
+ export interface ControlSocketSendResult {
93
+ outcome: SendFinalOutcome;
94
+ /** Present ONLY on a `rejected` outcome that came from the dead-path re-resolve
95
+ * (5c-2b): the resolver's machine-readable reason (dormant-fire-forget-unsupported /
96
+ * mailbox-undeliverable / indeterminate-no-spawn / bad-target / target-address-conflict).
97
+ * An in-band RPC refusal carries NO reason (there is no resolver taxonomy for it). The
98
+ * 5d runner carries this verbatim so the surface can tell "in-band refusal" from
99
+ * "no live route" — the N3 carry-over the hand boundary used to drop. */
100
+ rejectReason?: string;
101
+ }
102
+
103
+ // A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
104
+ // a `failed` (the hand releases first, then rethrows — never swallows the failure), plus
105
+ // the optional resolver reject reason to carry on a re-resolve `rejected` (N3).
106
+ interface SendDrive {
107
+ outcome: SendFinalOutcome;
108
+ error?: unknown;
109
+ rejectReason?: string;
110
+ }
111
+
112
+ /**
113
+ * Execute a control-socket send to a final outcome and release the held lock exactly
114
+ * once. `lock` MUST be the in-domain claim the decider handed over —
115
+ * `decideReleasePolicy` throws if it is null or paired with the wrong gid (a mis-wire
116
+ * is fail-loud, not a runtime branch). On a `failed` outcome the original transport
117
+ * error is rethrown AFTER the (single) release.
118
+ */
119
+ export async function executeControlSocketSend(
120
+ plan: ControlSocketPlan,
121
+ lock: LockClaim | null,
122
+ deps: ControlSocketSendDeps,
123
+ ): Promise<ControlSocketSendResult> {
124
+ // Throws on a null / mis-paired lock (?7 + gid invariants). After this line the
125
+ // control-socket policy is release-after-send-final and the lock is non-null.
126
+ const policy: ReleasePolicy = decideReleasePolicy(plan, lock);
127
+ const held = lock as LockClaim;
128
+
129
+ // Lock-leak backstop: once the lock is held, ANY throw out of the drive — a
130
+ // contract-violation guard (spawn-bg re-resolve), the mis-route assert, even a buggy
131
+ // dep that throws where it should return — must still release the lock before it
132
+ // propagates. A leaked lock pins the gid forever (5a's worst failure). So convert any
133
+ // such throw into a `failed` final outcome, run finalizeRelease (which releases on
134
+ // the send-final event), and then rethrow the original error from there.
135
+ let drive: SendDrive;
136
+ try {
137
+ drive = await driveSend(plan, held, deps);
138
+ } catch (err) {
139
+ drive = { outcome: "failed", error: err };
140
+ }
141
+ finalizeRelease(policy, deps, held, drive);
142
+ return { outcome: drive.outcome, rejectReason: drive.rejectReason };
143
+ }
144
+
145
+ /** Drive the 1차 send and route a connect failure through the F3 split. */
146
+ async function driveSend(plan: ControlSocketPlan, lock: LockClaim, deps: ControlSocketSendDeps): Promise<SendDrive> {
147
+ let result: RpcSendResult;
148
+ try {
149
+ result = await deps.sendOverSocket(plan);
150
+ } catch (err) {
151
+ const liveness = deps.classifyConnect((err as NodeJS.ErrnoException)?.code);
152
+ if (liveness === "indeterminate") {
153
+ // Stall/unknown — the socket may be alive. A fallback here risks a DOUBLE
154
+ // delivery, so finalize as failed WITHOUT touching deadFallback / mailbox.
155
+ return { outcome: "failed", error: err };
156
+ }
157
+ // dead ⇒ proven non-delivery ⇒ same-lock one-shot re-resolve (lock still held).
158
+ return await driveDeadFallback(plan, lock, deps);
159
+ }
160
+ // A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback.
161
+ return { outcome: result.success ? "sent" : "rejected" };
162
+ }
163
+
164
+ /**
165
+ * The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
166
+ * chosen plan exactly once (the retry is one-shot — a second connect failure does NOT
167
+ * re-enter the fallback, it finalizes as failed). The hand only executes; the resolver
168
+ * decided.
169
+ */
170
+ async function driveDeadFallback(
171
+ plan: ControlSocketPlan,
172
+ lock: LockClaim,
173
+ deps: ControlSocketSendDeps,
174
+ ): Promise<SendDrive> {
175
+ let resolution: DeadFallbackResolution;
176
+ try {
177
+ resolution = await deps.deadFallback(plan, lock);
178
+ } catch (err) {
179
+ return { outcome: "failed", error: err };
180
+ }
181
+ if (resolution.kind === "reject") {
182
+ // N3: carry the resolver's reason out so the runner/surface can distinguish a
183
+ // dormant-fire-forget / undeliverable / no-route reject from an in-band refusal.
184
+ return { outcome: "rejected", rejectReason: resolution.reason };
185
+ }
186
+ const rePlan = resolution.plan;
187
+ // Same-lock re-resolve invariant: the fallback plan MUST target the SAME gid the lock
188
+ // is held for. A resolver that returns a DIFFERENT target's plan would send/enqueue to
189
+ // B while only A's lock is ever released — a mis-route AND a lock leak. Fail loud (the
190
+ // execute-level catch in executeControlSocketSend releases A's lock before rethrowing).
191
+ if (rePlan.targetGardenId !== plan.targetGardenId || rePlan.targetGardenId !== lock.gardenId) {
192
+ throw new Error(
193
+ `entwurf-v2-send: re-resolve returned a plan for a different target (${rePlan.targetGardenId}) than the held lock (${lock.gardenId}) — same-lock re-resolve violated.`,
194
+ );
195
+ }
196
+ switch (rePlan.transport) {
197
+ case "control-socket":
198
+ // Re-resolve picked another live socket — retry the send ONCE. A second
199
+ // connect failure finalizes as failed (no further fallback).
200
+ try {
201
+ const r = await deps.sendOverSocket(rePlan);
202
+ return { outcome: r.success ? "fallback-sent" : "rejected" };
203
+ } catch (err) {
204
+ return { outcome: "failed", error: err };
205
+ }
206
+ case "meta-mailbox":
207
+ // Re-resolve picked the mailbox — enqueue ONCE via the injected helper. The
208
+ // hand never reaches for the mailbox on its own; only the resolver routes here.
209
+ try {
210
+ const r = await deps.sendViaMailbox(rePlan, lock);
211
+ return { outcome: r.success ? "fallback-sent" : "rejected" };
212
+ } catch (err) {
213
+ return { outcome: "failed", error: err };
214
+ }
215
+ case "spawn-bg":
216
+ // A SEND fallback must never re-resolve into a spawn — that is a decider
217
+ // contract violation (a send and a spawn are different actions), so fail loud
218
+ // rather than silently mis-execute.
219
+ throw new Error("entwurf-v2-send: re-resolve returned a spawn-bg plan for a send fallback (contract violation).");
220
+ }
221
+ }
222
+
223
+ /**
224
+ * N1: a release failure AFTER a non-`failed` final outcome. The send already reached a
225
+ * terminal result (`sent` / `fallback-sent` / `rejected`) — the delivery (or in-band
226
+ * refusal) HAPPENED — but `releaseLock` then threw, so the lock is dirty. This is NOT a
227
+ * send failure, and the caller MUST NOT re-dispatch (a re-send would double-deliver). A
228
+ * structured error (not a bare rethrow) lets the 5d runner render "finalized + lock
229
+ * dirty, retry-unsafe" distinctly from "send failed". For a `failed` outcome the original
230
+ * send error still wins — that path never builds this error.
231
+ */
232
+ export class SendDeliveredReleaseFailedError extends Error {
233
+ readonly finalizedOutcome: Exclude<SendFinalOutcome, "failed">;
234
+ readonly releaseError: unknown;
235
+ constructor(finalizedOutcome: Exclude<SendFinalOutcome, "failed">, releaseError: unknown) {
236
+ const detail = releaseError instanceof Error ? releaseError.message : String(releaseError);
237
+ super(
238
+ `entwurf-v2-send: ${finalizedOutcome} delivered but releaseLock failed (lock dirty, do NOT re-send): ${detail}`,
239
+ );
240
+ this.name = "SendDeliveredReleaseFailedError";
241
+ this.finalizedOutcome = finalizedOutcome;
242
+ this.releaseError = releaseError;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Fold the single send-final event into the reducer and release the lock if (and only
248
+ * if) the reducer says so. On a `failed` outcome the lock is released FIRST and then
249
+ * the original error is rethrown — and if `releaseLock` itself throws, the original
250
+ * send error still wins (a release failure must not MASK the send failure; 5b). On a
251
+ * NON-`failed` outcome a release failure throws a `SendDeliveredReleaseFailedError` (N1):
252
+ * the delivery happened, so the runner must surface "finalized + lock dirty", not "failed".
253
+ */
254
+ function finalizeRelease(policy: ReleasePolicy, deps: ControlSocketSendDeps, lock: LockClaim, drive: SendDrive): void {
255
+ const initial: ReleaseState = initialReleaseState();
256
+ const { shouldRelease } = reduceRelease(policy, initial, { kind: "send-final", outcome: drive.outcome });
257
+ const original = drive.outcome === "failed" ? drive.error : undefined;
258
+
259
+ if (shouldRelease) {
260
+ try {
261
+ deps.releaseLock(lock);
262
+ } catch (releaseErr) {
263
+ // A release failure must not swallow a real send failure.
264
+ if (drive.outcome === "failed") throw original;
265
+ // N1: the delivery/refusal already happened — surface it as a structured,
266
+ // retry-unsafe error rather than a bare release throw.
267
+ throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr);
268
+ }
269
+ }
270
+ if (drive.outcome === "failed") throw original;
271
+ }