@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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-decider — the PURE dispatch decider for the unified `entwurf_v2` verb
|
|
3
|
+
* (0.11 Stage 0 step 5b). It turns a caller's (target, intent, …) request plus
|
|
4
|
+
* already-resolvable facts into a single `DispatchDecision`: either a reject
|
|
5
|
+
* receipt, or an execute receipt + an `ExecutionPlan` + (for in-domain backends) a
|
|
6
|
+
* held `LockClaim`. It performs NO transport: no send, no enqueue, no spawn, no
|
|
7
|
+
* release-watcher. Step 5c executes the chosen plan; step 5d registers the MCP
|
|
8
|
+
* surface. The decider only DECIDES.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate pure module (step 4 discipline = gate-first → pure-before-IO →
|
|
11
|
+
* wire): every IO surface the decision needs — the target lookup, the per-gid lock,
|
|
12
|
+
* the lstat/connect socket inspection, the trust preflight, the mailbox-deliverability
|
|
13
|
+
* seam — is INJECTED via `DispatchDeciderDeps`, so the gate drives every branch with
|
|
14
|
+
* fakes and the live wrappers wire the real fns. The plan is shaped so 5c's
|
|
15
|
+
* transport hand consumes it WITHOUT re-deriving any path/arg (socketPath,
|
|
16
|
+
* mailboxDir, sessionsDir, launchArgs are all planted here once — 4c "재유도 금지"):
|
|
17
|
+
* the hand is a plan-keyed dispatcher, never a second brain. The two deliberate
|
|
18
|
+
* exceptions (GPT힣 1차 검수): the spawned child's runtime pid is NOT in the plan
|
|
19
|
+
* (it is born during 5c execution → it is the watcher's release-context, not a plan
|
|
20
|
+
* input), and the launch identity (provider/model) is read by the 5c launcher from
|
|
21
|
+
* the saved session JSONL (its existing authority) — putting that read in the
|
|
22
|
+
* decider would make it impure. So the plan carries no provider/model.
|
|
23
|
+
*
|
|
24
|
+
* The frozen 7-step order (NEXT.md "통합 decider 순서"):
|
|
25
|
+
* 1. requireGardenId — runtime guard BEFORE any path is built (F2-P1; closes the
|
|
26
|
+
* MCP-schema bypass for pi-native/internal callers).
|
|
27
|
+
* 2. resolveTarget — no citizen → bad-target; quarantined (non-pi gid sharing
|
|
28
|
+
* a socket/symlink) → target-address-conflict. PROBE-FREE.
|
|
29
|
+
* 3. backend → isLivenessSupported.
|
|
30
|
+
* 4. acquireLock — IN-DOMAIN ONLY (?7), BEFORE lstat/connect, so the probe
|
|
31
|
+
* happens under the lock (the TOCTOU 5a's lock closes).
|
|
32
|
+
* 5. in-domain — inspectTargetControlSocket (lstat-then-connect, ?2) →
|
|
33
|
+
* resolveDispatch → on a resume verdict, preflight the target cwd (1B: deny →
|
|
34
|
+
* nonce-owned release → untrusted-fail-fast) → plan.
|
|
35
|
+
* 6. unsupported — NO lock; deps.mailboxDeliverabilityFor (REQUIRED seam: wake-mode
|
|
36
|
+
* capability AND a live active-receiver, fail-closed) → resolveDispatch → meta-mailbox
|
|
37
|
+
* plan or reject. SE-2 2d-3: a terminated/drifted self-fetch citizen is refused, never
|
|
38
|
+
* enqueued as mailbox garbage.
|
|
39
|
+
* 7. send-fail fallback is 5c's job (the decider decides ONCE; the held lock nonce
|
|
40
|
+
* is what lets 5c re-resolve at most once under the same claim).
|
|
41
|
+
*
|
|
42
|
+
* Every reject is minted through `makeRejectReceipt` (?6 chokepoint) — the decider
|
|
43
|
+
* never hand-assembles a `{ok:false, …}` literal, so the pre-probe-null rule cannot
|
|
44
|
+
* be bypassed.
|
|
45
|
+
*/
|
|
46
|
+
import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.js";
|
|
47
|
+
import { isLivenessSupported, makeRejectReceipt, resolveDispatch, } from "./entwurf-v2-contract.js";
|
|
48
|
+
import { defaultMetaMailboxDir, defaultMetaSessionsDir, metaCapabilityFor, } from "./meta-session.js";
|
|
49
|
+
import { isValidSessionId } from "./session-id.js";
|
|
50
|
+
import { controlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
|
|
51
|
+
// Re-export the shared conflict predicate so producers of a TargetResolution have a
|
|
52
|
+
// single import site for it (it is the SAME fn the fact-provider listing uses).
|
|
53
|
+
export { isNonPiGardenIdSocketConflict };
|
|
54
|
+
// ── observe timeout (?3) ───────────────────────────────────────────────────
|
|
55
|
+
// The bounded wait 5c's release-watcher gives a spawned child to surface its
|
|
56
|
+
// control socket / exit. Planted into the resume plan so the hand does not invent
|
|
57
|
+
// a timeout. A standalone constant + env override (NOT a multiple of the probe
|
|
58
|
+
// timeout — a different concern); 30s initial, tunable to 45s after live data.
|
|
59
|
+
export const ENTWURF_V2_OBSERVE_TIMEOUT_MS = (() => {
|
|
60
|
+
const raw = process.env.ENTWURF_V2_OBSERVE_TIMEOUT_MS;
|
|
61
|
+
const n = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
63
|
+
})();
|
|
64
|
+
export const ENTWURF_V2_MODE_DEFAULT = "follow_up";
|
|
65
|
+
/**
|
|
66
|
+
* F2-P1 defense in depth: never build a lock/socket path from an unvalidated gid.
|
|
67
|
+
* The MCP TypeBox pattern guards that one surface; a pi-native / internal caller
|
|
68
|
+
* bypasses the schema, so the decider re-validates as its very first step.
|
|
69
|
+
*/
|
|
70
|
+
function requireGardenId(target) {
|
|
71
|
+
if (!isValidSessionId(target)) {
|
|
72
|
+
throw new Error(`entwurf_v2 decider: refusing to dispatch to an invalid garden id (${JSON.stringify(target)}).`);
|
|
73
|
+
}
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Mailbox WAKE-MODE capability (?0 frozen): ONLY a self-fetch backend (claude-code) has a
|
|
78
|
+
* drainable meta-bridge mailbox. direct-inject backends (codex/agy/pi) are fail-closed —
|
|
79
|
+
* that is intended, not a gap (the 0.10.0 mailbox + doorbell is a self-fetch drain;
|
|
80
|
+
* direct-inject drain is an unproven capability). Do NOT widen by deliveryLevel — only by
|
|
81
|
+
* a real per-backend predicate.
|
|
82
|
+
*
|
|
83
|
+
* This is the CAPABILITY HALF of deliverability only. Full mailbox deliverability ALSO
|
|
84
|
+
* requires a live active-receiver (a presence marker matching the identity) — that
|
|
85
|
+
* conjunction lives in the required `mailboxDeliverabilityFor` seam (SE-2 slice 2d-3). The
|
|
86
|
+
* decider NEVER calls this helper directly: deliverability flows exclusively through the
|
|
87
|
+
* seam so the active-receiver axis can never be skipped. Kept as a named, gate-pinned
|
|
88
|
+
* helper for the production seam to compose and for capability-only call sites.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveMailboxWakeModeCapability(identity, capabilityFor = metaCapabilityFor) {
|
|
91
|
+
return capabilityFor(identity.backend).wakeMode === "self-fetch";
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The pure dispatch decider. See the module header for the 7-step contract. Async
|
|
95
|
+
* only because the socket inspection/probe are async; it touches the filesystem
|
|
96
|
+
* ONLY through injected deps.
|
|
97
|
+
*/
|
|
98
|
+
export async function decideDispatch(input, deps) {
|
|
99
|
+
const mailboxDir = deps.mailboxDir ?? defaultMetaMailboxDir();
|
|
100
|
+
const sessionsDir = deps.sessionsDir ?? defaultMetaSessionsDir();
|
|
101
|
+
const observeTimeoutMs = deps.observeTimeoutMs ?? ENTWURF_V2_OBSERVE_TIMEOUT_MS;
|
|
102
|
+
const mode = input.mode ?? ENTWURF_V2_MODE_DEFAULT;
|
|
103
|
+
const wantsReply = input.wantsReply ?? false;
|
|
104
|
+
const ctx = { mode, wantsReply, observeTimeoutMs };
|
|
105
|
+
const reject = (receipt, diagnostic) => diagnostic ? { kind: "reject", receipt, diagnostic } : { kind: "reject", receipt };
|
|
106
|
+
// 1. requireGardenId — BEFORE any path is built.
|
|
107
|
+
const gardenId = requireGardenId(input.target);
|
|
108
|
+
// 2. resolveTarget — probe-free. no citizen → bad-target; quarantined → conflict.
|
|
109
|
+
const resolution = await deps.resolveTarget(gardenId);
|
|
110
|
+
// 2b. A1 narrow (0.11.0): a record-LESS live pi control socket — a socket-only pi
|
|
111
|
+
// endpoint (no citizen identity, but an addressable control socket). It is a REAL,
|
|
112
|
+
// addressable citizen, so it runs the SAME in-domain probe table as a record-backed pi
|
|
113
|
+
// (lock → inspect → table verdict) under `allowResume:false`. We DELIBERATELY do NOT
|
|
114
|
+
// short-circuit non-fire-and-forget here: rejecting owned-outcome pre-probe with
|
|
115
|
+
// `bad-target` would be a category lie (a LIVE, addressable peer classified as
|
|
116
|
+
// absent/typo). The frozen table is the honest authority instead —
|
|
117
|
+
// owned-outcome × live → `owned-live-no-autosend` (use fire-and-forget for a live peer)
|
|
118
|
+
// owned-outcome × dormant → resume verdict, but `allowResume:false` refuses it with
|
|
119
|
+
// `socket-only-no-resume-authority` (no trusted cwd to spawn-bg)
|
|
120
|
+
// fire-and-forget × live → control-socket send (unchanged)
|
|
121
|
+
// `allowResume:false` is what keeps spawn-bg from EVER opening into a record-less
|
|
122
|
+
// endpoint, regardless of intent — so routing every intent through is safe.
|
|
123
|
+
if (resolution.identity === null && resolution.socketOnlyPi === true) {
|
|
124
|
+
return decideInDomain(gardenId, input, deps, ctx, { allowResume: false });
|
|
125
|
+
}
|
|
126
|
+
// 2c. no citizen → bad-target; quarantined → conflict.
|
|
127
|
+
if (resolution.identity === null) {
|
|
128
|
+
return reject(makeRejectReceipt("bad-target", null));
|
|
129
|
+
}
|
|
130
|
+
if (resolution.preProbeAddressConflict) {
|
|
131
|
+
return reject(makeRejectReceipt("target-address-conflict", null));
|
|
132
|
+
}
|
|
133
|
+
const identity = resolution.identity;
|
|
134
|
+
// 3. backend.
|
|
135
|
+
if (!isLivenessSupported(identity.backend)) {
|
|
136
|
+
// 6. unsupported path — NO lock (?7). Deliverability comes from the REQUIRED seam
|
|
137
|
+
// (wake-mode capability AND a live active-receiver marker matching this identity),
|
|
138
|
+
// NOT a wake-mode-only helper — so a terminated self-fetch citizen's mailbox is
|
|
139
|
+
// fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
|
|
140
|
+
const deliverability = await deps.mailboxDeliverabilityFor(identity);
|
|
141
|
+
const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
|
|
142
|
+
if (!receipt.ok)
|
|
143
|
+
return reject(receipt);
|
|
144
|
+
// the only allow cell here is fire-and-forget → meta-mailbox send.
|
|
145
|
+
const plan = {
|
|
146
|
+
transport: "meta-mailbox",
|
|
147
|
+
action: "send",
|
|
148
|
+
targetGardenId: gardenId,
|
|
149
|
+
mailboxDir,
|
|
150
|
+
sessionsDir,
|
|
151
|
+
wantsReply,
|
|
152
|
+
message: input.message,
|
|
153
|
+
};
|
|
154
|
+
return { kind: "execute", receipt, plan, lock: null };
|
|
155
|
+
}
|
|
156
|
+
// 4-5. in-domain (record-backed pi): lock → inspect → route (resume allowed, cwd from record).
|
|
157
|
+
return decideInDomain(gardenId, input, deps, ctx, { allowResume: true, cwd: identity.cwd });
|
|
158
|
+
}
|
|
159
|
+
async function decideInDomain(gardenId, input, deps, ctx, resume) {
|
|
160
|
+
const { acquireLock, releaseLock, inspectSocket, probeSocket } = deps;
|
|
161
|
+
// 4. acquire the per-gid lock BEFORE lstat/connect.
|
|
162
|
+
const acq = acquireLock(gardenId);
|
|
163
|
+
if (!acq.ok) {
|
|
164
|
+
// B3: carry the lock's holder evidence (pid/host/createdAt + lockPath) as a
|
|
165
|
+
// diagnostic so a permanently-held gid is observable/clearable. The receipt
|
|
166
|
+
// stays pre-probe-null; the conflict rides alongside it.
|
|
167
|
+
return {
|
|
168
|
+
kind: "reject",
|
|
169
|
+
receipt: makeRejectReceipt("target-locked", null),
|
|
170
|
+
diagnostic: { kind: "target-locked", conflict: acq.conflict },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const lock = acq.claim;
|
|
174
|
+
const rejectAfterRelease = (receipt) => {
|
|
175
|
+
releaseLock(lock);
|
|
176
|
+
return { kind: "reject", receipt };
|
|
177
|
+
};
|
|
178
|
+
let retainLock = false;
|
|
179
|
+
try {
|
|
180
|
+
// 5. under the lock: inspect the socket (lstat-then-connect), then route.
|
|
181
|
+
const inspection = await inspectSocket(gardenId);
|
|
182
|
+
const mapped = await mapInspectionToLiveness(inspection, probeSocket);
|
|
183
|
+
if ("addressConflict" in mapped) {
|
|
184
|
+
return rejectAfterRelease(makeRejectReceipt("target-address-conflict", null));
|
|
185
|
+
}
|
|
186
|
+
const { liveness, socketPath } = mapped;
|
|
187
|
+
const receipt = resolveDispatch(input.intent, liveness, false);
|
|
188
|
+
if (!receipt.ok) {
|
|
189
|
+
// resolver reject (owned-live-no-autosend / indeterminate-no-spawn / …) — the
|
|
190
|
+
// lock was for an in-domain probe that yielded no execute, so release it.
|
|
191
|
+
return rejectAfterRelease(receipt);
|
|
192
|
+
}
|
|
193
|
+
if (receipt.action === "resume") {
|
|
194
|
+
if (!resume.allowResume) {
|
|
195
|
+
// A1 narrow guard: the resume verdict is owned-outcome × dormant ONLY. A
|
|
196
|
+
// socket-only pi endpoint now routes ALL intents through here under
|
|
197
|
+
// `allowResume:false` (2b no longer pre-rejects non-fire-and-forget), so this
|
|
198
|
+
// guard IS reachable — it is the honest home of "owned-outcome to a DORMANT
|
|
199
|
+
// record-less socket". REFUSE: a record-less endpoint has no trusted cwd/resume
|
|
200
|
+
// authority, so spawn-bg must never open into it. This is a POST-probe guard
|
|
201
|
+
// (we measured `liveness` above), so it carries the honest measured liveness —
|
|
202
|
+
// NOT the pre-probe `bad-target` lie that would mislabel the citizen absent.
|
|
203
|
+
return rejectAfterRelease(makeRejectReceipt("socket-only-no-resume-authority", liveness));
|
|
204
|
+
}
|
|
205
|
+
// 1B: preflight runs ONLY here (the sole branch that launches a child into a
|
|
206
|
+
// target cwd). deny → nonce-owned release → untrusted-fail-fast, with the
|
|
207
|
+
// honest measured liveness (dormant = the `dead` we just probed).
|
|
208
|
+
const outcome = await deps.preflightForCwd(resume.cwd);
|
|
209
|
+
if (outcome.kind === "deny") {
|
|
210
|
+
return rejectAfterRelease(makeRejectReceipt("untrusted-fail-fast", liveness));
|
|
211
|
+
}
|
|
212
|
+
const plan = {
|
|
213
|
+
transport: "spawn-bg",
|
|
214
|
+
action: "resume",
|
|
215
|
+
targetGardenId: gardenId,
|
|
216
|
+
sessionId: gardenId, // D3: gid is the pi resume authority, not nativeSessionId.
|
|
217
|
+
cwd: resume.cwd,
|
|
218
|
+
prompt: input.message,
|
|
219
|
+
launchArgs: outcome.launchArgs,
|
|
220
|
+
expectedSocketPath: socketPath,
|
|
221
|
+
observeTimeoutMs: ctx.observeTimeoutMs,
|
|
222
|
+
releaseWhen: "socket-alive-or-child-exited",
|
|
223
|
+
};
|
|
224
|
+
retainLock = true;
|
|
225
|
+
return { kind: "execute", receipt, plan, lock };
|
|
226
|
+
}
|
|
227
|
+
// receipt.action === "send" → control-socket send (lock kept for 5c re-resolve).
|
|
228
|
+
const plan = {
|
|
229
|
+
transport: "control-socket",
|
|
230
|
+
action: "send",
|
|
231
|
+
targetGardenId: gardenId,
|
|
232
|
+
socketPath,
|
|
233
|
+
mode: ctx.mode,
|
|
234
|
+
wantsReply: ctx.wantsReply,
|
|
235
|
+
message: input.message,
|
|
236
|
+
};
|
|
237
|
+
retainLock = true;
|
|
238
|
+
return { kind: "execute", receipt, plan, lock };
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (!retainLock) {
|
|
242
|
+
try {
|
|
243
|
+
releaseLock(lock);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// best-effort: a release failure must NOT mask the original throw.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** The canonical control-socket path for a target — re-exported so a production
|
|
253
|
+
* resolveTarget/wrapper plants the SAME path the decider/plan use (no drift). */
|
|
254
|
+
export { controlSocketPath };
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-lock — the per-gid dispatch lock primitive (0.11 Stage 0 step 5a,
|
|
3
|
+
* 버킷 B F2). LOAD-BEARING: the guard against a double-spawn of the same dormant
|
|
4
|
+
* target by two V2 dispatchers that share the substrate through different entry
|
|
5
|
+
* points. SCOPE (honest): this protects v2/v2 only. The legacy `entwurf_resume`
|
|
6
|
+
* is unchanged (동결결정 10 scope A) and does NOT take this lock, so v2/legacy
|
|
7
|
+
* concurrent resume is a KNOWN residual gap (rare — single-orchestrator practice),
|
|
8
|
+
* closed only at full cut-over. Do not read this header as "v2/legacy is guarded".
|
|
9
|
+
*
|
|
10
|
+
* ENVIRONMENT ASSUMPTION (stale reclaim): `hostname` equality is used as the
|
|
11
|
+
* proxy for "same machine", so a holder pid is reclaim-probed with kill(0) only
|
|
12
|
+
* when its hostname matches ours. This holds when `~/.pi` is NOT shared across
|
|
13
|
+
* hosts. If two machines with the same hostname shared `~/.pi` over NFS, a remote
|
|
14
|
+
* pid could be mis-judged ESRCH and a live remote lock wrongly reclaimed. GLG's
|
|
15
|
+
* environment (laptop/nuc/oracle = distinct hostnames, non-shared homes) does not
|
|
16
|
+
* hit this; documented so a future shared-home setup reopens the reclaim axis.
|
|
17
|
+
*
|
|
18
|
+
* Why a lockfile and not pi's own guard (검증원장 F2, source-verified): pi
|
|
19
|
+
* `SessionManager._persist` only takes an `openSync(file,"wx")` on the FIRST
|
|
20
|
+
* flush of a NEW session (session-manager.js:652/:1146 = a concurrent-CREATE
|
|
21
|
+
* EEXIST guard). A v2 dispatch always RESUMES an existing citizen, and the
|
|
22
|
+
* resume path (`setSessionFile` → flushed=true → plain `appendFileSync`, :664)
|
|
23
|
+
* takes no lock — so pi does NOT self-guard concurrent resume. The per-gid
|
|
24
|
+
* lockfile here is the only thing standing between two dispatchers and a
|
|
25
|
+
* duplicated session.
|
|
26
|
+
*
|
|
27
|
+
* Invariants (source-verified, frozen — do NOT relax without reopening the
|
|
28
|
+
* ledger):
|
|
29
|
+
* - acquire = `openSync(lockPath, "wx")` — an atomic, OS-level create-exclusive.
|
|
30
|
+
* The same primitive pi itself uses; no new direct dependency (proper-lockfile
|
|
31
|
+
* avoided — this is a short dispatch claim, not durable state).
|
|
32
|
+
* - acquire runs BEFORE any liveness probe (the decider's lock step precedes
|
|
33
|
+
* lstat/connect) — the probe must happen UNDER the lock or the TOCTOU it
|
|
34
|
+
* closes reopens.
|
|
35
|
+
* - release = unlink ONLY when the on-disk nonce is still ours. A reclaimed +
|
|
36
|
+
* re-acquired lock carries a different nonce, so a late release can never
|
|
37
|
+
* delete a successor's claim.
|
|
38
|
+
* - stale reclaim = SAME hostname AND `kill(pid,0) === ESRCH` ONLY. A TTL-only
|
|
39
|
+
* steal is forbidden (it would re-admit the double-spawn this primitive
|
|
40
|
+
* exists to prevent). EPERM (another user's LIVE pid) is fail-closed: NOT
|
|
41
|
+
* reclaimed (F2-P2 — the ESRCH-only branch is easy to drop, so the gate pins
|
|
42
|
+
* EPERM/unknown = not-reclaimed explicitly). A different hostname is never
|
|
43
|
+
* reclaimed (we cannot reason about a remote pid).
|
|
44
|
+
* - PID reuse → a permanently-held lock is the accepted cost of forbidding the
|
|
45
|
+
* TTL steal (workshop scale). It is made OBSERVABLE: a `target-locked`
|
|
46
|
+
* conflict carries the holder JSON (pid/host/createdAt/lockPath) so a human
|
|
47
|
+
* can clear it. An empty/corrupt lockfile (a crash between open-wx and write)
|
|
48
|
+
* surfaces through the SAME conflict path — never auto-deleted (it could be
|
|
49
|
+
* another acquirer mid-write).
|
|
50
|
+
*
|
|
51
|
+
* PURE of dispatch: this module knows nothing about transports, intents, or
|
|
52
|
+
* liveness routing. It only claims/reclaims/releases a file and reports a
|
|
53
|
+
* `target-locked` conflict. The decider (5b) decides WHETHER to lock (only for
|
|
54
|
+
* an in-domain backend — ?7) and the watcher (5c) decides WHEN to release
|
|
55
|
+
* (after an observable liveness transition — A2). Deps (clock / nonce / pid /
|
|
56
|
+
* hostname / kill) are injectable so the gate drives content deterministically
|
|
57
|
+
* over a real temp dir (the `openSync wx` atomicity is the thing under test, so
|
|
58
|
+
* the dir is real, not faked).
|
|
59
|
+
*/
|
|
60
|
+
import { randomBytes } from "node:crypto";
|
|
61
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
62
|
+
import * as os from "node:os";
|
|
63
|
+
import * as path from "node:path";
|
|
64
|
+
import { isValidSessionId } from "./session-id.js";
|
|
65
|
+
/** Canonical lock directory — a SEPARATE dir from the control sockets so the
|
|
66
|
+
* socket scan (`*.sock`) never sees a `<gid>.lock` and so a lock is never
|
|
67
|
+
* mistaken for a liveness signal. */
|
|
68
|
+
export const ENTWURF_V2_LOCK_DIR = path.join(os.homedir(), ".pi", "entwurf-v2-locks");
|
|
69
|
+
export const LOCK_SUFFIX = ".lock";
|
|
70
|
+
export const LOCK_OWNER = "entwurf_v2";
|
|
71
|
+
/** The reject reason a lock conflict maps to. Kept as a literal here (the lock
|
|
72
|
+
* primitive stays decoupled from the full contract); `check-entwurf-v2-lock`
|
|
73
|
+
* cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
|
|
74
|
+
* cannot drift. */
|
|
75
|
+
export const LOCK_CONFLICT_REASON = "target-locked";
|
|
76
|
+
/**
|
|
77
|
+
* Classify a holder pid for stale reclaim. ONLY `dead` (ESRCH) is reclaimable.
|
|
78
|
+
* `denied` (EPERM = another user's live pid) and any unknown error fail-closed
|
|
79
|
+
* to a non-reclaimable state — we never reclaim a lock we cannot prove is dead.
|
|
80
|
+
*/
|
|
81
|
+
export function classifyProcessLiveness(pid, killFn = process.kill) {
|
|
82
|
+
try {
|
|
83
|
+
killFn(pid, 0);
|
|
84
|
+
return "alive";
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const code = err.code;
|
|
88
|
+
if (code === "ESRCH")
|
|
89
|
+
return "dead";
|
|
90
|
+
if (code === "EPERM")
|
|
91
|
+
return "denied";
|
|
92
|
+
// Unknown error: fail-closed — treat as not-dead so we never reclaim it.
|
|
93
|
+
return "alive";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function lockPathFor(gardenId, dir = ENTWURF_V2_LOCK_DIR) {
|
|
97
|
+
// F2-P1 (defense in depth): never build a filesystem path from an unvalidated
|
|
98
|
+
// gid. The decider validates first (its step 1), but the lock layer refuses to
|
|
99
|
+
// be a path-traversal sink on its own — a bad gid throws, it does not write.
|
|
100
|
+
if (!isValidSessionId(gardenId)) {
|
|
101
|
+
throw new Error(`entwurf-v2-lock: refusing to build a lock path from an invalid garden id (${JSON.stringify(gardenId)}).`);
|
|
102
|
+
}
|
|
103
|
+
return path.join(dir, `${gardenId}${LOCK_SUFFIX}`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse a lockfile's bytes into a claim, or null when empty/corrupt/wrong-gid.
|
|
107
|
+
* When `expectedGardenId` is given, a well-formed claim whose `gardenId` does NOT
|
|
108
|
+
* match is treated as null (→ conflict, never reclaimed): the path authority IS
|
|
109
|
+
* the garden id (동결결정3), so a `<A>.lock` carrying `gardenId:B` is a corrupt
|
|
110
|
+
* address, not a holder we may probe-and-reclaim by A's heuristic.
|
|
111
|
+
*/
|
|
112
|
+
function parseLockClaim(raw, lockPath, expectedGardenId) {
|
|
113
|
+
let obj;
|
|
114
|
+
try {
|
|
115
|
+
obj = JSON.parse(raw);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (typeof obj !== "object" || obj === null)
|
|
121
|
+
return null;
|
|
122
|
+
const o = obj;
|
|
123
|
+
if (typeof o.gardenId !== "string" ||
|
|
124
|
+
typeof o.pid !== "number" ||
|
|
125
|
+
typeof o.hostname !== "string" ||
|
|
126
|
+
typeof o.createdAt !== "string" ||
|
|
127
|
+
typeof o.nonce !== "string" ||
|
|
128
|
+
o.owner !== LOCK_OWNER) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (expectedGardenId !== undefined && o.gardenId !== expectedGardenId)
|
|
132
|
+
return null;
|
|
133
|
+
return {
|
|
134
|
+
gardenId: o.gardenId,
|
|
135
|
+
pid: o.pid,
|
|
136
|
+
hostname: o.hostname,
|
|
137
|
+
createdAt: o.createdAt,
|
|
138
|
+
nonce: o.nonce,
|
|
139
|
+
owner: LOCK_OWNER,
|
|
140
|
+
lockPath,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Best-effort lockfile mtime (ISO) for human cleanup evidence — the ONLY age
|
|
144
|
+
* signal when the body is empty/corrupt (createdAt is then unreadable). */
|
|
145
|
+
function lockMtimeIso(lockPath) {
|
|
146
|
+
try {
|
|
147
|
+
return statSync(lockPath).mtime.toISOString();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function describeHolder(holder, lockPath) {
|
|
154
|
+
const mtime = lockMtimeIso(lockPath);
|
|
155
|
+
const age = mtime ? ` (file mtime ${mtime})` : "";
|
|
156
|
+
if (holder === null) {
|
|
157
|
+
return `lockfile at ${lockPath} is empty, corrupt, or holds a different garden id${age}; clear it by hand after confirming no dispatcher is mid-spawn`;
|
|
158
|
+
}
|
|
159
|
+
return `held by pid ${holder.pid} on host ${holder.hostname} since ${holder.createdAt}${age} (${lockPath}); clear it by hand if that process is gone`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Acquire the per-gid dispatch lock. Returns the claim on success, or a
|
|
163
|
+
* `target-locked` conflict (with the holder evidence) on contention. Stale reclaim
|
|
164
|
+
* (same host + ESRCH) runs UNDER a `<gid>.lock.reclaim` wx mutex so two
|
|
165
|
+
* dispatchers can never both reclaim the same dead lock (the F2 double-spawn race
|
|
166
|
+
* GPT+Fable found). It never loops — a race lost on the re-acquire is an honest
|
|
167
|
+
* conflict, not a spin.
|
|
168
|
+
*/
|
|
169
|
+
export function acquireLock(gardenId, deps = {}) {
|
|
170
|
+
const dir = deps.dir ?? ENTWURF_V2_LOCK_DIR;
|
|
171
|
+
const lockPath = lockPathFor(gardenId, dir); // validates gid (F2-P1)
|
|
172
|
+
const reclaimMarkerPath = `${lockPath}.reclaim`;
|
|
173
|
+
const pid = deps.pid ?? process.pid;
|
|
174
|
+
const hostname = deps.hostname ?? os.hostname();
|
|
175
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
176
|
+
const nonce = deps.nonce ?? (() => randomBytes(8).toString("hex"));
|
|
177
|
+
const killFn = deps.killFn ?? process.kill;
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
const claim = {
|
|
180
|
+
gardenId,
|
|
181
|
+
pid,
|
|
182
|
+
hostname,
|
|
183
|
+
createdAt: now(),
|
|
184
|
+
nonce: nonce(),
|
|
185
|
+
owner: LOCK_OWNER,
|
|
186
|
+
lockPath,
|
|
187
|
+
};
|
|
188
|
+
const conflict = (holder, detail) => ({
|
|
189
|
+
ok: false,
|
|
190
|
+
conflict: { reason: LOCK_CONFLICT_REASON, lockPath, holder, detail: detail ?? describeHolder(holder, lockPath) },
|
|
191
|
+
});
|
|
192
|
+
const readHolder = () => {
|
|
193
|
+
try {
|
|
194
|
+
return parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, gardenId);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
// Create the lock and write the claim. write AND close are ONE unit: ENOSPC /
|
|
201
|
+
// NFS can throw on close (the flush), not just write, so a failure in EITHER
|
|
202
|
+
// must best-effort unlink our OWN fresh file before rethrowing — otherwise the
|
|
203
|
+
// transient error leaves a stray lockfile that permanently corrupt-conflicts
|
|
204
|
+
// the gid (Fable 2 self-harm). The unlink is safe: we hold the file exclusively.
|
|
205
|
+
const tryCreate = () => {
|
|
206
|
+
let fd;
|
|
207
|
+
try {
|
|
208
|
+
fd = openSync(lockPath, "wx");
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return { ok: false, code: err.code };
|
|
212
|
+
}
|
|
213
|
+
let closed = false;
|
|
214
|
+
try {
|
|
215
|
+
writeSync(fd, `${JSON.stringify(claim)}\n`);
|
|
216
|
+
closeSync(fd);
|
|
217
|
+
closed = true;
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
if (!closed) {
|
|
222
|
+
try {
|
|
223
|
+
closeSync(fd);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* fd may already be unusable */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
unlinkSync(lockPath);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
/* best-effort; nothing else holds it */
|
|
234
|
+
}
|
|
235
|
+
throw new Error(`entwurf-v2-lock: failed to write claim to ${lockPath}: ${err.code ?? "unknown error"}`);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const first = tryCreate();
|
|
239
|
+
if (first.ok)
|
|
240
|
+
return { ok: true, claim };
|
|
241
|
+
if (first.code !== "EEXIST") {
|
|
242
|
+
// A non-EEXIST failure (EACCES, ENOSPC, …) is not a lock conflict — it is a
|
|
243
|
+
// real IO failure the caller must see, not a silent "locked".
|
|
244
|
+
throw new Error(`entwurf-v2-lock: failed to acquire ${lockPath}: ${first.code ?? "unknown error"}`);
|
|
245
|
+
}
|
|
246
|
+
// EEXIST: a lock already exists. Read it and decide reclaim vs conflict.
|
|
247
|
+
let holder;
|
|
248
|
+
try {
|
|
249
|
+
holder = parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, gardenId);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const code = err.code;
|
|
253
|
+
if (code === "ENOENT") {
|
|
254
|
+
// The holder released between our open-wx and our read — retry once.
|
|
255
|
+
const retry = tryCreate();
|
|
256
|
+
if (retry.ok)
|
|
257
|
+
return { ok: true, claim };
|
|
258
|
+
// Someone else re-grabbed it; report the actual winner (not "corrupt").
|
|
259
|
+
return conflict(readHolder());
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
// Empty/corrupt/wrong-gid lockfile → conflict (NEVER auto-deleted: could be
|
|
264
|
+
// mid-write, and there is no dead pid to reclaim by).
|
|
265
|
+
if (holder === null)
|
|
266
|
+
return conflict(null);
|
|
267
|
+
// Stale reclaim is allowed ONLY for our own host + a provably-dead pid (ESRCH).
|
|
268
|
+
const reclaimable = holder.hostname === hostname && classifyProcessLiveness(holder.pid, killFn) === "dead";
|
|
269
|
+
if (!reclaimable)
|
|
270
|
+
return conflict(holder);
|
|
271
|
+
// ── Reclaim under a wx mutex (closes the F2 two-reclaimer race) ────────────
|
|
272
|
+
// The blind unlink this replaced could delete a SUCCESSOR's fresh lock: two
|
|
273
|
+
// dispatchers read the same dead holder, the first reclaimed+recreated, the
|
|
274
|
+
// second's unlink then deleted the first's new lock → both spawned. The mutex
|
|
275
|
+
// serializes every would-be reclaimer: another reclaimer (or a fresh acquirer
|
|
276
|
+
// that EEXISTed on the still-present stale lock and re-entered this branch)
|
|
277
|
+
// loses the marker wx and fails closed. While the stale lock is still present
|
|
278
|
+
// it therefore cannot change under us; the ONE actor that can appear is a fresh
|
|
279
|
+
// acquirer winning the unlink→create gap (its wx then succeeds on the absent
|
|
280
|
+
// path) — handled below as an honest conflict, never a clobber. An EEXIST on
|
|
281
|
+
// the marker is a fail-closed conflict (a permanent conflict is the accepted
|
|
282
|
+
// worst case — same grade as a corrupt lockfile — never a double-spawn).
|
|
283
|
+
let markerFd;
|
|
284
|
+
try {
|
|
285
|
+
markerFd = openSync(reclaimMarkerPath, "wx");
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const code = err.code;
|
|
289
|
+
if (code === "EEXIST") {
|
|
290
|
+
const markerMtime = lockMtimeIso(reclaimMarkerPath);
|
|
291
|
+
const age = markerMtime ? ` (marker mtime ${markerMtime})` : "";
|
|
292
|
+
return conflict(holder, `reclaim already in progress (or a stale reclaim marker at ${reclaimMarkerPath}${age}); confirm no dispatcher is mid-reclaim, then clear it by hand`);
|
|
293
|
+
}
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
deps._test_beforeReread?.();
|
|
298
|
+
// Re-read UNDER the mutex: the lock must still be the exact dead claim we
|
|
299
|
+
// judged (Fable's nonce re-compare). If it changed (a normal release +
|
|
300
|
+
// recreate — impossible for a dead holder, but cheap insurance) abort.
|
|
301
|
+
const current = readHolder();
|
|
302
|
+
if (current === null || current.nonce !== holder.nonce)
|
|
303
|
+
return conflict(current);
|
|
304
|
+
try {
|
|
305
|
+
unlinkSync(lockPath);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
if (err.code !== "ENOENT")
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
deps._test_beforeRecreate?.();
|
|
312
|
+
const reacquired = tryCreate();
|
|
313
|
+
if (reacquired.ok)
|
|
314
|
+
return { ok: true, claim };
|
|
315
|
+
// A fresh acquirer slipped into the unlink→create gap — honest conflict.
|
|
316
|
+
return conflict(readHolder());
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
try {
|
|
320
|
+
closeSync(markerFd);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* fd may already be unusable */
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
unlinkSync(reclaimMarkerPath);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
/* best-effort; a leftover marker just fail-closes the next reclaim */
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Release the lock — unlink ONLY when the on-disk nonce is still ours. A lock
|
|
335
|
+
* that was reclaimed and re-acquired by a successor carries a different nonce, so
|
|
336
|
+
* a late release returns `not-owned` and leaves the successor's claim intact. An
|
|
337
|
+
* already-gone lock returns `absent`. This is the second half of the F2 guard:
|
|
338
|
+
* without the nonce check a recycled pid or a stale watcher could delete a live
|
|
339
|
+
* successor's lock. The read passes `claim.gardenId` so a `<A>.lock` carrying a
|
|
340
|
+
* different gardenId (with a coincidental same nonce) is `not-owned`, never freed
|
|
341
|
+
* — path authority is the gid all the way through (GPT 4 / 동결결정3).
|
|
342
|
+
*/
|
|
343
|
+
export function releaseLock(claim, deps = {}) {
|
|
344
|
+
const lockPath = claim.lockPath ?? lockPathFor(claim.gardenId, deps.dir);
|
|
345
|
+
let onDisk;
|
|
346
|
+
try {
|
|
347
|
+
onDisk = parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, claim.gardenId);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
if (err.code === "ENOENT")
|
|
351
|
+
return "absent";
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
if (onDisk === null || onDisk.nonce !== claim.nonce)
|
|
355
|
+
return "not-owned";
|
|
356
|
+
try {
|
|
357
|
+
unlinkSync(lockPath);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (err.code === "ENOENT")
|
|
361
|
+
return "absent";
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
return "released";
|
|
365
|
+
}
|