@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,125 @@
|
|
|
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
|
+
import { isLivenessSupported, resolveDispatch } from "./entwurf-v2-contract.js";
|
|
34
|
+
import { defaultMetaMailboxDir, defaultMetaSessionsDir } from "./meta-session.js";
|
|
35
|
+
import { mapInspectionToLiveness } from "./socket-discovery.js";
|
|
36
|
+
/**
|
|
37
|
+
* Re-resolve a dead control-socket send ONCE under the held lock. Returns a plan to
|
|
38
|
+
* execute (control-socket retry on a re-observed-alive target, or a meta-mailbox enqueue
|
|
39
|
+
* on a deliverable unsupported citizen) or a reject (dead/indeterminate pi, undeliverable
|
|
40
|
+
* citizen, bad target, address conflict). The returned plan ALWAYS targets the same gid
|
|
41
|
+
* the lock is held for. Inspect/probe throws are left to PROPAGATE — the 5c-2a hand's
|
|
42
|
+
* lock-leak backstop converts them to failed+release; this resolver never releases.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveDeadControlSendFallback(plan, lock, deps) {
|
|
45
|
+
// Mis-wire fail-loud, same grade as the 5c-2a hand: a plan/lock for different gids
|
|
46
|
+
// would re-resolve B while holding A's lock. (The hand also asserts this on the
|
|
47
|
+
// returned plan; asserting on the INPUT here fails even earlier, before any IO.)
|
|
48
|
+
if (plan.targetGardenId !== lock.gardenId) {
|
|
49
|
+
throw new Error(`entwurf-v2-send-fallback: plan target (${plan.targetGardenId}) does not match held lock (${lock.gardenId}) — mis-paired.`);
|
|
50
|
+
}
|
|
51
|
+
const gardenId = plan.targetGardenId;
|
|
52
|
+
const mailboxDir = deps.mailboxDir ?? defaultMetaMailboxDir();
|
|
53
|
+
const sessionsDir = deps.sessionsDir ?? defaultMetaSessionsDir();
|
|
54
|
+
// Probe-free target resolution first — a vanished target or a quarantined address
|
|
55
|
+
// short-circuits before any inspect/probe (mirrors decideDispatch steps 2). A1 narrow:
|
|
56
|
+
// a record-LESS but live pi control socket (socketOnlyPi, identity null) is NOT a
|
|
57
|
+
// bad-target — it is an in-domain pi endpoint that re-resolves through the inspect/probe
|
|
58
|
+
// path below (fire-and-forget: alive → retry control-send, dead → honest reject). Only a
|
|
59
|
+
// genuinely absent target (identity null AND not socket-only) is bad-target.
|
|
60
|
+
const resolution = await deps.resolveTarget(gardenId);
|
|
61
|
+
if (resolution.identity === null && resolution.socketOnlyPi !== true) {
|
|
62
|
+
return { kind: "reject", reason: "bad-target" };
|
|
63
|
+
}
|
|
64
|
+
if (resolution.preProbeAddressConflict) {
|
|
65
|
+
return { kind: "reject", reason: "target-address-conflict" };
|
|
66
|
+
}
|
|
67
|
+
const identity = resolution.identity;
|
|
68
|
+
// Unsupported backend (claude-code self-fetch, …) → the mailbox mini-table, keyed on
|
|
69
|
+
// intent alone. This is NOT an in-domain dormant (the N2 asymmetry) — a deliverable
|
|
70
|
+
// citizen's honest channel is its mailbox. No inspect/probe on this axis. A socket-only
|
|
71
|
+
// pi endpoint (identity null) is in-domain pi, so it NEVER takes this branch — only a
|
|
72
|
+
// record-backed unsupported identity does.
|
|
73
|
+
if (identity !== null && !isLivenessSupported(identity.backend)) {
|
|
74
|
+
const deliverability = await deps.mailboxDeliverabilityFor(identity);
|
|
75
|
+
const receipt = resolveDispatch("fire-and-forget", "unsupported", deliverability.deliverable);
|
|
76
|
+
if (!receipt.ok) {
|
|
77
|
+
return { kind: "reject", reason: receipt.reason };
|
|
78
|
+
}
|
|
79
|
+
// The only allow cell here is fire-and-forget → meta-mailbox send. Preserve the
|
|
80
|
+
// original message/wantsReply; mode is meaningless for the mailbox (not carried).
|
|
81
|
+
return {
|
|
82
|
+
kind: "execute",
|
|
83
|
+
plan: {
|
|
84
|
+
transport: "meta-mailbox",
|
|
85
|
+
action: "send",
|
|
86
|
+
targetGardenId: gardenId,
|
|
87
|
+
mailboxDir,
|
|
88
|
+
sessionsDir,
|
|
89
|
+
wantsReply: plan.wantsReply,
|
|
90
|
+
message: plan.message,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// In-domain (pi-like) → inspect + probe the canonical path UNDER the held lock. A
|
|
95
|
+
// re-observed `dead`/`indeterminate` is an honest reject (never forced to one or the
|
|
96
|
+
// other); `address-conflict` is a reject too (a symlink planted since dispatch).
|
|
97
|
+
const inspection = await deps.inspectSocket(gardenId);
|
|
98
|
+
const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
99
|
+
if ("addressConflict" in mapped) {
|
|
100
|
+
return { kind: "reject", reason: "target-address-conflict" };
|
|
101
|
+
}
|
|
102
|
+
const { liveness, socketPath } = mapped;
|
|
103
|
+
const receipt = resolveDispatch("fire-and-forget", liveness, false);
|
|
104
|
+
if (!receipt.ok) {
|
|
105
|
+
return { kind: "reject", reason: receipt.reason };
|
|
106
|
+
}
|
|
107
|
+
// fire-and-forget + live is the ONLY in-domain allow cell → control-socket send. A
|
|
108
|
+
// resume/spawn-bg or meta-mailbox transport here would be a frozen-table drift, so
|
|
109
|
+
// fail loud rather than mis-route a SEND fallback into a child spawn.
|
|
110
|
+
if (receipt.transport !== "control-socket") {
|
|
111
|
+
throw new Error(`entwurf-v2-send-fallback: in-domain fire-and-forget re-resolve yielded unexpected transport (${receipt.transport}) — table drift.`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
kind: "execute",
|
|
115
|
+
plan: {
|
|
116
|
+
transport: "control-socket",
|
|
117
|
+
action: "send",
|
|
118
|
+
targetGardenId: gardenId,
|
|
119
|
+
socketPath,
|
|
120
|
+
mode: plan.mode,
|
|
121
|
+
wantsReply: plan.wantsReply,
|
|
122
|
+
message: plan.message,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
|
|
35
|
+
/**
|
|
36
|
+
* Execute a control-socket send to a final outcome and release the held lock exactly
|
|
37
|
+
* once. `lock` MUST be the in-domain claim the decider handed over —
|
|
38
|
+
* `decideReleasePolicy` throws if it is null or paired with the wrong gid (a mis-wire
|
|
39
|
+
* is fail-loud, not a runtime branch). On a `failed` outcome the original transport
|
|
40
|
+
* error is rethrown AFTER the (single) release.
|
|
41
|
+
*/
|
|
42
|
+
export async function executeControlSocketSend(plan, lock, deps) {
|
|
43
|
+
// Throws on a null / mis-paired lock (?7 + gid invariants). After this line the
|
|
44
|
+
// control-socket policy is release-after-send-final and the lock is non-null.
|
|
45
|
+
const policy = decideReleasePolicy(plan, lock);
|
|
46
|
+
const held = lock;
|
|
47
|
+
// Lock-leak backstop: once the lock is held, ANY throw out of the drive — a
|
|
48
|
+
// contract-violation guard (spawn-bg re-resolve), the mis-route assert, even a buggy
|
|
49
|
+
// dep that throws where it should return — must still release the lock before it
|
|
50
|
+
// propagates. A leaked lock pins the gid forever (5a's worst failure). So convert any
|
|
51
|
+
// such throw into a `failed` final outcome, run finalizeRelease (which releases on
|
|
52
|
+
// the send-final event), and then rethrow the original error from there.
|
|
53
|
+
let drive;
|
|
54
|
+
try {
|
|
55
|
+
drive = await driveSend(plan, held, deps);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
drive = { outcome: "failed", error: err };
|
|
59
|
+
}
|
|
60
|
+
finalizeRelease(policy, deps, held, drive);
|
|
61
|
+
return { outcome: drive.outcome, rejectReason: drive.rejectReason };
|
|
62
|
+
}
|
|
63
|
+
/** Drive the 1차 send and route a connect failure through the F3 split. */
|
|
64
|
+
async function driveSend(plan, lock, deps) {
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = await deps.sendOverSocket(plan);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const liveness = deps.classifyConnect(err?.code);
|
|
71
|
+
if (liveness === "indeterminate") {
|
|
72
|
+
// Stall/unknown — the socket may be alive. A fallback here risks a DOUBLE
|
|
73
|
+
// delivery, so finalize as failed WITHOUT touching deadFallback / mailbox.
|
|
74
|
+
return { outcome: "failed", error: err };
|
|
75
|
+
}
|
|
76
|
+
// dead ⇒ proven non-delivery ⇒ same-lock one-shot re-resolve (lock still held).
|
|
77
|
+
return await driveDeadFallback(plan, lock, deps);
|
|
78
|
+
}
|
|
79
|
+
// A completed RPC: ack ⇒ sent; in-band refusal ⇒ rejected, NO fallback.
|
|
80
|
+
return { outcome: result.success ? "sent" : "rejected" };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The dead-path fallback: re-resolve ONCE under the held lock, then run the resolver's
|
|
84
|
+
* chosen plan exactly once (the retry is one-shot — a second connect failure does NOT
|
|
85
|
+
* re-enter the fallback, it finalizes as failed). The hand only executes; the resolver
|
|
86
|
+
* decided.
|
|
87
|
+
*/
|
|
88
|
+
async function driveDeadFallback(plan, lock, deps) {
|
|
89
|
+
let resolution;
|
|
90
|
+
try {
|
|
91
|
+
resolution = await deps.deadFallback(plan, lock);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return { outcome: "failed", error: err };
|
|
95
|
+
}
|
|
96
|
+
if (resolution.kind === "reject") {
|
|
97
|
+
// N3: carry the resolver's reason out so the runner/surface can distinguish a
|
|
98
|
+
// dormant-fire-forget / undeliverable / no-route reject from an in-band refusal.
|
|
99
|
+
return { outcome: "rejected", rejectReason: resolution.reason };
|
|
100
|
+
}
|
|
101
|
+
const rePlan = resolution.plan;
|
|
102
|
+
// Same-lock re-resolve invariant: the fallback plan MUST target the SAME gid the lock
|
|
103
|
+
// is held for. A resolver that returns a DIFFERENT target's plan would send/enqueue to
|
|
104
|
+
// B while only A's lock is ever released — a mis-route AND a lock leak. Fail loud (the
|
|
105
|
+
// execute-level catch in executeControlSocketSend releases A's lock before rethrowing).
|
|
106
|
+
if (rePlan.targetGardenId !== plan.targetGardenId || rePlan.targetGardenId !== lock.gardenId) {
|
|
107
|
+
throw new Error(`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.`);
|
|
108
|
+
}
|
|
109
|
+
switch (rePlan.transport) {
|
|
110
|
+
case "control-socket":
|
|
111
|
+
// Re-resolve picked another live socket — retry the send ONCE. A second
|
|
112
|
+
// connect failure finalizes as failed (no further fallback).
|
|
113
|
+
try {
|
|
114
|
+
const r = await deps.sendOverSocket(rePlan);
|
|
115
|
+
return { outcome: r.success ? "fallback-sent" : "rejected" };
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return { outcome: "failed", error: err };
|
|
119
|
+
}
|
|
120
|
+
case "meta-mailbox":
|
|
121
|
+
// Re-resolve picked the mailbox — enqueue ONCE via the injected helper. The
|
|
122
|
+
// hand never reaches for the mailbox on its own; only the resolver routes here.
|
|
123
|
+
try {
|
|
124
|
+
const r = await deps.sendViaMailbox(rePlan, lock);
|
|
125
|
+
return { outcome: r.success ? "fallback-sent" : "rejected" };
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return { outcome: "failed", error: err };
|
|
129
|
+
}
|
|
130
|
+
case "spawn-bg":
|
|
131
|
+
// A SEND fallback must never re-resolve into a spawn — that is a decider
|
|
132
|
+
// contract violation (a send and a spawn are different actions), so fail loud
|
|
133
|
+
// rather than silently mis-execute.
|
|
134
|
+
throw new Error("entwurf-v2-send: re-resolve returned a spawn-bg plan for a send fallback (contract violation).");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* N1: a release failure AFTER a non-`failed` final outcome. The send already reached a
|
|
139
|
+
* terminal result (`sent` / `fallback-sent` / `rejected`) — the delivery (or in-band
|
|
140
|
+
* refusal) HAPPENED — but `releaseLock` then threw, so the lock is dirty. This is NOT a
|
|
141
|
+
* send failure, and the caller MUST NOT re-dispatch (a re-send would double-deliver). A
|
|
142
|
+
* structured error (not a bare rethrow) lets the 5d runner render "finalized + lock
|
|
143
|
+
* dirty, retry-unsafe" distinctly from "send failed". For a `failed` outcome the original
|
|
144
|
+
* send error still wins — that path never builds this error.
|
|
145
|
+
*/
|
|
146
|
+
export class SendDeliveredReleaseFailedError extends Error {
|
|
147
|
+
finalizedOutcome;
|
|
148
|
+
releaseError;
|
|
149
|
+
constructor(finalizedOutcome, releaseError) {
|
|
150
|
+
const detail = releaseError instanceof Error ? releaseError.message : String(releaseError);
|
|
151
|
+
super(`entwurf-v2-send: ${finalizedOutcome} delivered but releaseLock failed (lock dirty, do NOT re-send): ${detail}`);
|
|
152
|
+
this.name = "SendDeliveredReleaseFailedError";
|
|
153
|
+
this.finalizedOutcome = finalizedOutcome;
|
|
154
|
+
this.releaseError = releaseError;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Fold the single send-final event into the reducer and release the lock if (and only
|
|
159
|
+
* if) the reducer says so. On a `failed` outcome the lock is released FIRST and then
|
|
160
|
+
* the original error is rethrown — and if `releaseLock` itself throws, the original
|
|
161
|
+
* send error still wins (a release failure must not MASK the send failure; 5b). On a
|
|
162
|
+
* NON-`failed` outcome a release failure throws a `SendDeliveredReleaseFailedError` (N1):
|
|
163
|
+
* the delivery happened, so the runner must surface "finalized + lock dirty", not "failed".
|
|
164
|
+
*/
|
|
165
|
+
function finalizeRelease(policy, deps, lock, drive) {
|
|
166
|
+
const initial = initialReleaseState();
|
|
167
|
+
const { shouldRelease } = reduceRelease(policy, initial, { kind: "send-final", outcome: drive.outcome });
|
|
168
|
+
const original = drive.outcome === "failed" ? drive.error : undefined;
|
|
169
|
+
if (shouldRelease) {
|
|
170
|
+
try {
|
|
171
|
+
deps.releaseLock(lock);
|
|
172
|
+
}
|
|
173
|
+
catch (releaseErr) {
|
|
174
|
+
// A release failure must not swallow a real send failure.
|
|
175
|
+
if (drive.outcome === "failed")
|
|
176
|
+
throw original;
|
|
177
|
+
// N1: the delivery/refusal already happened — surface it as a structured,
|
|
178
|
+
// retry-unsafe error rather than a bare release throw.
|
|
179
|
+
throw new SendDeliveredReleaseFailedError(drive.outcome, releaseErr);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (drive.outcome === "failed")
|
|
183
|
+
throw original;
|
|
184
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-spawn-production — the PRODUCTION `SpawnBgResumeDeps` factory for the 5c-3a
|
|
3
|
+
* spawn-bg watcher (0.11 Stage 0 step 5c-3c). It wires the six injected IO seams the pure
|
|
4
|
+
* watcher (`executeSpawnBgResume`) declares onto the real world: spawn a resume child as a
|
|
5
|
+
* resident citizen, observe its control socket by CONNECTABILITY (not mere existence),
|
|
6
|
+
* watch its exit, time the observe window, and kill on timeout. The watcher's release
|
|
7
|
+
* policy is untouched — this module only supplies the IO.
|
|
8
|
+
*
|
|
9
|
+
* Every seam stays injectable (defaults = the real fns) so a DETERMINISTIC gate drives
|
|
10
|
+
* spawnChild's argv, the exit/timeout/kill wiring, and the socket-alive verdict WITHOUT a
|
|
11
|
+
* real pi spawn or socket — the heavy live path (a real `pi --entwurf-control` resident
|
|
12
|
+
* child + a real unix-socket connect) is proven once by a separate opt-in smoke
|
|
13
|
+
* (`smoke-entwurf-v2-spawn-live`), kept OUT of `pnpm check` so the every-commit loop stays
|
|
14
|
+
* fast and deterministic (D5: chain = deterministic gate; live smoke = phase gate before 5d).
|
|
15
|
+
*
|
|
16
|
+
* The factory does NOT capture `plan` or `lock` (D3): `SpawnBgResumeDeps.spawnChild(plan)`
|
|
17
|
+
* already takes the plan, and the lock is the watcher's authority — it flows to
|
|
18
|
+
* `deps.releaseLock` from the watcher, never from here. A captured plan/lock would be a
|
|
19
|
+
* second authority that could drift from the one the watcher holds.
|
|
20
|
+
*
|
|
21
|
+
* socket-alive = CONNECTABLE, never file-exists (GPT 5c-3c, Q5): inspect the EXACT
|
|
22
|
+
* `plan.expectedSocketPath` (via the path-addressed `inspectControlSocketPath` — no gid
|
|
23
|
+
* re-derivation), map to liveness through the shared `mapInspectionToLiveness`, then apply
|
|
24
|
+
* `socketWatchVerdict`: a connectable socket is alive; a forged address (symlink / not a
|
|
25
|
+
* socket) is rejected IMMEDIATELY (time does not heal a forged path → the watcher's backstop
|
|
26
|
+
* kills and fail-closes to a retained lock); a dead/indeterminate socket keeps waiting (the
|
|
27
|
+
* dormant citizen's stale socket file, or a stall, is "not up yet", not a failure).
|
|
28
|
+
*/
|
|
29
|
+
import { spawn } from "node:child_process";
|
|
30
|
+
import * as fs from "node:fs/promises";
|
|
31
|
+
import { findSessionFileById, getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity, } from "./entwurf-core.js";
|
|
32
|
+
import { buildResumePiArgs } from "./entwurf-resume-args.js";
|
|
33
|
+
import { releaseLock } from "./entwurf-v2-lock.js";
|
|
34
|
+
import { V2_RESUME_RESIDENT_SESSION_ENV } from "./entwurf-v2-resume-marker.js";
|
|
35
|
+
import { inspectControlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
|
|
36
|
+
import { probeSocketLiveness } from "./socket-probe.js";
|
|
37
|
+
/**
|
|
38
|
+
* Translate a mapped socket observation into the watcher's poll decision.
|
|
39
|
+
* - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
|
|
40
|
+
* state: time cannot turn a forged path into a live socket, so the poll must REJECT
|
|
41
|
+
* immediately (→ watcher backstop → kill → retained), never keep waiting.
|
|
42
|
+
* - alive — a connectable socket. Resolve: the resumed citizen is up.
|
|
43
|
+
* - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
|
|
44
|
+
* indeterminate (a stall). Keep polling until the socket connects, or the observe
|
|
45
|
+
* timeout / abort ends the wait.
|
|
46
|
+
*/
|
|
47
|
+
export function socketWatchVerdict(mapped) {
|
|
48
|
+
if ("addressConflict" in mapped)
|
|
49
|
+
return "forged";
|
|
50
|
+
return mapped.liveness === "alive" ? "alive" : "wait";
|
|
51
|
+
}
|
|
52
|
+
/** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
|
|
53
|
+
* NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
|
|
54
|
+
function requireProductionChild(child) {
|
|
55
|
+
const c = child;
|
|
56
|
+
if (!c.proc || !c.exitPromise) {
|
|
57
|
+
throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
|
|
58
|
+
}
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Resolve launch identity for a resume from the saved session — the same authority the
|
|
63
|
+
* legacy launcher uses (readSessionIdentity = first model_change; getEntwurfExplicitExtensions
|
|
64
|
+
* = bridge re-injection / #29 fail-fast), MINUS the legacy completion-delivery tail (that is
|
|
65
|
+
* 5d). Throws on anything that makes a resume impossible — no session file, no recorded
|
|
66
|
+
* model, an unresolvable ACP bridge, or no header cwd (NEVER falls back to process.cwd, #9).
|
|
67
|
+
* Each throw becomes the watcher's `spawn-start-failed` (no child to watch → release).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveResumeLaunchIdentity(plan) {
|
|
70
|
+
const sessionFile = findSessionFileById(plan.sessionId);
|
|
71
|
+
if (!sessionFile) {
|
|
72
|
+
throw new Error(`entwurf-v2-spawn-production: no saved session for ${plan.sessionId} — cannot resume.`);
|
|
73
|
+
}
|
|
74
|
+
const identity = readSessionIdentity(sessionFile, { requireEntwurf: true });
|
|
75
|
+
const resumeModel = identity?.modelId ?? null;
|
|
76
|
+
if (!identity || !resumeModel) {
|
|
77
|
+
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
|
|
78
|
+
}
|
|
79
|
+
const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
|
|
80
|
+
if (explicitExtensions.unresolvedAcpIntent) {
|
|
81
|
+
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
|
|
82
|
+
`extension could not be resolved — refusing to resume with an unknown provider (#29).`);
|
|
83
|
+
}
|
|
84
|
+
if (!identity.cwd) {
|
|
85
|
+
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
cwd: identity.cwd,
|
|
89
|
+
explicitExtensionArgs: explicitExtensions.args,
|
|
90
|
+
provider: explicitExtensions.provider ?? identity.provider,
|
|
91
|
+
model: explicitExtensions.modelOverride ?? resumeModel,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const DEFAULT_POLL_INTERVAL_MS = 150;
|
|
95
|
+
const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
96
|
+
/** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
|
|
97
|
+
* the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
|
|
98
|
+
* from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
|
|
99
|
+
function defaultSpawnChild(cmd, args, cwd, env) {
|
|
100
|
+
const proc = spawn(cmd, [...args], {
|
|
101
|
+
cwd,
|
|
102
|
+
shell: false,
|
|
103
|
+
detached: true,
|
|
104
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
105
|
+
env,
|
|
106
|
+
});
|
|
107
|
+
proc.unref();
|
|
108
|
+
mirrorChildStderr(proc);
|
|
109
|
+
return proc;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
|
|
113
|
+
* NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
|
|
114
|
+
* the deterministic gate; the defaults are the real IO.
|
|
115
|
+
*/
|
|
116
|
+
export function makeProductionSpawnBgResumeDeps(opts = {}) {
|
|
117
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
118
|
+
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
119
|
+
const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
|
|
120
|
+
const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
|
|
121
|
+
const lstatFn = opts.lstatFn ?? ((p) => fs.lstat(p));
|
|
122
|
+
const probeFn = opts.probeFn ?? ((socketPath) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
|
|
123
|
+
const releaseFn = opts.releaseFn ?? ((lock) => void releaseLock(lock));
|
|
124
|
+
const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
125
|
+
const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
|
|
126
|
+
// An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
|
|
127
|
+
// by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
|
|
128
|
+
const abortableDelay = (ms, signal) => new Promise((resolve, reject) => {
|
|
129
|
+
if (signal.aborted) {
|
|
130
|
+
reject(new Error("aborted"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const timer = setTimeoutFn(() => {
|
|
134
|
+
signal.removeEventListener("abort", onAbort);
|
|
135
|
+
resolve();
|
|
136
|
+
}, ms);
|
|
137
|
+
const onAbort = () => {
|
|
138
|
+
clearTimeoutFn(timer);
|
|
139
|
+
reject(new Error("aborted"));
|
|
140
|
+
};
|
|
141
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
killGraceMs,
|
|
145
|
+
spawnChild: async (plan) => {
|
|
146
|
+
const identity = resolveIdentity(plan);
|
|
147
|
+
const args = buildResumePiArgs({
|
|
148
|
+
variant: "v2-control",
|
|
149
|
+
sessionId: plan.sessionId,
|
|
150
|
+
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
151
|
+
provider: identity.provider,
|
|
152
|
+
model: identity.model,
|
|
153
|
+
prompt: plan.prompt,
|
|
154
|
+
launchArgs: plan.launchArgs,
|
|
155
|
+
});
|
|
156
|
+
// Plant the sessionId-bound authorization marker (QB-resident): this resume promotes a
|
|
157
|
+
// dormant `entwurf`-tagged session to a live `--entwurf-control` resident, which the
|
|
158
|
+
// entwurf-control guard would otherwise crash as a "corrupt resident session name". The
|
|
159
|
+
// marker authorizes ONLY this exact session — a human hand-opening the same session with
|
|
160
|
+
// `--entwurf-control` carries no marker and still crashes (the invariant is narrowed, not
|
|
161
|
+
// dropped). buildResumePiArgs's argv alone can't say "this is a v2 spawn-bg resume".
|
|
162
|
+
const childEnv = { ...process.env, [V2_RESUME_RESIDENT_SESSION_ENV]: plan.sessionId };
|
|
163
|
+
const proc = spawnChildFn("pi", args, identity.cwd, childEnv);
|
|
164
|
+
// B2: capture exit EAGERLY — the instant the proc exists, before we even await the
|
|
165
|
+
// spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
|
|
166
|
+
// only (a signal kill reports null); it never rejects, so racing it against abort is safe.
|
|
167
|
+
const exitPromise = new Promise((resolve) => {
|
|
168
|
+
proc.on("exit", (code) => resolve(typeof code === "number" ? code : null));
|
|
169
|
+
});
|
|
170
|
+
// B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
|
|
171
|
+
// failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
|
|
172
|
+
// for the `spawn` event to confirm a real start; an `error` before it rejects, which the
|
|
173
|
+
// watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
|
|
174
|
+
// spawn-started that later stalls into a wrongful retained lock.
|
|
175
|
+
await new Promise((resolve, reject) => {
|
|
176
|
+
const onSpawn = () => {
|
|
177
|
+
cleanup();
|
|
178
|
+
resolve();
|
|
179
|
+
};
|
|
180
|
+
const onError = (err) => {
|
|
181
|
+
cleanup();
|
|
182
|
+
reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
|
|
183
|
+
};
|
|
184
|
+
const cleanup = () => {
|
|
185
|
+
proc.removeListener("spawn", onSpawn);
|
|
186
|
+
proc.removeListener("error", onError);
|
|
187
|
+
};
|
|
188
|
+
proc.on("spawn", onSpawn);
|
|
189
|
+
proc.on("error", onError);
|
|
190
|
+
});
|
|
191
|
+
const child = { pid: proc.pid, proc, exitPromise };
|
|
192
|
+
return child;
|
|
193
|
+
},
|
|
194
|
+
awaitSocketAlive: async (socketPath, signal) => {
|
|
195
|
+
// Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
|
|
196
|
+
// reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
|
|
197
|
+
while (!signal.aborted) {
|
|
198
|
+
const inspection = await inspectControlSocketPath(socketPath, lstatFn);
|
|
199
|
+
const mapped = await mapInspectionToLiveness(inspection, probeFn);
|
|
200
|
+
const verdict = socketWatchVerdict(mapped);
|
|
201
|
+
if (verdict === "alive")
|
|
202
|
+
return;
|
|
203
|
+
if (verdict === "forged") {
|
|
204
|
+
throw new Error(`entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`);
|
|
205
|
+
}
|
|
206
|
+
// wait — sleep one interval, honoring abort (rejects → loop exits below).
|
|
207
|
+
await abortableDelay(pollIntervalMs, signal);
|
|
208
|
+
}
|
|
209
|
+
throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
|
|
210
|
+
},
|
|
211
|
+
awaitChildExit: (child, signal) =>
|
|
212
|
+
// Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
|
|
213
|
+
// B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
|
|
214
|
+
// unhandled rejection; only the abort branch rejects.
|
|
215
|
+
new Promise((resolve, reject) => {
|
|
216
|
+
const { exitPromise } = requireProductionChild(child);
|
|
217
|
+
if (signal.aborted) {
|
|
218
|
+
reject(new Error("aborted"));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const onAbort = () => reject(new Error("aborted"));
|
|
222
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
223
|
+
exitPromise.then((code) => {
|
|
224
|
+
signal.removeEventListener("abort", onAbort);
|
|
225
|
+
resolve(code);
|
|
226
|
+
});
|
|
227
|
+
}),
|
|
228
|
+
awaitTimeout: (ms, signal) => abortableDelay(ms, signal),
|
|
229
|
+
killChild: (child) => {
|
|
230
|
+
// SIGTERM only this slice — the watcher's killGrace then waits for the resulting
|
|
231
|
+
// child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
|
|
232
|
+
// separate policy + smoke). Best-effort: the watcher catches a throw here.
|
|
233
|
+
requireProductionChild(child).proc.kill("SIGTERM");
|
|
234
|
+
},
|
|
235
|
+
releaseLock: (lock) => releaseFn(lock),
|
|
236
|
+
};
|
|
237
|
+
}
|