@junghanacs/entwurf 0.12.0 → 0.12.2
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/CHANGELOG.md +40 -0
- package/README.md +88 -28
- package/docs/setup-clean-host.md +117 -219
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
- package/mcp/entwurf-bridge/dist/protocol.js +29 -0
- package/mcp/entwurf-bridge/start.sh +49 -7
- package/mcp/entwurf-bridge/test.sh +12 -3
- package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
- package/package.json +30 -9
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
- package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
- package/run.sh +150 -15
- package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
- package/scripts/check-entwurf-v2-contract.ts +6 -4
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +37 -10
- package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
- package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-mailbox — the 5c-4 meta-mailbox SEND body (0.11 Stage 0 step 5c-4, the
|
|
3
|
+
* LAST 5c transport slice). It is ENQUEUE-ONLY: render the mailbox body from the plan
|
|
4
|
+
* the 5b decider ALREADY chose and drop it in the target citizen's meta-bridge mailbox.
|
|
5
|
+
* It DECIDES NO ROUTING (the dispatch table did) and carries NO release seam — a
|
|
6
|
+
* meta-mailbox plan is the lock-free path (?7), and even when the 5c-2a send hand calls
|
|
7
|
+
* this on a re-resolved mailbox fallback (under a still-held control-socket lock),
|
|
8
|
+
* RELEASE STAYS THE HAND'S SINGLE RESPONSIBILITY. So this module has no `releaseLock`
|
|
9
|
+
* dep AT ALL — a lock leak from the mailbox body is structurally impossible.
|
|
10
|
+
*
|
|
11
|
+
* Two consumers, one body:
|
|
12
|
+
* - the 5c-2a send hand's `deps.sendViaMailbox(plan, lock)` fallback seam
|
|
13
|
+
* (entwurf-v2-send.ts) — a dead control socket re-resolved into a mailbox plan.
|
|
14
|
+
* - the future 5d dispatch runner's direct unsupported-citizen fire-and-forget path
|
|
15
|
+
* (the decider hands back a meta-mailbox plan with a NULL lock).
|
|
16
|
+
* Both call `executeMetaMailboxSend`; the hand reaches it through the async
|
|
17
|
+
* `sendViaMailbox(plan, lock)` adapter the production factory builds.
|
|
18
|
+
*
|
|
19
|
+
* A mailbox has NO in-band refuse — there is no live receiver to answer `success:false`.
|
|
20
|
+
* An enqueue either succeeds (`{success:true}`) or THROWS (citizen gone / fs / identity).
|
|
21
|
+
* The body NEVER folds a throw into `{success:false}`: the send hand maps a thrown error
|
|
22
|
+
* to `failed`+rethrow, which is the honest outcome for a non-delivery. `success:false` is
|
|
23
|
+
* a control-socket RPC notion (the receiver answered and refused) and would be a lie here.
|
|
24
|
+
*
|
|
25
|
+
* ctx-free, dep-injected (the same discipline the other entwurf-v2-* libs keep): the live
|
|
26
|
+
* sender envelope is built at the wiring site and passed in, so this module never imports
|
|
27
|
+
* ExtensionContext.
|
|
28
|
+
*/
|
|
29
|
+
import { formatMetaMailboxBody } from "./meta-mailbox-body.js";
|
|
30
|
+
import { enqueueMetaMessage } from "./meta-session.js";
|
|
31
|
+
/**
|
|
32
|
+
* Render the plan into a mailbox body and enqueue it ONCE. `sender` is the resolved
|
|
33
|
+
* envelope (built from live ctx at the wiring site — ctx kept OUT of this module); when
|
|
34
|
+
* it is undefined the raw `plan.message` is enqueued (the same envelope-less fallback the
|
|
35
|
+
* legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
|
|
36
|
+
* caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
|
|
37
|
+
* throw PROPAGATES; it is never converted to `{success:false}`.
|
|
38
|
+
*/
|
|
39
|
+
export function executeMetaMailboxSend(plan, sender, deps) {
|
|
40
|
+
const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
|
|
41
|
+
deps.enqueue({
|
|
42
|
+
gardenId: plan.targetGardenId,
|
|
43
|
+
body,
|
|
44
|
+
sessionsDir: plan.sessionsDir,
|
|
45
|
+
mailboxDir: plan.mailboxDir,
|
|
46
|
+
});
|
|
47
|
+
return { success: true };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the production `sendViaMailbox(plan, lock)` adapter the 5c-2a send hand consumes.
|
|
51
|
+
* It IGNORES `lock` entirely (release is the hand's job; a mailbox plan is lock-free) and
|
|
52
|
+
* wraps the sync body in the async dep signature. `senderProvider` is supplied by the
|
|
53
|
+
* wiring site — it calls the private `buildLocalSenderEnvelope(ctx)` and decorates
|
|
54
|
+
* origin/replyable — so this module never imports ExtensionContext. `enqueue` defaults to
|
|
55
|
+
* the real `enqueueMetaMessage`; the gate injects a fake.
|
|
56
|
+
*/
|
|
57
|
+
export function makeProductionSendViaMailbox(opts) {
|
|
58
|
+
const enqueue = opts.enqueue ?? enqueueMetaMessage;
|
|
59
|
+
// `_lock` is named in the signature for the send-hand dep contract but is NEVER read:
|
|
60
|
+
// a mailbox enqueue does not own or release the lock. `async` so a synchronous enqueue
|
|
61
|
+
// throw surfaces as a REJECTED promise (the async dep contract), not a sync throw — the
|
|
62
|
+
// send hand's `await` + try/catch handles either, but a rejection is the honest shape.
|
|
63
|
+
return async (plan, _lock) => executeMetaMailboxSend(plan, opts.senderProvider(), { enqueue });
|
|
64
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-production — 5d-2b: the ctx-free PRODUCTION assembly of `runEntwurfV2`'s deps.
|
|
3
|
+
* `makeProductionEntwurfV2Deps(opts)` wires the real 5b decider IO seams + the three 5c
|
|
4
|
+
* transport hands into one `EntwurfV2RunDeps` (`{decide, executor}`). It touches NO
|
|
5
|
+
* `ExtensionContext`/`ExtensionAPI` — the wiring SITE (5d-3, entwurf-control.ts) builds the
|
|
6
|
+
* `senderProvider` from `buildLocalSenderEnvelope(ctx)` and passes it in, so this module
|
|
7
|
+
* stays in `lib/` with the rest of the gate-/smoke-testable v2 core.
|
|
8
|
+
*
|
|
9
|
+
* Three invariants this factory exists to guarantee (each gate-proven):
|
|
10
|
+
* - ONE lock domain (Q2/QB3). A single `release` closure bound to `lockDir` is the lock
|
|
11
|
+
* authority for EVERY hand: the decider's `releaseLock`, the control-send hand's
|
|
12
|
+
* `releaseLock`, AND the spawn watcher's `releaseFn`. The spawn factory's default
|
|
13
|
+
* `releaseFn` ignores `lockDir` (it would release into the DEFAULT lock dir) — passing
|
|
14
|
+
* `release` explicitly is what closes that split-brain.
|
|
15
|
+
* - ONE mailbox sender (Q3). A single `makeProductionSendViaMailbox` instance is the
|
|
16
|
+
* top-level meta-mailbox hand AND the control-send dead-fallback's mailbox enqueue, so a
|
|
17
|
+
* direct send and a fallback send never drift in sender envelope / dirs.
|
|
18
|
+
* - ONE path set (Q5). `sessionsDir`/`mailboxDir`/`lockDir`/`controlSocketDir` are resolved
|
|
19
|
+
* ONCE and threaded to the decider, the dead-fallback resolver, and the enqueue, so the
|
|
20
|
+
* plan the decider plants and the plan the fallback plants address the same store.
|
|
21
|
+
*
|
|
22
|
+
* `resolveTarget` (QB1): a MISSING meta-record is a soft `bad-target` (identity:null); a
|
|
23
|
+
* PRESENT-but-corrupt record fails LOUD (the read throws drift) — never matched by string.
|
|
24
|
+
* `preProbeAddressConflict` (QB2): a single record-side `lstat` (NO connect) of the target's
|
|
25
|
+
* canonical socket path; `indeterminate` (EACCES/unknown) fails loud rather than silently
|
|
26
|
+
* claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
|
|
27
|
+
*/
|
|
28
|
+
import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
|
|
29
|
+
import { mailboxConversationalDeliverable, receiverMarkerMatchesIdentity, } from "./entwurf-deliverability.js";
|
|
30
|
+
import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.js";
|
|
31
|
+
import { isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
32
|
+
import { decideDispatch, } from "./entwurf-v2-decider.js";
|
|
33
|
+
import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
|
|
34
|
+
import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.js";
|
|
35
|
+
import { executeControlSocketSend, } from "./entwurf-v2-send.js";
|
|
36
|
+
import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
|
|
37
|
+
import { executeSpawnBgResume } from "./entwurf-v2-spawn.js";
|
|
38
|
+
import { makeProductionSpawnBgResumeDeps } from "./entwurf-v2-spawn-production.js";
|
|
39
|
+
import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readMetaIdentityByGardenId, readMetaReceiverMarker, } from "./meta-session.js";
|
|
40
|
+
import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isSocketOnlyPiCandidate, } from "./socket-discovery.js";
|
|
41
|
+
import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
|
|
42
|
+
/**
|
|
43
|
+
* 0.12.1 B-2: the production `preflight` seam default. preflight value-imports
|
|
44
|
+
* `@earendil-works/pi-coding-agent` (ProjectTrustStore), so importing it eagerly
|
|
45
|
+
* would pull pi into the harness-neutral MCP bridge's boot closure. This wrapper
|
|
46
|
+
* defers that to a lazy `await import()` reached ONLY on the owned-outcome resume
|
|
47
|
+
* branch (the decider awaits it). peers/self/list/mailbox-deliver therefore boot
|
|
48
|
+
* with no pi package present; a pi-less environment that DOES hit a spawn-bg resume
|
|
49
|
+
* surfaces an honest module-not-found at that point rather than failing boot.
|
|
50
|
+
*/
|
|
51
|
+
async function lazyProductionPreflight(input) {
|
|
52
|
+
const { preflight } = await import("./entwurf-preflight.js");
|
|
53
|
+
return preflight(input);
|
|
54
|
+
}
|
|
55
|
+
/** Map a record-side socket inspection to the singleton (socketGids, symlinkedGids) the
|
|
56
|
+
* `isNonPiGardenIdSocketConflict` predicate consumes. `indeterminate` fails LOUD (QB2): an
|
|
57
|
+
* unprovable conflict must NOT be folded to "no conflict" — that would silently allow an
|
|
58
|
+
* unsupported-backend mailbox send onto a quarantined address. */
|
|
59
|
+
function conflictSetsFor(gid, inspection) {
|
|
60
|
+
switch (inspection.kind) {
|
|
61
|
+
case "absent":
|
|
62
|
+
return { socketGids: new Set(), symlinkedGids: new Set() };
|
|
63
|
+
case "socket-file":
|
|
64
|
+
return { socketGids: new Set([gid]), symlinkedGids: new Set() };
|
|
65
|
+
case "address-conflict":
|
|
66
|
+
// symlink → the symlink axis; not-socket → a non-symlink `.sock` entry, same axis
|
|
67
|
+
// the fact-provider's `socketGids` carries (NOT only real sockets).
|
|
68
|
+
return inspection.reason === "symlink"
|
|
69
|
+
? { socketGids: new Set(), symlinkedGids: new Set([gid]) }
|
|
70
|
+
: { socketGids: new Set([gid]), symlinkedGids: new Set() };
|
|
71
|
+
case "indeterminate":
|
|
72
|
+
throw new Error(`entwurf-v2-production: cannot resolve target ${gid} — its control socket lstat is indeterminate (${inspection.error}); refusing to claim "no address conflict".`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the production `runEntwurfV2` deps. See the module header for the three wiring
|
|
77
|
+
* invariants. The returned `{ decide, executor }` is exactly the shape `runEntwurfV2` joins.
|
|
78
|
+
*/
|
|
79
|
+
export function makeProductionEntwurfV2Deps(opts) {
|
|
80
|
+
// ── ONE path set (Q5): resolve every dir ONCE ─────────────────────────────
|
|
81
|
+
const lockDir = opts.lockDir ?? ENTWURF_V2_LOCK_DIR;
|
|
82
|
+
const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
|
|
83
|
+
const mailboxDir = opts.mailboxDir ?? defaultMetaMailboxDir();
|
|
84
|
+
const controlSocketDir = opts.controlSocketDir ?? CONTROL_SOCKET_DIR;
|
|
85
|
+
const s = opts.seams ?? {};
|
|
86
|
+
const io = {
|
|
87
|
+
metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
|
|
88
|
+
readIdentity: s.readIdentity ?? readMetaIdentityByGardenId,
|
|
89
|
+
readReceiverMarker: s.readReceiverMarker ?? ((gid) => readMetaReceiverMarker({ gardenId: gid })),
|
|
90
|
+
inspectPath: s.inspectPath ?? inspectControlSocketPath,
|
|
91
|
+
acquireLock: s.acquireLock ?? realAcquireLock,
|
|
92
|
+
releaseLock: s.releaseLock ?? realReleaseLock,
|
|
93
|
+
inspectSocket: s.inspectSocket ?? inspectTargetControlSocket,
|
|
94
|
+
probeSocket: s.probeSocket ?? probeSocketLiveness,
|
|
95
|
+
preflight: s.preflight ?? lazyProductionPreflight,
|
|
96
|
+
classifyConnect: s.classifyConnect ?? classifyConnectError,
|
|
97
|
+
sendRpc: s.sendRpc ?? realSendRpc,
|
|
98
|
+
enqueue: s.enqueue ?? enqueueMetaMessage,
|
|
99
|
+
spawnOverrides: s.spawnOverrides ?? {},
|
|
100
|
+
};
|
|
101
|
+
// ── ONE lock domain (Q2/QB3): a single lockDir-bound release for ALL hands ─
|
|
102
|
+
const acquire = (gid) => io.acquireLock(gid, { dir: lockDir });
|
|
103
|
+
const release = (claim) => {
|
|
104
|
+
io.releaseLock(claim, { dir: lockDir });
|
|
105
|
+
};
|
|
106
|
+
// ── ONE mailbox sender (Q3): one instance for the hand AND the dead-fallback ─
|
|
107
|
+
const sendViaMailbox = makeProductionSendViaMailbox({
|
|
108
|
+
senderProvider: opts.senderProvider,
|
|
109
|
+
enqueue: io.enqueue,
|
|
110
|
+
});
|
|
111
|
+
// ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
|
|
112
|
+
// receiver (a presence marker that matches THIS identity). The SAME closure is injected
|
|
113
|
+
// into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
|
|
114
|
+
// send can never drift to different deliverability verdicts. recordBacked is true by
|
|
115
|
+
// construction — resolveTarget already proved the record exists before any unsupported-
|
|
116
|
+
// backend mailbox route, and the closure is only consulted on that route. A null /
|
|
117
|
+
// dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
|
|
118
|
+
// terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
|
|
119
|
+
const mailboxDeliverabilityFor = (identity) => {
|
|
120
|
+
const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
|
|
121
|
+
const marker = io.readReceiverMarker(identity.gardenId);
|
|
122
|
+
const matched = receiverMarkerMatchesIdentity(marker, identity);
|
|
123
|
+
return mailboxConversationalDeliverable({
|
|
124
|
+
wakeMode,
|
|
125
|
+
recordBacked: true,
|
|
126
|
+
ownerAlive: matched,
|
|
127
|
+
watchArmed: matched,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
// ── target resolution (QB1 + QB2) ─────────────────────────────────────────
|
|
131
|
+
const resolveTarget = async (gid) => {
|
|
132
|
+
// MISSING record → not a citizen. But a record-LESS, gid-shaped, NON-SYMLINK control
|
|
133
|
+
// socket means the gid is still an addressable socket-only pi endpoint (A1 narrow,
|
|
134
|
+
// 0.11.0): entwurf_v2 accepts it as a FIRE-AND-FORGET control-send target only. This is
|
|
135
|
+
// PROBE-FREE — a single `inspectPath` lstat (NO connect), the SAME seam the pre-probe
|
|
136
|
+
// conflict uses — and `isSocketOnlyPiCandidate` promotes ONLY a confirmed non-symlink
|
|
137
|
+
// socket (`socket-file`); symlink/absent/not-socket/indeterminate stay a plain
|
|
138
|
+
// bad-target. PRESENT record → read (drift/corrupt throws = fail-loud).
|
|
139
|
+
if (!io.metaRecordExists(gid, sessionsDir)) {
|
|
140
|
+
const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
|
|
141
|
+
return { identity: null, preProbeAddressConflict: false, socketOnlyPi: isSocketOnlyPiCandidate(inspection) };
|
|
142
|
+
}
|
|
143
|
+
const identity = io.readIdentity(gid, sessionsDir);
|
|
144
|
+
// `preProbeAddressConflict` is the record-side NON-PI conflict ONLY (B1). An in-domain
|
|
145
|
+
// (pi) target's socket lstat/connect MUST run UNDER the lock in the decider's later
|
|
146
|
+
// `inspectSocket` step (1C: lock BEFORE lstat/connect) — so a pi target short-circuits
|
|
147
|
+
// here with NO pre-lock lstat. Doing the lstat here would (a) be a needless pre-lock IO
|
|
148
|
+
// and (b) turn a pi target's `indeterminate` lstat into a top-level throw, stealing it
|
|
149
|
+
// from the under-lock `inspectSocket → indeterminate → indeterminate-no-spawn` path.
|
|
150
|
+
if (isLivenessSupported(identity.backend)) {
|
|
151
|
+
return { identity, preProbeAddressConflict: false };
|
|
152
|
+
}
|
|
153
|
+
// Only an unsupported (non-pi) citizen reaches the record-side lstat: a single lstat (no
|
|
154
|
+
// connect) of the canonical path; `indeterminate` fails loud (QB2 — never "no conflict").
|
|
155
|
+
const inspection = await io.inspectPath(controlSocketPath(gid, controlSocketDir));
|
|
156
|
+
const { socketGids, symlinkedGids } = conflictSetsFor(gid, inspection);
|
|
157
|
+
const preProbeAddressConflict = isNonPiGardenIdSocketConflict(identity.backend, gid, socketGids, symlinkedGids);
|
|
158
|
+
return { identity, preProbeAddressConflict };
|
|
159
|
+
};
|
|
160
|
+
const inspectSocket = (gid) => io.inspectSocket(gid, controlSocketDir);
|
|
161
|
+
const probeSocket = (socketPath) => io.probeSocket(socketPath);
|
|
162
|
+
// ── decider deps (5b) ─────────────────────────────────────────────────────
|
|
163
|
+
const deciderDeps = {
|
|
164
|
+
resolveTarget,
|
|
165
|
+
acquireLock: acquire,
|
|
166
|
+
releaseLock: release,
|
|
167
|
+
inspectSocket,
|
|
168
|
+
probeSocket,
|
|
169
|
+
preflightForCwd: (cwd) => io.preflight({ cwd, agentDir: opts.agentDir, prefixRoots: opts.prefixRoots }),
|
|
170
|
+
mailboxDeliverabilityFor,
|
|
171
|
+
mailboxDir,
|
|
172
|
+
sessionsDir,
|
|
173
|
+
observeTimeoutMs: opts.observeTimeoutMs,
|
|
174
|
+
};
|
|
175
|
+
// ── control-send hand deps (5c-2): the dead-fallback shares resolveTarget /
|
|
176
|
+
// inspect / probe / dirs with the decider; the mailbox enqueue is the SAME
|
|
177
|
+
// `sendViaMailbox` instance; the release is the SAME `release` closure. ─────
|
|
178
|
+
const controlSendDeps = {
|
|
179
|
+
sendOverSocket: async (plan) => {
|
|
180
|
+
const { response } = await io.sendRpc(plan.socketPath, {
|
|
181
|
+
type: "send",
|
|
182
|
+
message: plan.message,
|
|
183
|
+
mode: plan.mode,
|
|
184
|
+
wants_reply: plan.wantsReply,
|
|
185
|
+
sender: opts.senderProvider(),
|
|
186
|
+
});
|
|
187
|
+
return { success: response.success, error: response.error };
|
|
188
|
+
},
|
|
189
|
+
classifyConnect: io.classifyConnect,
|
|
190
|
+
releaseLock: release,
|
|
191
|
+
deadFallback: (plan, lock) => resolveDeadControlSendFallback(plan, lock, {
|
|
192
|
+
resolveTarget,
|
|
193
|
+
inspectSocket,
|
|
194
|
+
probeSocket,
|
|
195
|
+
mailboxDeliverabilityFor,
|
|
196
|
+
mailboxDir,
|
|
197
|
+
sessionsDir,
|
|
198
|
+
}),
|
|
199
|
+
sendViaMailbox,
|
|
200
|
+
};
|
|
201
|
+
// ── executor: the three transport hands, each pre-bound ───────────────────
|
|
202
|
+
const executor = {
|
|
203
|
+
sendControl: (plan, lock) => executeControlSocketSend(plan, lock, controlSendDeps),
|
|
204
|
+
resumeSpawnBg: (plan, lock) => executeSpawnBgResume(plan, lock,
|
|
205
|
+
// QB3: inject the shared lockDir-bound `release` — never the spawn factory's
|
|
206
|
+
// default releaseFn (which would release into the DEFAULT lock dir).
|
|
207
|
+
makeProductionSpawnBgResumeDeps({
|
|
208
|
+
...io.spawnOverrides,
|
|
209
|
+
killGraceMs: opts.killGraceMs ?? io.spawnOverrides.killGraceMs,
|
|
210
|
+
releaseFn: release,
|
|
211
|
+
})),
|
|
212
|
+
sendMailbox: (plan, _lock) => sendViaMailbox(plan, _lock),
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
decide: (input) => decideDispatch(input, deciderDeps),
|
|
216
|
+
executor,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-release — the PURE release-policy reducer for the 5c transport hand
|
|
3
|
+
* (0.11 Stage 0 step 5c-1). It answers ONE question with zero IO: given the plan
|
|
4
|
+
* the 5b decider chose and the lock it handed over, on WHICH execution event may
|
|
5
|
+
* the transport hand release that lock — and it guarantees the release fires
|
|
6
|
+
* AT MOST ONCE.
|
|
7
|
+
*
|
|
8
|
+
* Why a pure reducer before any transport IO (5b discipline = gate-first →
|
|
9
|
+
* pure-before-IO → wire): the load-bearing risk of 5c is NOT the spawn/send IO, it
|
|
10
|
+
* is the RELEASE TIMING (Fable 3, "release-after-observation"). A spawn-bg dispatch
|
|
11
|
+
* that releases its lock too early — on spawn-started/ack instead of on an observed
|
|
12
|
+
* liveness transition — reopens the exact double-spawn window 5a's per-gid lock
|
|
13
|
+
* exists to close. So the timing logic is isolated here as a pure state machine the
|
|
14
|
+
* gate drives over every event order, and 5c-2/5c-3/5c-4 only WIRE the real
|
|
15
|
+
* send/spawn/mailbox IO onto it. The hand feeds events; this module decides release.
|
|
16
|
+
*
|
|
17
|
+
* The three policies (one per ExecutionPlan transport):
|
|
18
|
+
* - no-lock — meta-mailbox (?7: no lock was ever held).
|
|
19
|
+
* Nothing to release on any event.
|
|
20
|
+
* - release-after-send-final — control-socket send. The lock is held only for
|
|
21
|
+
* the at-most-once re-resolve; release once the send reaches a FINAL outcome.
|
|
22
|
+
* - release-after-spawn-observation — spawn-bg resume. The lock is held until the
|
|
23
|
+
* FIRST observable transition: socket-alive OR child-exited (any code). A
|
|
24
|
+
* spawn-started event is explicitly NOT a release trigger (that is the whole
|
|
25
|
+
* point). A spawn that never started (spawn-start-failed) has no child to watch,
|
|
26
|
+
* so it releases to free the gid — keeping the lock would pin it forever.
|
|
27
|
+
*
|
|
28
|
+
* The release authority is ALWAYS the LockClaim the decider handed over (5c wires it
|
|
29
|
+
* through). This module never re-derives a lock by gid, never re-resolves a path —
|
|
30
|
+
* it is pure routing logic over opaque events.
|
|
31
|
+
*/
|
|
32
|
+
export function initialReleaseState() {
|
|
33
|
+
return { released: false };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An in-domain execute (control-socket / spawn-bg) must hold a lock whose gardenId
|
|
37
|
+
* IS the plan's target. Both halves are 5b decider-contract invariants, not runtime
|
|
38
|
+
* conditions: a null lock or a lock paired with the WRONG plan are the same grade of
|
|
39
|
+
* mis-wiring (a later release would free nothing, or free a DIFFERENT gid's lock), so
|
|
40
|
+
* both fail loud here rather than silently mis-releasing. Asserts the lock non-null
|
|
41
|
+
* so the caller may use it as a `LockClaim`.
|
|
42
|
+
*/
|
|
43
|
+
function assertInDomainLock(plan, lock) {
|
|
44
|
+
if (lock === null) {
|
|
45
|
+
throw new Error(`entwurf-v2-release: an in-domain (${plan.transport}) execute must hold a lock.`);
|
|
46
|
+
}
|
|
47
|
+
if (lock.gardenId !== plan.targetGardenId) {
|
|
48
|
+
throw new Error(`entwurf-v2-release: lock gardenId (${lock.gardenId}) does not match plan target (${plan.targetGardenId}) — mis-paired plan/lock.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Derive the release policy from the plan, cross-checking the lock invariants the 5b
|
|
53
|
+
* decider guarantees: meta-mailbox ⇒ lock null (?7); in-domain (control-socket /
|
|
54
|
+
* spawn-bg) ⇒ lock non-null AND lock.gardenId === plan.targetGardenId. A mismatch is
|
|
55
|
+
* a decider contract violation, not a runtime condition — it throws so a mis-wired
|
|
56
|
+
* hand fails loud instead of silently dropping or mis-releasing a lock.
|
|
57
|
+
*/
|
|
58
|
+
export function decideReleasePolicy(plan, lock) {
|
|
59
|
+
switch (plan.transport) {
|
|
60
|
+
case "meta-mailbox":
|
|
61
|
+
if (lock !== null) {
|
|
62
|
+
throw new Error("entwurf-v2-release: a meta-mailbox plan must carry no lock (?7 invariant violated).");
|
|
63
|
+
}
|
|
64
|
+
return { kind: "no-lock" };
|
|
65
|
+
case "control-socket":
|
|
66
|
+
assertInDomainLock(plan, lock);
|
|
67
|
+
return { kind: "release-after-send-final" };
|
|
68
|
+
case "spawn-bg":
|
|
69
|
+
assertInDomainLock(plan, lock);
|
|
70
|
+
return { kind: "release-after-spawn-observation" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Fold one execution event into the release decision. `shouldRelease` is true
|
|
75
|
+
* EXACTLY ONCE — on the first event that satisfies the policy's release condition;
|
|
76
|
+
* every later event (after `state.released`) returns false. The single-release
|
|
77
|
+
* guarantee is what lets the spawn watcher race socket-alive against child-exit
|
|
78
|
+
* without a double release: whichever fires first releases, the other is a no-op.
|
|
79
|
+
*/
|
|
80
|
+
export function reduceRelease(policy, state, event) {
|
|
81
|
+
if (state.released) {
|
|
82
|
+
return { state, shouldRelease: false };
|
|
83
|
+
}
|
|
84
|
+
const release = () => ({ state: { released: true }, shouldRelease: true });
|
|
85
|
+
const hold = () => ({ state, shouldRelease: false });
|
|
86
|
+
switch (policy.kind) {
|
|
87
|
+
case "no-lock":
|
|
88
|
+
// No lock was ever held → nothing to release on any event.
|
|
89
|
+
return hold();
|
|
90
|
+
case "release-after-send-final":
|
|
91
|
+
// Release once the send reaches a final outcome; hold before that.
|
|
92
|
+
return event.kind === "send-final" ? release() : hold();
|
|
93
|
+
case "release-after-spawn-observation":
|
|
94
|
+
// Fable 3: spawn-started is NOT a release event. Release on the first
|
|
95
|
+
// observed transition (socket-alive / child-exited, any code), or on a
|
|
96
|
+
// failed start (no child to watch).
|
|
97
|
+
switch (event.kind) {
|
|
98
|
+
case "socket-alive":
|
|
99
|
+
case "child-exited":
|
|
100
|
+
case "spawn-start-failed":
|
|
101
|
+
return release();
|
|
102
|
+
case "spawn-started":
|
|
103
|
+
case "send-final":
|
|
104
|
+
case "mailbox-enqueued":
|
|
105
|
+
return hold();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-resume-marker — the single source of truth for the env var name that a v2
|
|
3
|
+
* spawn-bg resume plants on its resume child to authorize it as an Entwurf-child resident.
|
|
4
|
+
*
|
|
5
|
+
* Why its own leaf module (no imports): the PRODUCER is `entwurf-v2-spawn-production.ts` (a
|
|
6
|
+
* lib module that imports siblings with `.ts` extensions, compiled by `scripts/tsconfig.json`
|
|
7
|
+
* with `allowImportingTsExtensions`), but the CONSUMER is `entwurf-control.ts` (the extension
|
|
8
|
+
* entry, compiled by the ROOT tsconfig WITHOUT that flag, importing siblings as `.js`).
|
|
9
|
+
* Importing the const straight from `entwurf-v2-spawn-production.ts` into `entwurf-control.ts`
|
|
10
|
+
* would drag the whole `.ts`-importing v2 decider/spawn subtree into the root program and
|
|
11
|
+
* trip TS5097 on every `.ts` extension. A zero-import leaf const is root-safe from either side.
|
|
12
|
+
*
|
|
13
|
+
* Meaning: when this env var equals the child's own sessionId, the child is the resume citizen
|
|
14
|
+
* a v2 spawn-bg resume promoted from a dormant `entwurf`-tagged session to a live
|
|
15
|
+
* `--entwurf-control` resident (an AUTHORIZED Entwurf child resident — keeps its `entwurf` tag,
|
|
16
|
+
* stays re-resumable once it dies). A human hand-opening the same session with
|
|
17
|
+
* `--entwurf-control` carries no marker → still a "corrupt resident session name" crash. The
|
|
18
|
+
* marker is sessionId-bound (not a generic boolean) so it authorizes only the exact session it
|
|
19
|
+
* was minted for; it is a wrong-surface crash guard, not a security boundary. See
|
|
20
|
+
* `entwurf-control.ts` `maybeSetResidentName` (consumer) and `entwurf-v2-spawn-production.ts`
|
|
21
|
+
* `makeProductionSpawnBgResumeDeps` (producer).
|
|
22
|
+
*/
|
|
23
|
+
export const V2_RESUME_RESIDENT_SESSION_ENV = "ENTWURF_V2_RESUME_RESIDENT_SESSION_ID";
|
|
24
|
+
/**
|
|
25
|
+
* True when this process is the resume child a v2 spawn-bg resume launched for EXACTLY
|
|
26
|
+
* `sessionId` — the marker equals the child's own id. The pure SSOT of the authorization
|
|
27
|
+
* check (`env` is injectable so the guard is gate-provable without touching `process.env`).
|
|
28
|
+
* The binding is to the exact id, not a generic boolean: a present-but-different marker is
|
|
29
|
+
* NOT authorized, so the env var cannot be reused to wave a different session past the guard.
|
|
30
|
+
*/
|
|
31
|
+
export function isV2ResumeResidentAuthorized(sessionId, env = process.env) {
|
|
32
|
+
return env[V2_RESUME_RESIDENT_SESSION_ENV] === sessionId;
|
|
33
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
import { SendDeliveredReleaseFailedError, } from "./entwurf-v2-send.js";
|
|
32
|
+
function errorMessage(err) {
|
|
33
|
+
return err instanceof Error ? err.message : String(err);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Route an already-decided `DispatchDecision` to its transport hand. The decider owns the
|
|
37
|
+
* routing; this owns ONLY the dispatch + the outcome→result mapping (incl. the N1/N3
|
|
38
|
+
* carry-overs). A `reject` runs no hand. An `execute` switches on `plan.transport`.
|
|
39
|
+
*/
|
|
40
|
+
export async function executeDispatch(decision, deps) {
|
|
41
|
+
if (decision.kind === "reject") {
|
|
42
|
+
return { kind: "rejected", receipt: decision.receipt, diagnostic: decision.diagnostic };
|
|
43
|
+
}
|
|
44
|
+
const { receipt, plan, lock } = decision;
|
|
45
|
+
const transport = plan.transport;
|
|
46
|
+
switch (plan.transport) {
|
|
47
|
+
case "control-socket": {
|
|
48
|
+
try {
|
|
49
|
+
const r = await deps.sendControl(plan, lock);
|
|
50
|
+
return {
|
|
51
|
+
kind: "executed",
|
|
52
|
+
receipt,
|
|
53
|
+
transport,
|
|
54
|
+
outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// N1: a delivered/refused send whose release then threw — lock dirty, do NOT retry.
|
|
59
|
+
if (err instanceof SendDeliveredReleaseFailedError) {
|
|
60
|
+
return {
|
|
61
|
+
kind: "execution-failed",
|
|
62
|
+
receipt,
|
|
63
|
+
transport,
|
|
64
|
+
error: errorMessage(err),
|
|
65
|
+
finalizedOutcome: err.finalizedOutcome,
|
|
66
|
+
releaseFailed: true,
|
|
67
|
+
retrySafe: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// A `failed` send rethrows its original transport error (lock already released).
|
|
71
|
+
return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
case "spawn-bg": {
|
|
75
|
+
try {
|
|
76
|
+
// `lock-retained` is a RETURNED result (fail-closed), not a throw — it rides `executed`.
|
|
77
|
+
const result = await deps.resumeSpawnBg(plan, lock);
|
|
78
|
+
return { kind: "executed", receipt, transport, outcome: { transport: "spawn-bg", result } };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
case "meta-mailbox": {
|
|
85
|
+
try {
|
|
86
|
+
// lock is null here (?7) — passed verbatim; the production adapter ignores it.
|
|
87
|
+
const r = await deps.sendMailbox(plan, lock);
|
|
88
|
+
// 5c-4 contract: a mailbox enqueue is `{success:true}` OR a throw — there is NO
|
|
89
|
+
// in-band reject (no live receiver to refuse). A `success:false` is therefore a
|
|
90
|
+
// CONTRACT VIOLATION, not a soft failure: fail loud rather than silently render it
|
|
91
|
+
// as a success ("Never warn. Throw."). The catch below folds it to execution-failed.
|
|
92
|
+
if (r.success !== true) {
|
|
93
|
+
throw new Error("entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).");
|
|
94
|
+
}
|
|
95
|
+
return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The top-level `entwurf_v2` runner: decide → execute, joined. It runs the injected
|
|
105
|
+
* decider to a `DispatchDecision`, then routes that decision through `executeDispatch`.
|
|
106
|
+
* That is the WHOLE body — there is no extra branching, lock re-judgement, or transport
|
|
107
|
+
* decision here (the decider owns routing, `executeDispatch` owns dispatch + the N1/N3
|
|
108
|
+
* result mapping). A `decide` THROW PROPAGATES untouched: a decision was never produced,
|
|
109
|
+
* so there is no receipt to wrap and no `EntwurfV2RunResult` to honestly return — the 5d-3
|
|
110
|
+
* surface top-level catch renders it. A reject/execute decision flows straight into
|
|
111
|
+
* `executeDispatch`, whose `EntwurfV2RunResult` is returned verbatim.
|
|
112
|
+
*/
|
|
113
|
+
export async function runEntwurfV2(input, deps) {
|
|
114
|
+
const decision = await deps.decide(input);
|
|
115
|
+
return executeDispatch(decision, deps.executor);
|
|
116
|
+
}
|