@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,412 @@
|
|
|
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
|
+
|
|
61
|
+
import { randomBytes } from "node:crypto";
|
|
62
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
63
|
+
import * as os from "node:os";
|
|
64
|
+
import * as path from "node:path";
|
|
65
|
+
import { isValidSessionId } from "./session-id.js";
|
|
66
|
+
|
|
67
|
+
/** Canonical lock directory — a SEPARATE dir from the control sockets so the
|
|
68
|
+
* socket scan (`*.sock`) never sees a `<gid>.lock` and so a lock is never
|
|
69
|
+
* mistaken for a liveness signal. */
|
|
70
|
+
export const ENTWURF_V2_LOCK_DIR = path.join(os.homedir(), ".pi", "entwurf-v2-locks");
|
|
71
|
+
export const LOCK_SUFFIX = ".lock";
|
|
72
|
+
export const LOCK_OWNER = "entwurf_v2" as const;
|
|
73
|
+
|
|
74
|
+
/** The reject reason a lock conflict maps to. Kept as a literal here (the lock
|
|
75
|
+
* primitive stays decoupled from the full contract); `check-entwurf-v2-lock`
|
|
76
|
+
* cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
|
|
77
|
+
* cannot drift. */
|
|
78
|
+
export const LOCK_CONFLICT_REASON = "target-locked" as const;
|
|
79
|
+
|
|
80
|
+
/** The on-disk lock claim. `nonce` is the release authority (only the holder of
|
|
81
|
+
* this exact nonce may unlink); `pid`+`hostname` are the stale-reclaim authority
|
|
82
|
+
* (same host + ESRCH); `createdAt` is human-cleanup evidence only. */
|
|
83
|
+
export interface LockClaim {
|
|
84
|
+
gardenId: string;
|
|
85
|
+
pid: number;
|
|
86
|
+
hostname: string;
|
|
87
|
+
createdAt: string;
|
|
88
|
+
nonce: string;
|
|
89
|
+
owner: typeof LOCK_OWNER;
|
|
90
|
+
lockPath: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A failed acquire: the target is already locked. `holder` is the parsed
|
|
94
|
+
* existing claim (null when the lockfile is empty/corrupt — a crash window);
|
|
95
|
+
* `detail` is the human-readable reason a person needs to clear it by hand. */
|
|
96
|
+
export interface LockConflict {
|
|
97
|
+
reason: typeof LOCK_CONFLICT_REASON;
|
|
98
|
+
lockPath: string;
|
|
99
|
+
holder: LockClaim | null;
|
|
100
|
+
detail: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type AcquireLockResult = { ok: true; claim: LockClaim } | { ok: false; conflict: LockConflict };
|
|
104
|
+
|
|
105
|
+
export interface LockDeps {
|
|
106
|
+
dir?: string;
|
|
107
|
+
now?: () => string;
|
|
108
|
+
nonce?: () => string;
|
|
109
|
+
pid?: number;
|
|
110
|
+
hostname?: string;
|
|
111
|
+
/** `kill(pid, 0)` surface for stale reclaim — injected so the gate controls
|
|
112
|
+
* ESRCH / EPERM / alive without real processes. Default = `process.kill`. */
|
|
113
|
+
killFn?: (pid: number, signal: 0) => void;
|
|
114
|
+
/** TEST-ONLY seams to drive the reclaim critical section deterministically
|
|
115
|
+
* (simulate a competitor changing the lock under our reclaim mutex). Default
|
|
116
|
+
* undefined = noop in production; never set outside the gate. */
|
|
117
|
+
_test_beforeReread?: () => void;
|
|
118
|
+
_test_beforeRecreate?: () => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type ProcessLiveness = "alive" | "dead" | "denied";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Classify a holder pid for stale reclaim. ONLY `dead` (ESRCH) is reclaimable.
|
|
125
|
+
* `denied` (EPERM = another user's live pid) and any unknown error fail-closed
|
|
126
|
+
* to a non-reclaimable state — we never reclaim a lock we cannot prove is dead.
|
|
127
|
+
*/
|
|
128
|
+
export function classifyProcessLiveness(
|
|
129
|
+
pid: number,
|
|
130
|
+
killFn: (pid: number, signal: 0) => void = process.kill,
|
|
131
|
+
): ProcessLiveness {
|
|
132
|
+
try {
|
|
133
|
+
killFn(pid, 0);
|
|
134
|
+
return "alive";
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
137
|
+
if (code === "ESRCH") return "dead";
|
|
138
|
+
if (code === "EPERM") return "denied";
|
|
139
|
+
// Unknown error: fail-closed — treat as not-dead so we never reclaim it.
|
|
140
|
+
return "alive";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function lockPathFor(gardenId: string, dir: string = ENTWURF_V2_LOCK_DIR): string {
|
|
145
|
+
// F2-P1 (defense in depth): never build a filesystem path from an unvalidated
|
|
146
|
+
// gid. The decider validates first (its step 1), but the lock layer refuses to
|
|
147
|
+
// be a path-traversal sink on its own — a bad gid throws, it does not write.
|
|
148
|
+
if (!isValidSessionId(gardenId)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`entwurf-v2-lock: refusing to build a lock path from an invalid garden id (${JSON.stringify(gardenId)}).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return path.join(dir, `${gardenId}${LOCK_SUFFIX}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a lockfile's bytes into a claim, or null when empty/corrupt/wrong-gid.
|
|
158
|
+
* When `expectedGardenId` is given, a well-formed claim whose `gardenId` does NOT
|
|
159
|
+
* match is treated as null (→ conflict, never reclaimed): the path authority IS
|
|
160
|
+
* the garden id (동결결정3), so a `<A>.lock` carrying `gardenId:B` is a corrupt
|
|
161
|
+
* address, not a holder we may probe-and-reclaim by A's heuristic.
|
|
162
|
+
*/
|
|
163
|
+
function parseLockClaim(raw: string, lockPath: string, expectedGardenId?: string): LockClaim | null {
|
|
164
|
+
let obj: unknown;
|
|
165
|
+
try {
|
|
166
|
+
obj = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (typeof obj !== "object" || obj === null) return null;
|
|
171
|
+
const o = obj as Record<string, unknown>;
|
|
172
|
+
if (
|
|
173
|
+
typeof o.gardenId !== "string" ||
|
|
174
|
+
typeof o.pid !== "number" ||
|
|
175
|
+
typeof o.hostname !== "string" ||
|
|
176
|
+
typeof o.createdAt !== "string" ||
|
|
177
|
+
typeof o.nonce !== "string" ||
|
|
178
|
+
o.owner !== LOCK_OWNER
|
|
179
|
+
) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (expectedGardenId !== undefined && o.gardenId !== expectedGardenId) return null;
|
|
183
|
+
return {
|
|
184
|
+
gardenId: o.gardenId,
|
|
185
|
+
pid: o.pid,
|
|
186
|
+
hostname: o.hostname,
|
|
187
|
+
createdAt: o.createdAt,
|
|
188
|
+
nonce: o.nonce,
|
|
189
|
+
owner: LOCK_OWNER,
|
|
190
|
+
lockPath,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Best-effort lockfile mtime (ISO) for human cleanup evidence — the ONLY age
|
|
195
|
+
* signal when the body is empty/corrupt (createdAt is then unreadable). */
|
|
196
|
+
function lockMtimeIso(lockPath: string): string | null {
|
|
197
|
+
try {
|
|
198
|
+
return statSync(lockPath).mtime.toISOString();
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeHolder(holder: LockClaim | null, lockPath: string): string {
|
|
205
|
+
const mtime = lockMtimeIso(lockPath);
|
|
206
|
+
const age = mtime ? ` (file mtime ${mtime})` : "";
|
|
207
|
+
if (holder === null) {
|
|
208
|
+
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`;
|
|
209
|
+
}
|
|
210
|
+
return `held by pid ${holder.pid} on host ${holder.hostname} since ${holder.createdAt}${age} (${lockPath}); clear it by hand if that process is gone`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Acquire the per-gid dispatch lock. Returns the claim on success, or a
|
|
215
|
+
* `target-locked` conflict (with the holder evidence) on contention. Stale reclaim
|
|
216
|
+
* (same host + ESRCH) runs UNDER a `<gid>.lock.reclaim` wx mutex so two
|
|
217
|
+
* dispatchers can never both reclaim the same dead lock (the F2 double-spawn race
|
|
218
|
+
* GPT+Fable found). It never loops — a race lost on the re-acquire is an honest
|
|
219
|
+
* conflict, not a spin.
|
|
220
|
+
*/
|
|
221
|
+
export function acquireLock(gardenId: string, deps: LockDeps = {}): AcquireLockResult {
|
|
222
|
+
const dir = deps.dir ?? ENTWURF_V2_LOCK_DIR;
|
|
223
|
+
const lockPath = lockPathFor(gardenId, dir); // validates gid (F2-P1)
|
|
224
|
+
const reclaimMarkerPath = `${lockPath}.reclaim`;
|
|
225
|
+
const pid = deps.pid ?? process.pid;
|
|
226
|
+
const hostname = deps.hostname ?? os.hostname();
|
|
227
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
228
|
+
const nonce = deps.nonce ?? (() => randomBytes(8).toString("hex"));
|
|
229
|
+
const killFn = deps.killFn ?? process.kill;
|
|
230
|
+
|
|
231
|
+
mkdirSync(dir, { recursive: true });
|
|
232
|
+
|
|
233
|
+
const claim: LockClaim = {
|
|
234
|
+
gardenId,
|
|
235
|
+
pid,
|
|
236
|
+
hostname,
|
|
237
|
+
createdAt: now(),
|
|
238
|
+
nonce: nonce(),
|
|
239
|
+
owner: LOCK_OWNER,
|
|
240
|
+
lockPath,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const conflict = (holder: LockClaim | null, detail?: string): AcquireLockResult => ({
|
|
244
|
+
ok: false,
|
|
245
|
+
conflict: { reason: LOCK_CONFLICT_REASON, lockPath, holder, detail: detail ?? describeHolder(holder, lockPath) },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const readHolder = (): LockClaim | null => {
|
|
249
|
+
try {
|
|
250
|
+
return parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, gardenId);
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Create the lock and write the claim. write AND close are ONE unit: ENOSPC /
|
|
257
|
+
// NFS can throw on close (the flush), not just write, so a failure in EITHER
|
|
258
|
+
// must best-effort unlink our OWN fresh file before rethrowing — otherwise the
|
|
259
|
+
// transient error leaves a stray lockfile that permanently corrupt-conflicts
|
|
260
|
+
// the gid (Fable 2 self-harm). The unlink is safe: we hold the file exclusively.
|
|
261
|
+
const tryCreate = (): { ok: true } | { ok: false; code: string | undefined } => {
|
|
262
|
+
let fd: number;
|
|
263
|
+
try {
|
|
264
|
+
fd = openSync(lockPath, "wx");
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return { ok: false, code: (err as NodeJS.ErrnoException).code };
|
|
267
|
+
}
|
|
268
|
+
let closed = false;
|
|
269
|
+
try {
|
|
270
|
+
writeSync(fd, `${JSON.stringify(claim)}\n`);
|
|
271
|
+
closeSync(fd);
|
|
272
|
+
closed = true;
|
|
273
|
+
return { ok: true };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (!closed) {
|
|
276
|
+
try {
|
|
277
|
+
closeSync(fd);
|
|
278
|
+
} catch {
|
|
279
|
+
/* fd may already be unusable */
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
unlinkSync(lockPath);
|
|
284
|
+
} catch {
|
|
285
|
+
/* best-effort; nothing else holds it */
|
|
286
|
+
}
|
|
287
|
+
throw new Error(
|
|
288
|
+
`entwurf-v2-lock: failed to write claim to ${lockPath}: ${(err as NodeJS.ErrnoException).code ?? "unknown error"}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const first = tryCreate();
|
|
294
|
+
if (first.ok) return { ok: true, claim };
|
|
295
|
+
if (first.code !== "EEXIST") {
|
|
296
|
+
// A non-EEXIST failure (EACCES, ENOSPC, …) is not a lock conflict — it is a
|
|
297
|
+
// real IO failure the caller must see, not a silent "locked".
|
|
298
|
+
throw new Error(`entwurf-v2-lock: failed to acquire ${lockPath}: ${first.code ?? "unknown error"}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// EEXIST: a lock already exists. Read it and decide reclaim vs conflict.
|
|
302
|
+
let holder: LockClaim | null;
|
|
303
|
+
try {
|
|
304
|
+
holder = parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, gardenId);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
307
|
+
if (code === "ENOENT") {
|
|
308
|
+
// The holder released between our open-wx and our read — retry once.
|
|
309
|
+
const retry = tryCreate();
|
|
310
|
+
if (retry.ok) return { ok: true, claim };
|
|
311
|
+
// Someone else re-grabbed it; report the actual winner (not "corrupt").
|
|
312
|
+
return conflict(readHolder());
|
|
313
|
+
}
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Empty/corrupt/wrong-gid lockfile → conflict (NEVER auto-deleted: could be
|
|
318
|
+
// mid-write, and there is no dead pid to reclaim by).
|
|
319
|
+
if (holder === null) return conflict(null);
|
|
320
|
+
|
|
321
|
+
// Stale reclaim is allowed ONLY for our own host + a provably-dead pid (ESRCH).
|
|
322
|
+
const reclaimable = holder.hostname === hostname && classifyProcessLiveness(holder.pid, killFn) === "dead";
|
|
323
|
+
if (!reclaimable) return conflict(holder);
|
|
324
|
+
|
|
325
|
+
// ── Reclaim under a wx mutex (closes the F2 two-reclaimer race) ────────────
|
|
326
|
+
// The blind unlink this replaced could delete a SUCCESSOR's fresh lock: two
|
|
327
|
+
// dispatchers read the same dead holder, the first reclaimed+recreated, the
|
|
328
|
+
// second's unlink then deleted the first's new lock → both spawned. The mutex
|
|
329
|
+
// serializes every would-be reclaimer: another reclaimer (or a fresh acquirer
|
|
330
|
+
// that EEXISTed on the still-present stale lock and re-entered this branch)
|
|
331
|
+
// loses the marker wx and fails closed. While the stale lock is still present
|
|
332
|
+
// it therefore cannot change under us; the ONE actor that can appear is a fresh
|
|
333
|
+
// acquirer winning the unlink→create gap (its wx then succeeds on the absent
|
|
334
|
+
// path) — handled below as an honest conflict, never a clobber. An EEXIST on
|
|
335
|
+
// the marker is a fail-closed conflict (a permanent conflict is the accepted
|
|
336
|
+
// worst case — same grade as a corrupt lockfile — never a double-spawn).
|
|
337
|
+
let markerFd: number;
|
|
338
|
+
try {
|
|
339
|
+
markerFd = openSync(reclaimMarkerPath, "wx");
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
342
|
+
if (code === "EEXIST") {
|
|
343
|
+
const markerMtime = lockMtimeIso(reclaimMarkerPath);
|
|
344
|
+
const age = markerMtime ? ` (marker mtime ${markerMtime})` : "";
|
|
345
|
+
return conflict(
|
|
346
|
+
holder,
|
|
347
|
+
`reclaim already in progress (or a stale reclaim marker at ${reclaimMarkerPath}${age}); confirm no dispatcher is mid-reclaim, then clear it by hand`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
throw err;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
deps._test_beforeReread?.();
|
|
354
|
+
// Re-read UNDER the mutex: the lock must still be the exact dead claim we
|
|
355
|
+
// judged (Fable's nonce re-compare). If it changed (a normal release +
|
|
356
|
+
// recreate — impossible for a dead holder, but cheap insurance) abort.
|
|
357
|
+
const current = readHolder();
|
|
358
|
+
if (current === null || current.nonce !== holder.nonce) return conflict(current);
|
|
359
|
+
try {
|
|
360
|
+
unlinkSync(lockPath);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
363
|
+
}
|
|
364
|
+
deps._test_beforeRecreate?.();
|
|
365
|
+
const reacquired = tryCreate();
|
|
366
|
+
if (reacquired.ok) return { ok: true, claim };
|
|
367
|
+
// A fresh acquirer slipped into the unlink→create gap — honest conflict.
|
|
368
|
+
return conflict(readHolder());
|
|
369
|
+
} finally {
|
|
370
|
+
try {
|
|
371
|
+
closeSync(markerFd);
|
|
372
|
+
} catch {
|
|
373
|
+
/* fd may already be unusable */
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
unlinkSync(reclaimMarkerPath);
|
|
377
|
+
} catch {
|
|
378
|
+
/* best-effort; a leftover marker just fail-closes the next reclaim */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export type ReleaseResult = "released" | "not-owned" | "absent";
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Release the lock — unlink ONLY when the on-disk nonce is still ours. A lock
|
|
387
|
+
* that was reclaimed and re-acquired by a successor carries a different nonce, so
|
|
388
|
+
* a late release returns `not-owned` and leaves the successor's claim intact. An
|
|
389
|
+
* already-gone lock returns `absent`. This is the second half of the F2 guard:
|
|
390
|
+
* without the nonce check a recycled pid or a stale watcher could delete a live
|
|
391
|
+
* successor's lock. The read passes `claim.gardenId` so a `<A>.lock` carrying a
|
|
392
|
+
* different gardenId (with a coincidental same nonce) is `not-owned`, never freed
|
|
393
|
+
* — path authority is the gid all the way through (GPT 4 / 동결결정3).
|
|
394
|
+
*/
|
|
395
|
+
export function releaseLock(claim: LockClaim, deps: { dir?: string } = {}): ReleaseResult {
|
|
396
|
+
const lockPath = claim.lockPath ?? lockPathFor(claim.gardenId, deps.dir);
|
|
397
|
+
let onDisk: LockClaim | null;
|
|
398
|
+
try {
|
|
399
|
+
onDisk = parseLockClaim(readFileSync(lockPath, "utf8"), lockPath, claim.gardenId);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "absent";
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
if (onDisk === null || onDisk.nonce !== claim.nonce) return "not-owned";
|
|
405
|
+
try {
|
|
406
|
+
unlinkSync(lockPath);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "absent";
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
return "released";
|
|
412
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-mailbox — the 5c-4 meta-mailbox SEND body (0.11 Stage 0 step 5c-4, the
|
|
3
|
+
* LAST 5c transport slice). It is ENQUEUE-ONLY: render the mailbox body from the plan
|
|
4
|
+
* the 5b decider ALREADY chose and drop it in the target citizen's meta-bridge mailbox.
|
|
5
|
+
* It DECIDES NO ROUTING (the dispatch table did) and carries NO release seam — a
|
|
6
|
+
* meta-mailbox plan is the lock-free path (?7), and even when the 5c-2a send hand calls
|
|
7
|
+
* this on a re-resolved mailbox fallback (under a still-held control-socket lock),
|
|
8
|
+
* RELEASE STAYS THE HAND'S SINGLE RESPONSIBILITY. So this module has no `releaseLock`
|
|
9
|
+
* dep AT ALL — a lock leak from the mailbox body is structurally impossible.
|
|
10
|
+
*
|
|
11
|
+
* Two consumers, one body:
|
|
12
|
+
* - the 5c-2a send hand's `deps.sendViaMailbox(plan, lock)` fallback seam
|
|
13
|
+
* (entwurf-v2-send.ts) — a dead control socket re-resolved into a mailbox plan.
|
|
14
|
+
* - the future 5d dispatch runner's direct unsupported-citizen fire-and-forget path
|
|
15
|
+
* (the decider hands back a meta-mailbox plan with a NULL lock).
|
|
16
|
+
* Both call `executeMetaMailboxSend`; the hand reaches it through the async
|
|
17
|
+
* `sendViaMailbox(plan, lock)` adapter the production factory builds.
|
|
18
|
+
*
|
|
19
|
+
* A mailbox has NO in-band refuse — there is no live receiver to answer `success:false`.
|
|
20
|
+
* An enqueue either succeeds (`{success:true}`) or THROWS (citizen gone / fs / identity).
|
|
21
|
+
* The body NEVER folds a throw into `{success:false}`: the send hand maps a thrown error
|
|
22
|
+
* to `failed`+rethrow, which is the honest outcome for a non-delivery. `success:false` is
|
|
23
|
+
* a control-socket RPC notion (the receiver answered and refused) and would be a lie here.
|
|
24
|
+
*
|
|
25
|
+
* ctx-free, dep-injected (the same discipline the other entwurf-v2-* libs keep): the live
|
|
26
|
+
* sender envelope is built at the wiring site and passed in, so this module never imports
|
|
27
|
+
* ExtensionContext.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { LockClaim } from "./entwurf-v2-lock.ts";
|
|
31
|
+
import type { MetaMailboxPlan, RpcSendResult } from "./entwurf-v2-send.ts";
|
|
32
|
+
import { formatMetaMailboxBody, type MailboxSenderEnvelope } from "./meta-mailbox-body.ts";
|
|
33
|
+
import { type EnqueueMetaMessageOptions, type EnqueueMetaMessageResult, enqueueMetaMessage } from "./meta-session.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The ONLY IO seam: enqueue a rendered body into a citizen's mailbox. Injected so the
|
|
37
|
+
* gate proves the enqueue ARGUMENTS plus the no-routing / no-release shape without
|
|
38
|
+
* touching the filesystem. There is deliberately NO release seam and NO routing seam
|
|
39
|
+
* (no inspect / probe / resolve) — the plan is final.
|
|
40
|
+
*/
|
|
41
|
+
export interface MetaMailboxSendDeps {
|
|
42
|
+
enqueue: (opts: EnqueueMetaMessageOptions) => EnqueueMetaMessageResult;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Render the plan into a mailbox body and enqueue it ONCE. `sender` is the resolved
|
|
47
|
+
* envelope (built from live ctx at the wiring site — ctx kept OUT of this module); when
|
|
48
|
+
* it is undefined the raw `plan.message` is enqueued (the same envelope-less fallback the
|
|
49
|
+
* legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
|
|
50
|
+
* caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
|
|
51
|
+
* throw PROPAGATES; it is never converted to `{success:false}`.
|
|
52
|
+
*/
|
|
53
|
+
export function executeMetaMailboxSend(
|
|
54
|
+
plan: MetaMailboxPlan,
|
|
55
|
+
sender: MailboxSenderEnvelope | undefined,
|
|
56
|
+
deps: MetaMailboxSendDeps,
|
|
57
|
+
): RpcSendResult {
|
|
58
|
+
const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
|
|
59
|
+
deps.enqueue({
|
|
60
|
+
gardenId: plan.targetGardenId,
|
|
61
|
+
body,
|
|
62
|
+
sessionsDir: plan.sessionsDir,
|
|
63
|
+
mailboxDir: plan.mailboxDir,
|
|
64
|
+
});
|
|
65
|
+
return { success: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build the production `sendViaMailbox(plan, lock)` adapter the 5c-2a send hand consumes.
|
|
70
|
+
* It IGNORES `lock` entirely (release is the hand's job; a mailbox plan is lock-free) and
|
|
71
|
+
* wraps the sync body in the async dep signature. `senderProvider` is supplied by the
|
|
72
|
+
* wiring site — it calls the private `buildLocalSenderEnvelope(ctx)` and decorates
|
|
73
|
+
* origin/replyable — so this module never imports ExtensionContext. `enqueue` defaults to
|
|
74
|
+
* the real `enqueueMetaMessage`; the gate injects a fake.
|
|
75
|
+
*/
|
|
76
|
+
export function makeProductionSendViaMailbox(opts: {
|
|
77
|
+
senderProvider: () => MailboxSenderEnvelope | undefined;
|
|
78
|
+
enqueue?: (opts: EnqueueMetaMessageOptions) => EnqueueMetaMessageResult;
|
|
79
|
+
}): (plan: MetaMailboxPlan, lock: LockClaim) => Promise<RpcSendResult> {
|
|
80
|
+
const enqueue = opts.enqueue ?? enqueueMetaMessage;
|
|
81
|
+
// `_lock` is named in the signature for the send-hand dep contract but is NEVER read:
|
|
82
|
+
// a mailbox enqueue does not own or release the lock. `async` so a synchronous enqueue
|
|
83
|
+
// throw surfaces as a REJECTED promise (the async dep contract), not a sync throw — the
|
|
84
|
+
// send hand's `await` + try/catch handles either, but a rejection is the honest shape.
|
|
85
|
+
return async (plan: MetaMailboxPlan, _lock: LockClaim): Promise<RpcSendResult> =>
|
|
86
|
+
executeMetaMailboxSend(plan, opts.senderProvider(), { enqueue });
|
|
87
|
+
}
|