@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,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-entwurf-v2-send — deterministic gate for the 5c-2a control-socket SEND hand
|
|
3
|
+
* (`executeControlSocketSend`). It proves the send→outcome→release WIRING over
|
|
4
|
+
* injected fakes, with NO live socket, exactly as the 5b decider gate proves dispatch:
|
|
5
|
+
*
|
|
6
|
+
* 1. ack success → outcome `sent`, release ×1, deadFallback NOT called.
|
|
7
|
+
* 2. in-band reject (success:false) → outcome `rejected`, release ×1, NO fallback
|
|
8
|
+
* (deadFallback + mailbox NOT called — the receiver was reached and refused); NO
|
|
9
|
+
* rejectReason (an in-band refusal has no resolver taxonomy — N3 boundary).
|
|
10
|
+
* 3. dead → re-resolve(control-socket) → success → `fallback-sent`, release ×1,
|
|
11
|
+
* deadFallback called EXACTLY once and UNDER the still-held lock (before release).
|
|
12
|
+
* 4. dead → re-resolve reject → `rejected`, release ×1, and the resolver's reason is
|
|
13
|
+
* CARRIED on the result as `rejectReason` (N3 — no longer dropped at the boundary).
|
|
14
|
+
* 5. dead → re-resolve(control-socket) retry THROWS → `failed`, release ×1, original
|
|
15
|
+
* retry error rethrown; the retry is one-shot (deadFallback called once only).
|
|
16
|
+
* 6. dead → deadFallback THROWS → `failed`, release ×1, rethrow.
|
|
17
|
+
* 7. indeterminate → `failed`, release ×1, rethrow; deadFallback + mailbox NOT called
|
|
18
|
+
* (no double-delivery on an alive-but-stalled socket).
|
|
19
|
+
* 8. dead → re-resolve(meta-mailbox) → enqueue success → `fallback-sent`, release ×1
|
|
20
|
+
* (mailbox helper called once); enqueue success:false → `rejected`. A first-dead
|
|
21
|
+
* NEVER reaches the mailbox without the resolver routing it there.
|
|
22
|
+
* 9. single-release: releaseLock is invoked exactly once per send-final.
|
|
23
|
+
* 10. masking guard: on a `failed` send the ORIGINAL send error is rethrown even if
|
|
24
|
+
* releaseLock itself throws (a release failure must not mask the send failure).
|
|
25
|
+
* 11. lock invariants: null lock / mismatched-gid lock → throws (decideReleasePolicy).
|
|
26
|
+
* 12. contract: a re-resolve into a spawn-bg plan for a SEND → throws AND still
|
|
27
|
+
* releases the held lock ×1 (lock-leak backstop, not a leak).
|
|
28
|
+
* 13. lock-leak backstop: any UNEXPECTED dep throw after the lock is held
|
|
29
|
+
* (classifyConnect) still releases ×1 and rethrows the original error.
|
|
30
|
+
* 14. mis-route: a re-resolve returning a DIFFERENT-target control plan fails loud
|
|
31
|
+
* BEFORE any retry send (no retry, no mailbox, release ×1).
|
|
32
|
+
* 15. mis-route: a re-resolve returning a DIFFERENT-target mailbox plan fails loud
|
|
33
|
+
* BEFORE enqueue (no mailbox, release ×1).
|
|
34
|
+
* 16. non-failed releaseLock throw (sent): the delivery already happened, so a re-send
|
|
35
|
+
* would double-deliver — thrown as a STRUCTURED SendDeliveredReleaseFailedError
|
|
36
|
+
* (finalizedOutcome + releaseError), NOT a bare rethrow (N1) — release ×1.
|
|
37
|
+
* 17. mailbox fallback enqueue THROW → failed + rethrow, helper called once, release ×1.
|
|
38
|
+
*
|
|
39
|
+
* No real IO — fakes record call order so "release happens after re-resolve, exactly
|
|
40
|
+
* once" is asserted structurally.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import assert from "node:assert/strict";
|
|
44
|
+
import type { ExecutionPlan } from "../pi-extensions/lib/entwurf-v2-decider.ts";
|
|
45
|
+
import type { LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
|
|
46
|
+
import {
|
|
47
|
+
type ControlSocketSendDeps,
|
|
48
|
+
type DeadFallbackResolution,
|
|
49
|
+
executeControlSocketSend,
|
|
50
|
+
type RpcSendResult,
|
|
51
|
+
SendDeliveredReleaseFailedError,
|
|
52
|
+
} from "../pi-extensions/lib/entwurf-v2-send.ts";
|
|
53
|
+
|
|
54
|
+
let passed = 0;
|
|
55
|
+
function ok(label: string, cond: boolean): void {
|
|
56
|
+
assert.ok(cond, label);
|
|
57
|
+
console.log(` ok ${label}`);
|
|
58
|
+
passed++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const GID = "20260612T100000-aaaaaa";
|
|
62
|
+
const WRONG_GID = "20260612T999999-bbbbbb";
|
|
63
|
+
|
|
64
|
+
function lockClaim(gardenId = GID): LockClaim {
|
|
65
|
+
return {
|
|
66
|
+
gardenId,
|
|
67
|
+
pid: 4242,
|
|
68
|
+
hostname: "test-host",
|
|
69
|
+
createdAt: "2026-06-12T01:00:00.000Z",
|
|
70
|
+
nonce: "deadbeefcafef00d",
|
|
71
|
+
owner: "entwurf_v2",
|
|
72
|
+
lockPath: `/fake/locks/${gardenId}.lock`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CONTROL_PLAN = {
|
|
77
|
+
transport: "control-socket",
|
|
78
|
+
action: "send",
|
|
79
|
+
targetGardenId: GID,
|
|
80
|
+
socketPath: "/fake/ctl/s.sock",
|
|
81
|
+
mode: "follow_up",
|
|
82
|
+
wantsReply: false,
|
|
83
|
+
message: "m",
|
|
84
|
+
} as const satisfies Extract<ExecutionPlan, { transport: "control-socket" }>;
|
|
85
|
+
|
|
86
|
+
const RERESOLVED_CONTROL_PLAN = { ...CONTROL_PLAN, socketPath: "/fake/ctl/alt.sock" } as const;
|
|
87
|
+
|
|
88
|
+
const MAILBOX_PLAN = {
|
|
89
|
+
transport: "meta-mailbox",
|
|
90
|
+
action: "send",
|
|
91
|
+
targetGardenId: GID,
|
|
92
|
+
mailboxDir: "/fake/mailbox",
|
|
93
|
+
sessionsDir: "/fake/sessions",
|
|
94
|
+
wantsReply: false,
|
|
95
|
+
message: "m",
|
|
96
|
+
} as const satisfies Extract<ExecutionPlan, { transport: "meta-mailbox" }>;
|
|
97
|
+
|
|
98
|
+
const SPAWN_PLAN = {
|
|
99
|
+
transport: "spawn-bg",
|
|
100
|
+
action: "resume",
|
|
101
|
+
targetGardenId: GID,
|
|
102
|
+
sessionId: GID,
|
|
103
|
+
cwd: "/home/junghan/repos/gh/entwurf",
|
|
104
|
+
prompt: "p",
|
|
105
|
+
launchArgs: [],
|
|
106
|
+
expectedSocketPath: "/fake/ctl/s.sock",
|
|
107
|
+
observeTimeoutMs: 30_000,
|
|
108
|
+
releaseWhen: "socket-alive-or-child-exited",
|
|
109
|
+
} as const satisfies Extract<ExecutionPlan, { transport: "spawn-bg" }>;
|
|
110
|
+
|
|
111
|
+
// A connect-time error with a real `.code` (drives the F3 classifier the way a node
|
|
112
|
+
// net error would). dead = ECONNREFUSED/ENOENT; everything else = indeterminate.
|
|
113
|
+
function connError(code: string): NodeJS.ErrnoException {
|
|
114
|
+
const e = new Error(`connect ${code}`) as NodeJS.ErrnoException;
|
|
115
|
+
e.code = code;
|
|
116
|
+
return e;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The real F3 classifier — proving the wiring direction (dead vs indeterminate), not a
|
|
120
|
+
// hand-rolled stand-in.
|
|
121
|
+
function classifyConnect(code: string | undefined): "dead" | "indeterminate" {
|
|
122
|
+
return code === "ECONNREFUSED" || code === "ENOENT" ? "dead" : "indeterminate";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Trace {
|
|
126
|
+
order: string[];
|
|
127
|
+
socketSends: Array<Extract<ExecutionPlan, { transport: "control-socket" }>>;
|
|
128
|
+
mailboxSends: Array<Extract<ExecutionPlan, { transport: "meta-mailbox" }>>;
|
|
129
|
+
deadFallbackCalls: number;
|
|
130
|
+
releases: LockClaim[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface FakeSpec {
|
|
134
|
+
// 1차 send: a result (ack/in-band), or a throw with this connect code.
|
|
135
|
+
firstSend: { result: RpcSendResult } | { throwCode: string };
|
|
136
|
+
// re-resolve outcome (only consulted on a dead first send).
|
|
137
|
+
deadFallback?: DeadFallbackResolution | { throw: unknown };
|
|
138
|
+
// the retry/mailbox send when the re-resolve hands back a plan.
|
|
139
|
+
fallbackSend?: { result: RpcSendResult } | { throwCode: string };
|
|
140
|
+
// force releaseLock to throw (masking-guard case).
|
|
141
|
+
releaseThrows?: unknown;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function makeDeps(spec: FakeSpec): { deps: ControlSocketSendDeps; trace: Trace } {
|
|
145
|
+
const trace: Trace = { order: [], socketSends: [], mailboxSends: [], deadFallbackCalls: 0, releases: [] };
|
|
146
|
+
let socketCall = 0;
|
|
147
|
+
|
|
148
|
+
const deps: ControlSocketSendDeps = {
|
|
149
|
+
async sendOverSocket(plan) {
|
|
150
|
+
socketCall++;
|
|
151
|
+
trace.order.push(`sendOverSocket#${socketCall}`);
|
|
152
|
+
trace.socketSends.push(plan);
|
|
153
|
+
// First socket call = 1차 send; any later = the re-resolve retry.
|
|
154
|
+
const step = socketCall === 1 ? spec.firstSend : spec.fallbackSend;
|
|
155
|
+
if (!step) throw new Error("test: unexpected extra sendOverSocket call");
|
|
156
|
+
if ("throwCode" in step) throw connError(step.throwCode);
|
|
157
|
+
return step.result;
|
|
158
|
+
},
|
|
159
|
+
classifyConnect,
|
|
160
|
+
releaseLock(lock) {
|
|
161
|
+
trace.order.push("releaseLock");
|
|
162
|
+
trace.releases.push(lock);
|
|
163
|
+
if (spec.releaseThrows !== undefined) throw spec.releaseThrows;
|
|
164
|
+
},
|
|
165
|
+
async deadFallback(_plan, _lock) {
|
|
166
|
+
trace.deadFallbackCalls++;
|
|
167
|
+
trace.order.push("deadFallback");
|
|
168
|
+
if (!spec.deadFallback) throw new Error("test: deadFallback called but no spec");
|
|
169
|
+
if ("throw" in spec.deadFallback) throw spec.deadFallback.throw;
|
|
170
|
+
return spec.deadFallback;
|
|
171
|
+
},
|
|
172
|
+
async sendViaMailbox(plan) {
|
|
173
|
+
trace.order.push("sendViaMailbox");
|
|
174
|
+
trace.mailboxSends.push(plan);
|
|
175
|
+
const step = spec.fallbackSend;
|
|
176
|
+
if (!step) throw new Error("test: unexpected sendViaMailbox call");
|
|
177
|
+
if ("throwCode" in step) throw connError(step.throwCode);
|
|
178
|
+
return step.result;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
return { deps, trace };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function run(spec: FakeSpec, lock: LockClaim | null = lockClaim()) {
|
|
185
|
+
const { deps, trace } = makeDeps(spec);
|
|
186
|
+
const result = await executeControlSocketSend(CONTROL_PLAN, lock, deps);
|
|
187
|
+
return { result, trace };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function rejects(fn: () => Promise<unknown>): Promise<unknown> {
|
|
191
|
+
try {
|
|
192
|
+
await fn();
|
|
193
|
+
return Symbol("did-not-throw");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return err;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function main(): Promise<void> {
|
|
200
|
+
// ── 1: ack success → sent, release once, no fallback ──────────────────────
|
|
201
|
+
{
|
|
202
|
+
const { result, trace } = await run({ firstSend: { result: { success: true } } });
|
|
203
|
+
ok("ack success → sent", result.outcome === "sent");
|
|
204
|
+
ok("ack success → release ×1", trace.releases.length === 1 && trace.releases[0].nonce === lockClaim().nonce);
|
|
205
|
+
ok("ack success → deadFallback NOT called", trace.deadFallbackCalls === 0);
|
|
206
|
+
ok("ack success → release after the send", trace.order.join(",") === "sendOverSocket#1,releaseLock");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── 2: in-band reject → rejected, release once, NO fallback ───────────────
|
|
210
|
+
{
|
|
211
|
+
const { result, trace } = await run({ firstSend: { result: { success: false, error: "refused" } } });
|
|
212
|
+
ok("in-band reject → rejected", result.outcome === "rejected");
|
|
213
|
+
ok("in-band reject → release ×1", trace.releases.length === 1);
|
|
214
|
+
// N3 boundary: an in-band RPC refusal has NO resolver reason (only a dead-path
|
|
215
|
+
// re-resolve reject carries one) — the field stays undefined here.
|
|
216
|
+
ok("in-band reject → no rejectReason (in-band has no resolver taxonomy)", result.rejectReason === undefined);
|
|
217
|
+
ok(
|
|
218
|
+
"in-band reject → no deadFallback, no mailbox",
|
|
219
|
+
trace.deadFallbackCalls === 0 && trace.mailboxSends.length === 0,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── 3: dead → re-resolve(control) success → fallback-sent, fallback before release
|
|
224
|
+
{
|
|
225
|
+
const { result, trace } = await run({
|
|
226
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
227
|
+
deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
|
|
228
|
+
fallbackSend: { result: { success: true } },
|
|
229
|
+
});
|
|
230
|
+
ok("dead → re-resolve(control) success → fallback-sent", result.outcome === "fallback-sent");
|
|
231
|
+
ok("dead → deadFallback called exactly once", trace.deadFallbackCalls === 1);
|
|
232
|
+
ok("dead → re-resolve used the alt socket", trace.socketSends[1]?.socketPath === "/fake/ctl/alt.sock");
|
|
233
|
+
ok("dead → release ×1", trace.releases.length === 1);
|
|
234
|
+
ok(
|
|
235
|
+
"dead → deadFallback UNDER held lock (before release)",
|
|
236
|
+
trace.order.indexOf("deadFallback") < trace.order.indexOf("releaseLock"),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── 4: dead → re-resolve reject → rejected ────────────────────────────────
|
|
241
|
+
{
|
|
242
|
+
const { result, trace } = await run({
|
|
243
|
+
firstSend: { throwCode: "ENOENT" },
|
|
244
|
+
deadFallback: { kind: "reject", reason: "no-route" },
|
|
245
|
+
});
|
|
246
|
+
ok("dead → re-resolve reject → rejected", result.outcome === "rejected");
|
|
247
|
+
ok("dead → reject → release ×1, no retry send", trace.releases.length === 1 && trace.socketSends.length === 1);
|
|
248
|
+
// N3: the resolver's reason is carried out on the result (not dropped at the hand boundary).
|
|
249
|
+
ok("dead → re-resolve reject → rejectReason carried (N3)", result.rejectReason === "no-route");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── 5: dead → re-resolve(control) retry throws → failed + rethrow ──────────
|
|
253
|
+
{
|
|
254
|
+
const { deps, trace } = makeDeps({
|
|
255
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
256
|
+
deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
|
|
257
|
+
fallbackSend: { throwCode: "ECONNREFUSED" },
|
|
258
|
+
});
|
|
259
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
260
|
+
ok(
|
|
261
|
+
"dead → retry throws → rethrows the retry error",
|
|
262
|
+
err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED",
|
|
263
|
+
);
|
|
264
|
+
ok("dead → retry throws → release ×1 (failed still releases)", trace.releases.length === 1);
|
|
265
|
+
ok("dead → retry is one-shot (deadFallback called once)", trace.deadFallbackCalls === 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── 6: dead → deadFallback throws → failed + rethrow ──────────────────────
|
|
269
|
+
{
|
|
270
|
+
const sentinel = new Error("resolver boom");
|
|
271
|
+
const { deps, trace } = makeDeps({ firstSend: { throwCode: "ENOENT" }, deadFallback: { throw: sentinel } });
|
|
272
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
273
|
+
ok("dead → deadFallback throws → rethrows it", err === sentinel);
|
|
274
|
+
ok("dead → deadFallback throws → release ×1", trace.releases.length === 1);
|
|
275
|
+
}
|
|
276
|
+
for (const code of ["ETIMEDOUT", "EACCES"] as const) {
|
|
277
|
+
const { deps, trace } = makeDeps({ firstSend: { throwCode: code } });
|
|
278
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
279
|
+
ok(
|
|
280
|
+
`indeterminate(${code}) → rethrows the connect error`,
|
|
281
|
+
err instanceof Error && (err as NodeJS.ErrnoException).code === code,
|
|
282
|
+
);
|
|
283
|
+
ok(`indeterminate(${code}) → release ×1`, trace.releases.length === 1);
|
|
284
|
+
ok(
|
|
285
|
+
`indeterminate(${code}) → deadFallback + mailbox NOT called`,
|
|
286
|
+
trace.deadFallbackCalls === 0 && trace.mailboxSends.length === 0,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── 8: dead → re-resolve(meta-mailbox) → fallback-sent / rejected ─────────
|
|
291
|
+
{
|
|
292
|
+
const sent = await run({
|
|
293
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
294
|
+
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
295
|
+
fallbackSend: { result: { success: true } },
|
|
296
|
+
});
|
|
297
|
+
ok("dead → re-resolve(mailbox) enqueue → fallback-sent", sent.result.outcome === "fallback-sent");
|
|
298
|
+
ok("dead → mailbox helper called once", sent.trace.mailboxSends.length === 1);
|
|
299
|
+
ok("dead → mailbox reached only via resolver, release ×1", sent.trace.releases.length === 1);
|
|
300
|
+
|
|
301
|
+
const refused = await run({
|
|
302
|
+
firstSend: { throwCode: "ENOENT" },
|
|
303
|
+
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
304
|
+
fallbackSend: { result: { success: false } },
|
|
305
|
+
});
|
|
306
|
+
ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── 9: single-release across every outcome (release at most once) ─────────
|
|
310
|
+
{
|
|
311
|
+
const specs: FakeSpec[] = [
|
|
312
|
+
{ firstSend: { result: { success: true } } },
|
|
313
|
+
{ firstSend: { result: { success: false } } },
|
|
314
|
+
{
|
|
315
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
316
|
+
deadFallback: { kind: "execute", plan: RERESOLVED_CONTROL_PLAN },
|
|
317
|
+
fallbackSend: { result: { success: true } },
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
let allSingle = true;
|
|
321
|
+
for (const s of specs) {
|
|
322
|
+
const { trace } = await run(s);
|
|
323
|
+
if (trace.releases.length !== 1) allSingle = false;
|
|
324
|
+
}
|
|
325
|
+
ok("single-release: every non-throw outcome releases exactly once", allSingle);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 10: masking guard — releaseLock throw must NOT mask the send failure ──
|
|
329
|
+
{
|
|
330
|
+
const releaseErr = new Error("release boom");
|
|
331
|
+
// indeterminate first send → failed (error = the ETIMEDOUT connect error); the
|
|
332
|
+
// release then throws. The ORIGINAL send error must surface, not the release one.
|
|
333
|
+
const { deps, trace } = makeDeps({ firstSend: { throwCode: "ETIMEDOUT" }, releaseThrows: releaseErr });
|
|
334
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
335
|
+
ok(
|
|
336
|
+
"masking guard: original send error wins over a releaseLock throw",
|
|
337
|
+
err instanceof Error && (err as NodeJS.ErrnoException).code === "ETIMEDOUT" && err !== releaseErr,
|
|
338
|
+
);
|
|
339
|
+
ok("masking guard: release was still attempted", trace.releases.length === 1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── 11: lock invariants → throws (decideReleasePolicy) ────────────────────
|
|
343
|
+
{
|
|
344
|
+
const { deps } = makeDeps({ firstSend: { result: { success: true } } });
|
|
345
|
+
const nullErr = await rejects(() => executeControlSocketSend(CONTROL_PLAN, null, deps));
|
|
346
|
+
ok("null lock → throws (must hold lock)", nullErr instanceof Error);
|
|
347
|
+
|
|
348
|
+
const { deps: deps2 } = makeDeps({ firstSend: { result: { success: true } } });
|
|
349
|
+
const mismatchErr = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(WRONG_GID), deps2));
|
|
350
|
+
ok("mismatched-gid lock → throws (mis-paired plan/lock)", mismatchErr instanceof Error);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── 12: contract — re-resolve into a spawn-bg plan for a SEND → throws,
|
|
354
|
+
// AND the held lock is STILL released (lock-leak backstop) ───────────
|
|
355
|
+
{
|
|
356
|
+
const { deps, trace } = makeDeps({
|
|
357
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
358
|
+
deadFallback: { kind: "execute", plan: SPAWN_PLAN },
|
|
359
|
+
});
|
|
360
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
361
|
+
ok("re-resolve → spawn-bg for a send → throws (contract violation)", err instanceof Error);
|
|
362
|
+
ok("re-resolve → spawn-bg → lock STILL released ×1 (no leak)", trace.releases.length === 1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── 13: lock-leak backstop — an UNEXPECTED dep throw (classifyConnect) after
|
|
366
|
+
// the lock is held still releases ×1 and rethrows the original error ──
|
|
367
|
+
{
|
|
368
|
+
const { deps, trace } = makeDeps({ firstSend: { throwCode: "ECONNREFUSED" } });
|
|
369
|
+
const boom = new Error("classify boom");
|
|
370
|
+
deps.classifyConnect = () => {
|
|
371
|
+
throw boom;
|
|
372
|
+
};
|
|
373
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
374
|
+
ok("dep throw (classifyConnect) → rethrows the original error", err === boom);
|
|
375
|
+
ok("dep throw → lock STILL released ×1 (no leak)", trace.releases.length === 1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── 14: mis-route — re-resolve returns a DIFFERENT-target control plan →
|
|
379
|
+
// fail loud BEFORE any retry send, release ×1, no mailbox ────────────
|
|
380
|
+
{
|
|
381
|
+
const otherTargetControl = { ...RERESOLVED_CONTROL_PLAN, targetGardenId: WRONG_GID } as const;
|
|
382
|
+
const { deps, trace } = makeDeps({
|
|
383
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
384
|
+
deadFallback: { kind: "execute", plan: otherTargetControl },
|
|
385
|
+
fallbackSend: { result: { success: true } },
|
|
386
|
+
});
|
|
387
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
388
|
+
ok("mis-route (control, other target) → throws", err instanceof Error);
|
|
389
|
+
ok(
|
|
390
|
+
"mis-route (control) → no retry send, no mailbox, release ×1",
|
|
391
|
+
trace.socketSends.length === 1 && trace.mailboxSends.length === 0 && trace.releases.length === 1,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── 15: mis-route — re-resolve returns a DIFFERENT-target mailbox plan →
|
|
396
|
+
// fail loud BEFORE enqueue, release ×1 ──────────────────────────────
|
|
397
|
+
{
|
|
398
|
+
const otherTargetMailbox = { ...MAILBOX_PLAN, targetGardenId: WRONG_GID } as const;
|
|
399
|
+
const { deps, trace } = makeDeps({
|
|
400
|
+
firstSend: { throwCode: "ENOENT" },
|
|
401
|
+
deadFallback: { kind: "execute", plan: otherTargetMailbox },
|
|
402
|
+
fallbackSend: { result: { success: true } },
|
|
403
|
+
});
|
|
404
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
405
|
+
ok("mis-route (mailbox, other target) → throws", err instanceof Error);
|
|
406
|
+
ok("mis-route (mailbox) → no enqueue, release ×1", trace.mailboxSends.length === 0 && trace.releases.length === 1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── 16: non-failed releaseLock throw — the delivery already HAPPENED, so a re-send
|
|
410
|
+
// would double-deliver. N1 (5d-1a): this is now a STRUCTURED
|
|
411
|
+
// SendDeliveredReleaseFailedError carrying the finalized outcome + the release
|
|
412
|
+
// error, NOT a bare release throw — the 5d runner renders "finalized + lock
|
|
413
|
+
// dirty, retry-unsafe" distinctly from "send failed" (counterpart to case 10's
|
|
414
|
+
// failed-masking guard). Release still attempted ×1.
|
|
415
|
+
{
|
|
416
|
+
const releaseErr = new Error("release boom after delivery");
|
|
417
|
+
const { deps, trace } = makeDeps({ firstSend: { result: { success: true } }, releaseThrows: releaseErr });
|
|
418
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
419
|
+
ok(
|
|
420
|
+
"sent + releaseLock throw → SendDeliveredReleaseFailedError (N1)",
|
|
421
|
+
err instanceof SendDeliveredReleaseFailedError,
|
|
422
|
+
);
|
|
423
|
+
ok(
|
|
424
|
+
"sent + releaseLock throw → carries finalizedOutcome 'sent' + releaseError",
|
|
425
|
+
err instanceof SendDeliveredReleaseFailedError &&
|
|
426
|
+
err.finalizedOutcome === "sent" &&
|
|
427
|
+
err.releaseError === releaseErr,
|
|
428
|
+
);
|
|
429
|
+
ok("sent + releaseLock throw → release ×1 attempted", trace.releases.length === 1);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── 17: mailbox fallback enqueue THROW → failed + rethrow, release ×1 (Fable
|
|
433
|
+
// N2: the sendViaMailbox throw path existed but was ungated). ────────────
|
|
434
|
+
{
|
|
435
|
+
const { deps, trace } = makeDeps({
|
|
436
|
+
firstSend: { throwCode: "ECONNREFUSED" },
|
|
437
|
+
deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
|
|
438
|
+
fallbackSend: { throwCode: "EPIPE" },
|
|
439
|
+
});
|
|
440
|
+
const err = await rejects(() => executeControlSocketSend(CONTROL_PLAN, lockClaim(), deps));
|
|
441
|
+
ok(
|
|
442
|
+
"dead → mailbox enqueue throws → rethrows",
|
|
443
|
+
err instanceof Error && (err as NodeJS.ErrnoException).code === "EPIPE",
|
|
444
|
+
);
|
|
445
|
+
ok(
|
|
446
|
+
"dead → mailbox enqueue throws → helper called once, release ×1",
|
|
447
|
+
trace.mailboxSends.length === 1 && trace.releases.length === 1,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
console.log(`\n[check-entwurf-v2-send] ${passed} assertions ok`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await main();
|