@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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- 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
|
+
}
|