@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. 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
+ }