@junghanacs/entwurf 0.13.1 → 0.14.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 +48 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +45 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +27 -27
- package/VERIFY.md +22 -14
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +9 -4
- package/docs/external-mcp-host.md +4 -5
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +164 -28
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +22 -11
- package/pi-extensions/entwurf-control.ts +218 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/run.sh +471 -201
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +157 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +98 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +84 -76
- package/scripts/check-release-gate-outcomes.ts +127 -7
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +0 -1037
- package/scripts/mutants/release-gate.json +35 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +58 -18
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-native-push-live.ts +6 -17
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -275
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* entwurf-v2-spawn — the 5c-3a spawn-bg RESUME watcher hand (0.11 Stage 0 step 5c-3a).
|
|
3
|
-
* It WIRES the real spawn + socket-observe IO onto the pure release reducer (5c-1):
|
|
4
|
-
* launch the resume child, watch for the FIRST observable liveness transition, feed
|
|
5
|
-
* that observation to `reduceRelease`, and release the held lock EXACTLY ONCE — or, if
|
|
6
|
-
* NO observation can be obtained, RETAIN the lock and surface the evidence rather than
|
|
7
|
-
* release blind. Every IO seam is an injected dep (the gate fakes it with controlled
|
|
8
|
-
* promises), so the spawn→observe→release ORDERING is gate-provable without a real
|
|
9
|
-
* child, socket, or timer — the same "pure-before-IO, IO-via-dep" discipline 5b/5c-2
|
|
10
|
-
* used.
|
|
11
|
-
*
|
|
12
|
-
* The load-bearing contract (Fable 3, the whole reason 5c is sliced pure-before-IO):
|
|
13
|
-
* TIMEOUT IS NOT A RELEASE EVENT. A spawn-bg dispatch holds its per-gid lock until an
|
|
14
|
-
* OBSERVED transition — `socket-alive` (the resumed child stood its control socket up)
|
|
15
|
-
* or `child-exited` (any code, incl. null = killed by signal). A bare `observeTimeoutMs`
|
|
16
|
-
* expiry proves NOTHING (the child may stand its socket up a moment later), so releasing
|
|
17
|
-
* on it would reopen the exact double-spawn window 5a's lock exists to close. Instead the
|
|
18
|
-
* timeout RESOLVES BY OBSERVATION: kill the child, then wait a BOUNDED `killGraceMs` for
|
|
19
|
-
* the kill to produce a real `child-exited` (or a racing `socket-alive`). Only THAT
|
|
20
|
-
* observation releases. If even the grace elapses with no observation, the hand does NOT
|
|
21
|
-
* release — it returns a `lock-retained` diagnostic (released:false, with pid / socket /
|
|
22
|
-
* lockPath / timeouts) so an operator can SEE the long-held lock, exactly as F2-P2
|
|
23
|
-
* ("관측 가능해야 수용") demands. The function always returns BOUNDED — it never hangs.
|
|
24
|
-
*
|
|
25
|
-
* Post-spawn unexpected dep failure follows the SAME rule (GPT 5c-3a correction): once a
|
|
26
|
-
* child exists, an observation-less release is forbidden. A watch/timer/kill dep that
|
|
27
|
-
* throws is handled by best-effort kill → bounded attempt to OBSERVE the exit → release
|
|
28
|
-
* if observed, else `lock-retained` fail-closed. There is NO direct-release escape hatch:
|
|
29
|
-
* `deps.releaseLock` is reached ONLY through `reduceRelease` on a real observation event.
|
|
30
|
-
*
|
|
31
|
-
* Release authority is ALWAYS the LockClaim the decider handed over; the watcher reads
|
|
32
|
-
* ONLY `plan.expectedSocketPath` and never re-derives a socket path or a lock by gid.
|
|
33
|
-
*/
|
|
34
|
-
import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
|
|
35
|
-
function tagSocket(p) {
|
|
36
|
-
return p.then(() => ({ tag: "socket" }));
|
|
37
|
-
}
|
|
38
|
-
function tagExit(p) {
|
|
39
|
-
return p.then((code) => ({ tag: "exit", code }));
|
|
40
|
-
}
|
|
41
|
-
function tagTimeout(p) {
|
|
42
|
-
return p.then(() => ({ tag: "timeout" }));
|
|
43
|
-
}
|
|
44
|
-
function errMsg(err) {
|
|
45
|
-
return err instanceof Error ? err.message : String(err);
|
|
46
|
-
}
|
|
47
|
-
/** Swallow a watcher promise's eventual rejection when it is abandoned without a race
|
|
48
|
-
* handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
|
|
49
|
-
* loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
|
|
50
|
-
function defuse(p) {
|
|
51
|
-
if (p)
|
|
52
|
-
void p.catch(() => { });
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Launch a spawn-bg resume child and watch for the first observable liveness transition,
|
|
56
|
-
* releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
|
|
57
|
-
* surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
|
|
58
|
-
* the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
|
|
59
|
-
* wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
|
|
60
|
-
*
|
|
61
|
-
* A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
|
|
62
|
-
* happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
|
|
63
|
-
* 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
|
|
64
|
-
* state is a returned `SpawnBgResumeResult`.
|
|
65
|
-
*/
|
|
66
|
-
export async function executeSpawnBgResume(plan, lock, deps) {
|
|
67
|
-
// Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
|
|
68
|
-
// policy is release-after-spawn-observation and the lock is non-null.
|
|
69
|
-
const policy = decideReleasePolicy(plan, lock);
|
|
70
|
-
const held = lock;
|
|
71
|
-
let state = initialReleaseState();
|
|
72
|
-
// The ONLY path to deps.releaseLock: fold a real observation event through the reducer
|
|
73
|
-
// and release iff (and exactly once) the reducer says so. No direct-release hatch.
|
|
74
|
-
const fire = (event) => {
|
|
75
|
-
const r = reduceRelease(policy, state, event);
|
|
76
|
-
state = r.state;
|
|
77
|
-
if (r.shouldRelease)
|
|
78
|
-
deps.releaseLock(held);
|
|
79
|
-
};
|
|
80
|
-
// Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
|
|
81
|
-
// watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
|
|
82
|
-
let child;
|
|
83
|
-
try {
|
|
84
|
-
child = await deps.spawnChild(plan);
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
fire({ kind: "spawn-start-failed", error: errMsg(err) });
|
|
88
|
-
return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
|
|
89
|
-
}
|
|
90
|
-
const controller = new AbortController();
|
|
91
|
-
const { signal } = controller;
|
|
92
|
-
// Created ONCE and reused across the primary race and the kill-grace race; in the
|
|
93
|
-
// timeout branch both are still pending (timeout won), so re-racing them is safe.
|
|
94
|
-
//
|
|
95
|
-
// S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
|
|
96
|
-
// SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
|
|
97
|
-
// the send hand's case-13 backstop covers). The child already exists, so this is a
|
|
98
|
-
// post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
|
|
99
|
-
// Best-effort kill, defuse any watcher that DID start (so its later rejection is not
|
|
100
|
-
// unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
|
|
101
|
-
// observe the exit — awaitChildExit may itself be the thrower; retained is the honest
|
|
102
|
-
// floor. (The async-rejection path is handled later by the race catch → backstop.)
|
|
103
|
-
let socketP;
|
|
104
|
-
let exitP;
|
|
105
|
-
try {
|
|
106
|
-
socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
|
|
107
|
-
exitP = tagExit(deps.awaitChildExit(child, signal));
|
|
108
|
-
}
|
|
109
|
-
catch (err) {
|
|
110
|
-
try {
|
|
111
|
-
deps.killChild(child);
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
// best-effort
|
|
115
|
-
}
|
|
116
|
-
controller.abort();
|
|
117
|
-
defuse(socketP);
|
|
118
|
-
defuse(exitP);
|
|
119
|
-
return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
// Primary observation race. `timeout` participates but is NEVER a release event.
|
|
123
|
-
let first;
|
|
124
|
-
try {
|
|
125
|
-
const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
|
|
126
|
-
first = await Promise.race([socketP, exitP, timeoutP]);
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
// A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
|
|
130
|
-
return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
|
|
131
|
-
}
|
|
132
|
-
if (first.tag === "socket") {
|
|
133
|
-
fire({ kind: "socket-alive" });
|
|
134
|
-
return { kind: "socket-alive", released: true, pid: child.pid };
|
|
135
|
-
}
|
|
136
|
-
if (first.tag === "exit") {
|
|
137
|
-
fire({ kind: "child-exited", code: first.code });
|
|
138
|
-
return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
|
|
139
|
-
}
|
|
140
|
-
// first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
|
|
141
|
-
// grace for the kill to produce a real child-exited (or a racing socket-alive).
|
|
142
|
-
try {
|
|
143
|
-
deps.killChild(child);
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
// kill itself threw — still try to observe within the grace; if nothing, retain.
|
|
147
|
-
}
|
|
148
|
-
let second;
|
|
149
|
-
try {
|
|
150
|
-
const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
|
|
151
|
-
second = await Promise.race([socketP, exitP, graceP]);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
// A dep rejected during the grace → cannot confirm; retain the lock, surface it.
|
|
155
|
-
return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
|
|
156
|
-
}
|
|
157
|
-
if (second.tag === "socket") {
|
|
158
|
-
fire({ kind: "socket-alive" });
|
|
159
|
-
return { kind: "socket-alive", released: true, pid: child.pid };
|
|
160
|
-
}
|
|
161
|
-
if (second.tag === "exit") {
|
|
162
|
-
fire({ kind: "child-exited", code: second.code });
|
|
163
|
-
return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
|
|
164
|
-
}
|
|
165
|
-
// Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
|
|
166
|
-
// retain the lock and surface the evidence (bounded return, never a hang).
|
|
167
|
-
return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
// Tear down the losing watchers (their timers / FS watches) on every exit.
|
|
171
|
-
controller.abort();
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
|
|
176
|
-
* before any observation. An observation-less release is forbidden (it reopens the
|
|
177
|
-
* double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
|
|
178
|
-
* release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
|
|
179
|
-
* already-suspect socket watcher) plus a fresh grace timer.
|
|
180
|
-
*/
|
|
181
|
-
async function backstop(plan, deps, child, lockPath, exitP, signal, fire, originalError) {
|
|
182
|
-
try {
|
|
183
|
-
deps.killChild(child);
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
// best-effort
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
|
|
190
|
-
const obs = await Promise.race([exitP, graceP]);
|
|
191
|
-
if (obs.tag === "exit") {
|
|
192
|
-
fire({ kind: "child-exited", code: obs.code });
|
|
193
|
-
return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// exit watcher itself was the failing dep (or threw again) → fall through to retain.
|
|
198
|
-
}
|
|
199
|
-
return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
|
|
200
|
-
}
|
|
201
|
-
function retained(plan, child, reason, killGraceMs, lockPath, error) {
|
|
202
|
-
return {
|
|
203
|
-
kind: "lock-retained",
|
|
204
|
-
released: false,
|
|
205
|
-
reason,
|
|
206
|
-
error,
|
|
207
|
-
diagnostic: {
|
|
208
|
-
targetGardenId: plan.targetGardenId,
|
|
209
|
-
pid: child.pid,
|
|
210
|
-
expectedSocketPath: plan.expectedSocketPath,
|
|
211
|
-
lockPath,
|
|
212
|
-
observeTimeoutMs: plan.observeTimeoutMs,
|
|
213
|
-
killGraceMs,
|
|
214
|
-
},
|
|
215
|
-
};
|
|
216
|
-
}
|
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* entwurf-v2-spawn-production — the PRODUCTION `SpawnBgResumeDeps` factory for the 5c-3a
|
|
3
|
-
* spawn-bg watcher (0.11 Stage 0 step 5c-3c). It wires the six injected IO seams the pure
|
|
4
|
-
* watcher (`executeSpawnBgResume`) declares onto the real world: spawn a resume child as a
|
|
5
|
-
* resident citizen, observe its control socket by CONNECTABILITY (not mere existence),
|
|
6
|
-
* watch its exit, time the observe window, and kill on timeout. The watcher's release
|
|
7
|
-
* policy is untouched — this module only supplies the IO.
|
|
8
|
-
*
|
|
9
|
-
* Every seam stays injectable (defaults = the real fns) so a DETERMINISTIC gate drives
|
|
10
|
-
* spawnChild's argv, the exit/timeout/kill wiring, and the socket-alive verdict WITHOUT a
|
|
11
|
-
* real pi spawn or socket — the heavy live path (a real `pi --entwurf-control` resident
|
|
12
|
-
* child + a real unix-socket connect) is proven once by a separate opt-in smoke
|
|
13
|
-
* (`smoke-entwurf-v2-spawn-live`), kept OUT of `pnpm check` so the every-commit loop stays
|
|
14
|
-
* fast and deterministic (D5: chain = deterministic gate; live smoke = phase gate before 5d).
|
|
15
|
-
*
|
|
16
|
-
* The factory does NOT capture `plan` or `lock` (D3): `SpawnBgResumeDeps.spawnChild(plan)`
|
|
17
|
-
* already takes the plan, and the lock is the watcher's authority — it flows to
|
|
18
|
-
* `deps.releaseLock` from the watcher, never from here. A captured plan/lock would be a
|
|
19
|
-
* second authority that could drift from the one the watcher holds.
|
|
20
|
-
*
|
|
21
|
-
* socket-alive = CONNECTABLE, never file-exists (GPT 5c-3c, Q5): inspect the EXACT
|
|
22
|
-
* `plan.expectedSocketPath` (via the path-addressed `inspectControlSocketPath` — no gid
|
|
23
|
-
* re-derivation), map to liveness through the shared `mapInspectionToLiveness`, then apply
|
|
24
|
-
* `socketWatchVerdict`: a connectable socket is alive; a forged address (symlink / not a
|
|
25
|
-
* socket) is rejected IMMEDIATELY (time does not heal a forged path → the watcher's backstop
|
|
26
|
-
* kills and fail-closes to a retained lock); a dead/indeterminate socket keeps waiting (the
|
|
27
|
-
* dormant citizen's stale socket file, or a stall, is "not up yet", not a failure).
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { type ChildProcess, spawn } from "node:child_process";
|
|
31
|
-
import { existsSync } from "node:fs";
|
|
32
|
-
import * as fs from "node:fs/promises";
|
|
33
|
-
import { getEntwurfExplicitExtensions, mirrorChildStderr, readSessionIdentity } from "./entwurf-core.ts";
|
|
34
|
-
import { buildResumePiArgs } from "./entwurf-resume-args.ts";
|
|
35
|
-
import type { LockClaim } from "./entwurf-v2-lock.ts";
|
|
36
|
-
import { releaseLock } from "./entwurf-v2-lock.ts";
|
|
37
|
-
import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "./entwurf-v2-spawn.ts";
|
|
38
|
-
import { readAddressableMetaIdentity } from "./meta-session.ts";
|
|
39
|
-
import { inspectControlSocketPath, type LstatLike, mapInspectionToLiveness } from "./socket-discovery.ts";
|
|
40
|
-
import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
|
|
41
|
-
|
|
42
|
-
// ── socketWatchVerdict (pure — the R2 watch policy, gate-pinned) ──────────────
|
|
43
|
-
export type SocketWatchVerdict = "alive" | "wait" | "forged";
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Translate a mapped socket observation into the watcher's poll decision.
|
|
47
|
-
* - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
|
|
48
|
-
* state: time cannot turn a forged path into a live socket, so the poll must REJECT
|
|
49
|
-
* immediately (→ watcher backstop → kill → retained), never keep waiting.
|
|
50
|
-
* - alive — a connectable socket. Resolve: the resumed citizen is up.
|
|
51
|
-
* - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
|
|
52
|
-
* indeterminate (a stall). Keep polling until the socket connects, or the observe
|
|
53
|
-
* timeout / abort ends the wait.
|
|
54
|
-
*/
|
|
55
|
-
export function socketWatchVerdict(
|
|
56
|
-
mapped: { liveness: SocketLiveness; socketPath: string } | { addressConflict: true },
|
|
57
|
-
): SocketWatchVerdict {
|
|
58
|
-
if ("addressConflict" in mapped) return "forged";
|
|
59
|
-
return mapped.liveness === "alive" ? "alive" : "wait";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── the spawned child handle (D4) ────────────────────────────────────────────
|
|
63
|
-
// A minimal structural view of the process the deps share. The real ChildProcess
|
|
64
|
-
// satisfies it; the gate fakes it. The watcher sees only `pid` (SpawnedChild); the
|
|
65
|
-
// production deps carry `proc` so awaitChildExit / killChild act on the SAME process.
|
|
66
|
-
export interface SpawnedProcHandle {
|
|
67
|
-
pid?: number;
|
|
68
|
-
kill(signal?: NodeJS.Signals | number): boolean;
|
|
69
|
-
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
70
|
-
removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
interface ProductionSpawnedChild extends SpawnedChild {
|
|
74
|
-
proc: SpawnedProcHandle;
|
|
75
|
-
/** Exit captured EAGERLY at proc creation (B2): the `exit` listener is installed the
|
|
76
|
-
* instant the proc exists, BEFORE the watcher's awaitChildExit runs, so a child that
|
|
77
|
-
* exits in the micro-gap between spawnChild resolving and awaitChildExit registering is
|
|
78
|
-
* never missed (which would pin the lock through timeout→kill→retained). Resolve-ONLY
|
|
79
|
-
* (never rejects) so it is safe to leave racing an abort in awaitChildExit. */
|
|
80
|
-
exitPromise: Promise<number | null>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
|
|
84
|
-
* NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
|
|
85
|
-
function requireProductionChild(child: SpawnedChild): ProductionSpawnedChild {
|
|
86
|
-
const c = child as Partial<ProductionSpawnedChild>;
|
|
87
|
-
if (!c.proc || !c.exitPromise) {
|
|
88
|
-
throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
|
|
89
|
-
}
|
|
90
|
-
return c as ProductionSpawnedChild;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── launch identity (the spawnChild preamble, one injectable seam) ───────────
|
|
94
|
-
/** The launch-time facts buildResumePiArgs needs, resolved from the meta-record + the
|
|
95
|
-
* recorded transcript (record authority, #50 C2/C3); the gate injects a fake so
|
|
96
|
-
* spawnChild's argv is provable without a real session file. */
|
|
97
|
-
export interface LaunchIdentity {
|
|
98
|
-
/** The EXACT session JSONL to resume — `pi --session <path>`. */
|
|
99
|
-
sessionFile: string;
|
|
100
|
-
cwd: string;
|
|
101
|
-
explicitExtensionArgs: readonly string[];
|
|
102
|
-
provider: string | null | undefined;
|
|
103
|
-
model: string;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Resolve launch identity for a resume. The TARGET is now resolved through the
|
|
108
|
-
* meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
|
|
109
|
-
* header scan for a JSONL whose header id equalled the garden id — which only worked
|
|
110
|
-
* while entwurf forced pi's session id to BE the garden id. With the record minting the
|
|
111
|
-
* address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
|
|
112
|
-
* keeping it would not have been a "smaller change", it would have been a broken one.
|
|
113
|
-
*
|
|
114
|
-
* The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
|
|
115
|
-
* (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
|
|
116
|
-
* exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
|
|
117
|
-
* citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
|
|
118
|
-
* with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
|
|
119
|
-
* file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
|
|
120
|
-
* record remembers whose it is — a mismatch means the transcriptPath is stale or
|
|
121
|
-
* foreign, and resuming it would put a turn into a different being's session).
|
|
122
|
-
*
|
|
123
|
-
* That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
|
|
124
|
-
* GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
|
|
125
|
-
* integrity check and resume the SAME transcript concurrently under two different locks.
|
|
126
|
-
* Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
|
|
127
|
-
* a resume is exactly the moment a record stops being data and becomes an address.
|
|
128
|
-
*
|
|
129
|
-
* Everything else is unchanged authority: readSessionIdentity (first model_change) for
|
|
130
|
-
* provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
|
|
131
|
-
* Throws on anything that makes a resume impossible; each throw becomes the watcher's
|
|
132
|
-
* `spawn-start-failed` (no child to watch → release), never a silent no-op.
|
|
133
|
-
*/
|
|
134
|
-
export function resolveResumeLaunchIdentity(plan: SpawnBgPlan): LaunchIdentity {
|
|
135
|
-
const record = readAddressableMetaIdentity(plan.sessionId);
|
|
136
|
-
if (record.backend !== "pi") {
|
|
137
|
-
throw new Error(
|
|
138
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} is a ${record.backend} citizen — ` +
|
|
139
|
-
`spawn-bg resume is a host-adapter capability, and its domain currently contains backend pi only. ` +
|
|
140
|
-
`This is a relaunch capability, not the control-socket rail and not citizen rank.`,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
const sessionFile = record.transcriptPath;
|
|
144
|
-
if (!sessionFile) {
|
|
145
|
-
throw new Error(
|
|
146
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded transcriptPath — ` +
|
|
147
|
-
`the citizen never wrote a session file (no turn yet), so there is nothing to resume.`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
// A recorded path is only a resume target while the file is actually on disk.
|
|
151
|
-
// Without this check a missing transcript falls through readSessionIdentity's
|
|
152
|
-
// ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
|
|
153
|
-
// the transcript was deleted, or the record carries a phantom path minted
|
|
154
|
-
// before birth guarded on file existence.
|
|
155
|
-
if (!existsSync(sessionFile)) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} recorded transcriptPath "${sessionFile}" ` +
|
|
158
|
-
`does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
|
|
159
|
-
`path from a pre-guard birth; nothing to resume.`,
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
const identity = readSessionIdentity(sessionFile);
|
|
163
|
-
const resumeModel = identity?.modelId ?? null;
|
|
164
|
-
if (!identity || !resumeModel) {
|
|
165
|
-
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
|
|
166
|
-
}
|
|
167
|
-
if (identity.sessionId !== record.nativeSessionId) {
|
|
168
|
-
throw new Error(
|
|
169
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
|
|
170
|
-
`does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
|
|
171
|
-
`is stale or points at a foreign session file; refusing to resume another being's transcript.`,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
|
|
175
|
-
if (explicitExtensions.unresolvedAcpIntent) {
|
|
176
|
-
throw new Error(
|
|
177
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
|
|
178
|
-
`extension could not be resolved — refusing to resume with an unknown provider (#29).`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
if (!identity.cwd) {
|
|
182
|
-
throw new Error(
|
|
183
|
-
`entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`,
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
return {
|
|
187
|
-
sessionFile,
|
|
188
|
-
cwd: identity.cwd,
|
|
189
|
-
explicitExtensionArgs: explicitExtensions.args,
|
|
190
|
-
provider: explicitExtensions.provider ?? identity.provider,
|
|
191
|
-
model: explicitExtensions.modelOverride ?? resumeModel,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ── the injectable seams (defaults = the real world) ─────────────────────────
|
|
196
|
-
export interface ProductionSpawnOpts {
|
|
197
|
-
/** Poll interval for awaitSocketAlive's wait loop (ms). */
|
|
198
|
-
pollIntervalMs?: number;
|
|
199
|
-
/** Bounded wait, after a kill, for the resulting child-exited (the watcher's killGraceMs). */
|
|
200
|
-
killGraceMs?: number;
|
|
201
|
-
/** Connect-probe timeout for a socket-file inspection (ms). */
|
|
202
|
-
probeTimeoutMs?: number;
|
|
203
|
-
/** Resolve launch identity (default = resolveResumeLaunchIdentity). */
|
|
204
|
-
resolveIdentity?: (plan: SpawnBgPlan) => LaunchIdentity;
|
|
205
|
-
/** Spawn the resume child and return its handle (default = real `pi` spawn + unref +
|
|
206
|
-
* mirrorChildStderr). The child inherits this process's env untouched — the resume-marker
|
|
207
|
-
* env the factory used to plant died with the name-mirror guard it authorized (#50 C3).
|
|
208
|
-
* A throw becomes the watcher's spawn-start-failed. */
|
|
209
|
-
spawnChild?: (cmd: string, args: readonly string[], cwd: string) => SpawnedProcHandle;
|
|
210
|
-
/** lstat for socket inspection (default = fs.lstat). */
|
|
211
|
-
lstatFn?: (p: string) => Promise<LstatLike>;
|
|
212
|
-
/** Connect probe for a socket-file (default = probeSocketLiveness). */
|
|
213
|
-
probeFn?: (socketPath: string) => Promise<SocketLiveness>;
|
|
214
|
-
/** Release the held lock (default = the lock primitive's releaseLock). */
|
|
215
|
-
releaseFn?: (lock: LockClaim) => void;
|
|
216
|
-
/** Timer primitives (default = global setTimeout/clearTimeout) — injected so the gate
|
|
217
|
-
* proves scheduling + abort-clear deterministically. */
|
|
218
|
-
setTimeoutFn?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
219
|
-
clearTimeoutFn?: (t: ReturnType<typeof setTimeout>) => void;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const DEFAULT_POLL_INTERVAL_MS = 150;
|
|
223
|
-
const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
224
|
-
|
|
225
|
-
/** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
|
|
226
|
-
* the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
|
|
227
|
-
* from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
|
|
228
|
-
function defaultSpawnChild(cmd: string, args: readonly string[], cwd: string): SpawnedProcHandle {
|
|
229
|
-
const proc: ChildProcess = spawn(cmd, [...args], {
|
|
230
|
-
cwd,
|
|
231
|
-
shell: false,
|
|
232
|
-
detached: true,
|
|
233
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
234
|
-
});
|
|
235
|
-
proc.unref();
|
|
236
|
-
mirrorChildStderr(proc);
|
|
237
|
-
return proc;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
|
|
242
|
-
* NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
|
|
243
|
-
* the deterministic gate; the defaults are the real IO.
|
|
244
|
-
*/
|
|
245
|
-
export function makeProductionSpawnBgResumeDeps(opts: ProductionSpawnOpts = {}): SpawnBgResumeDeps {
|
|
246
|
-
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
247
|
-
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
248
|
-
const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
|
|
249
|
-
const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
|
|
250
|
-
const lstatFn = opts.lstatFn ?? ((p: string) => fs.lstat(p));
|
|
251
|
-
const probeFn =
|
|
252
|
-
opts.probeFn ?? ((socketPath: string) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
|
|
253
|
-
const releaseFn = opts.releaseFn ?? ((lock: LockClaim) => void releaseLock(lock));
|
|
254
|
-
const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
255
|
-
const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
|
|
256
|
-
|
|
257
|
-
// An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
|
|
258
|
-
// by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
|
|
259
|
-
const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
260
|
-
new Promise<void>((resolve, reject) => {
|
|
261
|
-
if (signal.aborted) {
|
|
262
|
-
reject(new Error("aborted"));
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const timer = setTimeoutFn(() => {
|
|
266
|
-
signal.removeEventListener("abort", onAbort);
|
|
267
|
-
resolve();
|
|
268
|
-
}, ms);
|
|
269
|
-
const onAbort = (): void => {
|
|
270
|
-
clearTimeoutFn(timer);
|
|
271
|
-
reject(new Error("aborted"));
|
|
272
|
-
};
|
|
273
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
killGraceMs,
|
|
278
|
-
|
|
279
|
-
spawnChild: async (plan: SpawnBgPlan): Promise<SpawnedChild> => {
|
|
280
|
-
const identity = resolveIdentity(plan);
|
|
281
|
-
const args = buildResumePiArgs({
|
|
282
|
-
variant: "v2-control",
|
|
283
|
-
sessionFile: identity.sessionFile,
|
|
284
|
-
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
285
|
-
provider: identity.provider,
|
|
286
|
-
model: identity.model,
|
|
287
|
-
prompt: plan.prompt,
|
|
288
|
-
launchArgs: plan.launchArgs,
|
|
289
|
-
});
|
|
290
|
-
const proc = spawnChildFn("pi", args, identity.cwd);
|
|
291
|
-
|
|
292
|
-
// B2: capture exit EAGERLY — the instant the proc exists, before we even await the
|
|
293
|
-
// spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
|
|
294
|
-
// only (a signal kill reports null); it never rejects, so racing it against abort is safe.
|
|
295
|
-
const exitPromise = new Promise<number | null>((resolve) => {
|
|
296
|
-
proc.on("exit", (code: unknown) => resolve(typeof code === "number" ? code : null));
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
|
|
300
|
-
// failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
|
|
301
|
-
// for the `spawn` event to confirm a real start; an `error` before it rejects, which the
|
|
302
|
-
// watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
|
|
303
|
-
// spawn-started that later stalls into a wrongful retained lock.
|
|
304
|
-
await new Promise<void>((resolve, reject) => {
|
|
305
|
-
const onSpawn = (): void => {
|
|
306
|
-
cleanup();
|
|
307
|
-
resolve();
|
|
308
|
-
};
|
|
309
|
-
const onError = (err: unknown): void => {
|
|
310
|
-
cleanup();
|
|
311
|
-
reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
|
|
312
|
-
};
|
|
313
|
-
const cleanup = (): void => {
|
|
314
|
-
proc.removeListener("spawn", onSpawn);
|
|
315
|
-
proc.removeListener("error", onError);
|
|
316
|
-
};
|
|
317
|
-
proc.on("spawn", onSpawn);
|
|
318
|
-
proc.on("error", onError);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const child: ProductionSpawnedChild = { pid: proc.pid, proc, exitPromise };
|
|
322
|
-
return child;
|
|
323
|
-
},
|
|
324
|
-
|
|
325
|
-
awaitSocketAlive: async (socketPath: string, signal: AbortSignal): Promise<void> => {
|
|
326
|
-
// Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
|
|
327
|
-
// reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
|
|
328
|
-
while (!signal.aborted) {
|
|
329
|
-
const inspection = await inspectControlSocketPath(socketPath, lstatFn);
|
|
330
|
-
const mapped = await mapInspectionToLiveness(inspection, probeFn);
|
|
331
|
-
const verdict = socketWatchVerdict(mapped);
|
|
332
|
-
if (verdict === "alive") return;
|
|
333
|
-
if (verdict === "forged") {
|
|
334
|
-
throw new Error(
|
|
335
|
-
`entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
// wait — sleep one interval, honoring abort (rejects → loop exits below).
|
|
339
|
-
await abortableDelay(pollIntervalMs, signal);
|
|
340
|
-
}
|
|
341
|
-
throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
|
|
342
|
-
},
|
|
343
|
-
|
|
344
|
-
awaitChildExit: (child: SpawnedChild, signal: AbortSignal): Promise<number | null> =>
|
|
345
|
-
// Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
|
|
346
|
-
// B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
|
|
347
|
-
// unhandled rejection; only the abort branch rejects.
|
|
348
|
-
new Promise<number | null>((resolve, reject) => {
|
|
349
|
-
const { exitPromise } = requireProductionChild(child);
|
|
350
|
-
if (signal.aborted) {
|
|
351
|
-
reject(new Error("aborted"));
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
const onAbort = (): void => reject(new Error("aborted"));
|
|
355
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
356
|
-
exitPromise.then((code) => {
|
|
357
|
-
signal.removeEventListener("abort", onAbort);
|
|
358
|
-
resolve(code);
|
|
359
|
-
});
|
|
360
|
-
}),
|
|
361
|
-
|
|
362
|
-
awaitTimeout: (ms: number, signal: AbortSignal): Promise<void> => abortableDelay(ms, signal),
|
|
363
|
-
|
|
364
|
-
killChild: (child: SpawnedChild): void => {
|
|
365
|
-
// SIGTERM only this slice — the watcher's killGrace then waits for the resulting
|
|
366
|
-
// child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
|
|
367
|
-
// separate policy + smoke). Best-effort: the watcher catches a throw here.
|
|
368
|
-
requireProductionChild(child).proc.kill("SIGTERM");
|
|
369
|
-
},
|
|
370
|
-
|
|
371
|
-
releaseLock: (lock: LockClaim): void => releaseFn(lock),
|
|
372
|
-
};
|
|
373
|
-
}
|