@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,337 @@
|
|
|
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 * as fs from "node:fs/promises";
|
|
32
|
+
import {
|
|
33
|
+
findSessionFileById,
|
|
34
|
+
getEntwurfExplicitExtensions,
|
|
35
|
+
mirrorChildStderr,
|
|
36
|
+
readSessionIdentity,
|
|
37
|
+
} from "./entwurf-core.ts";
|
|
38
|
+
import { buildResumePiArgs } from "./entwurf-resume-args.ts";
|
|
39
|
+
import type { LockClaim } from "./entwurf-v2-lock.ts";
|
|
40
|
+
import { releaseLock } from "./entwurf-v2-lock.ts";
|
|
41
|
+
import { V2_RESUME_RESIDENT_SESSION_ENV } from "./entwurf-v2-resume-marker.ts";
|
|
42
|
+
import type { SpawnBgPlan, SpawnBgResumeDeps, SpawnedChild } from "./entwurf-v2-spawn.ts";
|
|
43
|
+
import { inspectControlSocketPath, type LstatLike, mapInspectionToLiveness } from "./socket-discovery.ts";
|
|
44
|
+
import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
|
|
45
|
+
|
|
46
|
+
// ── socketWatchVerdict (pure — the R2 watch policy, gate-pinned) ──────────────
|
|
47
|
+
export type SocketWatchVerdict = "alive" | "wait" | "forged";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Translate a mapped socket observation into the watcher's poll decision.
|
|
51
|
+
* - forged — an address-conflict (symlink / not-a-socket). NOT a transient liveness
|
|
52
|
+
* state: time cannot turn a forged path into a live socket, so the poll must REJECT
|
|
53
|
+
* immediately (→ watcher backstop → kill → retained), never keep waiting.
|
|
54
|
+
* - alive — a connectable socket. Resolve: the resumed citizen is up.
|
|
55
|
+
* - wait — dead (the dormant citizen's stale/absent socket — "not up yet") or
|
|
56
|
+
* indeterminate (a stall). Keep polling until the socket connects, or the observe
|
|
57
|
+
* timeout / abort ends the wait.
|
|
58
|
+
*/
|
|
59
|
+
export function socketWatchVerdict(
|
|
60
|
+
mapped: { liveness: SocketLiveness; socketPath: string } | { addressConflict: true },
|
|
61
|
+
): SocketWatchVerdict {
|
|
62
|
+
if ("addressConflict" in mapped) return "forged";
|
|
63
|
+
return mapped.liveness === "alive" ? "alive" : "wait";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── the spawned child handle (D4) ────────────────────────────────────────────
|
|
67
|
+
// A minimal structural view of the process the deps share. The real ChildProcess
|
|
68
|
+
// satisfies it; the gate fakes it. The watcher sees only `pid` (SpawnedChild); the
|
|
69
|
+
// production deps carry `proc` so awaitChildExit / killChild act on the SAME process.
|
|
70
|
+
export interface SpawnedProcHandle {
|
|
71
|
+
pid?: number;
|
|
72
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
73
|
+
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
74
|
+
removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ProductionSpawnedChild extends SpawnedChild {
|
|
78
|
+
proc: SpawnedProcHandle;
|
|
79
|
+
/** Exit captured EAGERLY at proc creation (B2): the `exit` listener is installed the
|
|
80
|
+
* instant the proc exists, BEFORE the watcher's awaitChildExit runs, so a child that
|
|
81
|
+
* exits in the micro-gap between spawnChild resolving and awaitChildExit registering is
|
|
82
|
+
* never missed (which would pin the lock through timeout→kill→retained). Resolve-ONLY
|
|
83
|
+
* (never rejects) so it is safe to leave racing an abort in awaitChildExit. */
|
|
84
|
+
exitPromise: Promise<number | null>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Narrow a watcher-supplied SpawnedChild back to the production handle. A child that is
|
|
88
|
+
* NOT one we spawned (no `proc` / `exitPromise`) is a mis-wire — fail loud, never no-op. */
|
|
89
|
+
function requireProductionChild(child: SpawnedChild): ProductionSpawnedChild {
|
|
90
|
+
const c = child as Partial<ProductionSpawnedChild>;
|
|
91
|
+
if (!c.proc || !c.exitPromise) {
|
|
92
|
+
throw new Error("entwurf-v2-spawn-production: child handle has no proc/exitPromise (mis-wired SpawnBgResumeDeps).");
|
|
93
|
+
}
|
|
94
|
+
return c as ProductionSpawnedChild;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── launch identity (the spawnChild preamble, one injectable seam) ───────────
|
|
98
|
+
/** The launch-time facts buildResumePiArgs needs, resolved from the saved session. The
|
|
99
|
+
* default reads them the SAME way the legacy resume launcher does (header authority); the
|
|
100
|
+
* gate injects a fake so spawnChild's argv is provable without a real session file. */
|
|
101
|
+
export interface LaunchIdentity {
|
|
102
|
+
cwd: string;
|
|
103
|
+
explicitExtensionArgs: readonly string[];
|
|
104
|
+
provider: string | null | undefined;
|
|
105
|
+
model: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve launch identity for a resume from the saved session — the same authority the
|
|
110
|
+
* legacy launcher uses (readSessionIdentity = first model_change; getEntwurfExplicitExtensions
|
|
111
|
+
* = bridge re-injection / #29 fail-fast), MINUS the legacy completion-delivery tail (that is
|
|
112
|
+
* 5d). Throws on anything that makes a resume impossible — no session file, no recorded
|
|
113
|
+
* model, an unresolvable ACP bridge, or no header cwd (NEVER falls back to process.cwd, #9).
|
|
114
|
+
* Each throw becomes the watcher's `spawn-start-failed` (no child to watch → release).
|
|
115
|
+
*/
|
|
116
|
+
export function resolveResumeLaunchIdentity(plan: SpawnBgPlan): LaunchIdentity {
|
|
117
|
+
const sessionFile = findSessionFileById(plan.sessionId);
|
|
118
|
+
if (!sessionFile) {
|
|
119
|
+
throw new Error(`entwurf-v2-spawn-production: no saved session for ${plan.sessionId} — cannot resume.`);
|
|
120
|
+
}
|
|
121
|
+
const identity = readSessionIdentity(sessionFile, { requireEntwurf: true });
|
|
122
|
+
const resumeModel = identity?.modelId ?? null;
|
|
123
|
+
if (!identity || !resumeModel) {
|
|
124
|
+
throw new Error(`entwurf-v2-spawn-production: ${plan.sessionId} has no recorded model — cannot resume.`);
|
|
125
|
+
}
|
|
126
|
+
const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
|
|
127
|
+
if (explicitExtensions.unresolvedAcpIntent) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`entwurf-v2-spawn-production: ${plan.sessionId} recorded provider=entwurf but the bridge ` +
|
|
130
|
+
`extension could not be resolved — refusing to resume with an unknown provider (#29).`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!identity.cwd) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`entwurf-v2-spawn-production: ${plan.sessionId} header has no cwd (the cold-resume authority, #9).`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
cwd: identity.cwd,
|
|
140
|
+
explicitExtensionArgs: explicitExtensions.args,
|
|
141
|
+
provider: explicitExtensions.provider ?? identity.provider,
|
|
142
|
+
model: explicitExtensions.modelOverride ?? resumeModel,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── the injectable seams (defaults = the real world) ─────────────────────────
|
|
147
|
+
export interface ProductionSpawnOpts {
|
|
148
|
+
/** Poll interval for awaitSocketAlive's wait loop (ms). */
|
|
149
|
+
pollIntervalMs?: number;
|
|
150
|
+
/** Bounded wait, after a kill, for the resulting child-exited (the watcher's killGraceMs). */
|
|
151
|
+
killGraceMs?: number;
|
|
152
|
+
/** Connect-probe timeout for a socket-file inspection (ms). */
|
|
153
|
+
probeTimeoutMs?: number;
|
|
154
|
+
/** Resolve launch identity (default = resolveResumeLaunchIdentity). */
|
|
155
|
+
resolveIdentity?: (plan: SpawnBgPlan) => LaunchIdentity;
|
|
156
|
+
/** Spawn the resume child and return its handle (default = real `pi` spawn + unref +
|
|
157
|
+
* mirrorChildStderr). `env` carries the v2 spawn-bg resume marker (V2_RESUME_RESIDENT_SESSION_ENV)
|
|
158
|
+
* the factory plants so the resumed `--entwurf-control` resident is an AUTHORIZED Entwurf child,
|
|
159
|
+
* not a corrupt operator resident. A throw becomes the watcher's spawn-start-failed. */
|
|
160
|
+
spawnChild?: (cmd: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv) => SpawnedProcHandle;
|
|
161
|
+
/** lstat for socket inspection (default = fs.lstat). */
|
|
162
|
+
lstatFn?: (p: string) => Promise<LstatLike>;
|
|
163
|
+
/** Connect probe for a socket-file (default = probeSocketLiveness). */
|
|
164
|
+
probeFn?: (socketPath: string) => Promise<SocketLiveness>;
|
|
165
|
+
/** Release the held lock (default = the lock primitive's releaseLock). */
|
|
166
|
+
releaseFn?: (lock: LockClaim) => void;
|
|
167
|
+
/** Timer primitives (default = global setTimeout/clearTimeout) — injected so the gate
|
|
168
|
+
* proves scheduling + abort-clear deterministically. */
|
|
169
|
+
setTimeoutFn?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
170
|
+
clearTimeoutFn?: (t: ReturnType<typeof setTimeout>) => void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const DEFAULT_POLL_INTERVAL_MS = 150;
|
|
174
|
+
const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
175
|
+
|
|
176
|
+
/** The default spawnChild: a detached, unref'd `pi` resident child with stderr mirrored —
|
|
177
|
+
* the same launch posture as the legacy worker, minus `--no-extensions` (the argv comes
|
|
178
|
+
* from buildResumePiArgs v2-control). Detached so the resumed citizen survives this parent. */
|
|
179
|
+
function defaultSpawnChild(
|
|
180
|
+
cmd: string,
|
|
181
|
+
args: readonly string[],
|
|
182
|
+
cwd: string,
|
|
183
|
+
env: NodeJS.ProcessEnv,
|
|
184
|
+
): SpawnedProcHandle {
|
|
185
|
+
const proc: ChildProcess = spawn(cmd, [...args], {
|
|
186
|
+
cwd,
|
|
187
|
+
shell: false,
|
|
188
|
+
detached: true,
|
|
189
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
190
|
+
env,
|
|
191
|
+
});
|
|
192
|
+
proc.unref();
|
|
193
|
+
mirrorChildStderr(proc);
|
|
194
|
+
return proc;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build the production `SpawnBgResumeDeps` the 5c-3a watcher consumes. The factory captures
|
|
199
|
+
* NO plan and NO lock (D3) — both flow through the watcher. Pass `opts` to inject fakes for
|
|
200
|
+
* the deterministic gate; the defaults are the real IO.
|
|
201
|
+
*/
|
|
202
|
+
export function makeProductionSpawnBgResumeDeps(opts: ProductionSpawnOpts = {}): SpawnBgResumeDeps {
|
|
203
|
+
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
204
|
+
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
205
|
+
const resolveIdentity = opts.resolveIdentity ?? resolveResumeLaunchIdentity;
|
|
206
|
+
const spawnChildFn = opts.spawnChild ?? defaultSpawnChild;
|
|
207
|
+
const lstatFn = opts.lstatFn ?? ((p: string) => fs.lstat(p));
|
|
208
|
+
const probeFn =
|
|
209
|
+
opts.probeFn ?? ((socketPath: string) => probeSocketLiveness(socketPath, { timeoutMs: opts.probeTimeoutMs }));
|
|
210
|
+
const releaseFn = opts.releaseFn ?? ((lock: LockClaim) => void releaseLock(lock));
|
|
211
|
+
const setTimeoutFn = opts.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
|
|
212
|
+
const clearTimeoutFn = opts.clearTimeoutFn ?? ((t) => clearTimeout(t));
|
|
213
|
+
|
|
214
|
+
// An abortable delay: resolves after `ms`, or rejects on abort (clearing the timer). Used
|
|
215
|
+
// by both awaitTimeout (the observe/grace deadlines) and the socket poll's inter-poll sleep.
|
|
216
|
+
const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
217
|
+
new Promise<void>((resolve, reject) => {
|
|
218
|
+
if (signal.aborted) {
|
|
219
|
+
reject(new Error("aborted"));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const timer = setTimeoutFn(() => {
|
|
223
|
+
signal.removeEventListener("abort", onAbort);
|
|
224
|
+
resolve();
|
|
225
|
+
}, ms);
|
|
226
|
+
const onAbort = (): void => {
|
|
227
|
+
clearTimeoutFn(timer);
|
|
228
|
+
reject(new Error("aborted"));
|
|
229
|
+
};
|
|
230
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
killGraceMs,
|
|
235
|
+
|
|
236
|
+
spawnChild: async (plan: SpawnBgPlan): Promise<SpawnedChild> => {
|
|
237
|
+
const identity = resolveIdentity(plan);
|
|
238
|
+
const args = buildResumePiArgs({
|
|
239
|
+
variant: "v2-control",
|
|
240
|
+
sessionId: plan.sessionId,
|
|
241
|
+
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
242
|
+
provider: identity.provider,
|
|
243
|
+
model: identity.model,
|
|
244
|
+
prompt: plan.prompt,
|
|
245
|
+
launchArgs: plan.launchArgs,
|
|
246
|
+
});
|
|
247
|
+
// Plant the sessionId-bound authorization marker (QB-resident): this resume promotes a
|
|
248
|
+
// dormant `entwurf`-tagged session to a live `--entwurf-control` resident, which the
|
|
249
|
+
// entwurf-control guard would otherwise crash as a "corrupt resident session name". The
|
|
250
|
+
// marker authorizes ONLY this exact session — a human hand-opening the same session with
|
|
251
|
+
// `--entwurf-control` carries no marker and still crashes (the invariant is narrowed, not
|
|
252
|
+
// dropped). buildResumePiArgs's argv alone can't say "this is a v2 spawn-bg resume".
|
|
253
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env, [V2_RESUME_RESIDENT_SESSION_ENV]: plan.sessionId };
|
|
254
|
+
const proc = spawnChildFn("pi", args, identity.cwd, childEnv);
|
|
255
|
+
|
|
256
|
+
// B2: capture exit EAGERLY — the instant the proc exists, before we even await the
|
|
257
|
+
// spawn — so a fast exit cannot slip through the gap before awaitChildExit. Resolve-
|
|
258
|
+
// only (a signal kill reports null); it never rejects, so racing it against abort is safe.
|
|
259
|
+
const exitPromise = new Promise<number | null>((resolve) => {
|
|
260
|
+
proc.on("exit", (code: unknown) => resolve(typeof code === "number" ? code : null));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// B1: a started process is NOT guaranteed by `spawn()` returning — a spawn-time
|
|
264
|
+
// failure (ENOENT pi / exec error) arrives as an `error` event, not a sync throw. Wait
|
|
265
|
+
// for the `spawn` event to confirm a real start; an `error` before it rejects, which the
|
|
266
|
+
// watcher turns into spawn-start-failed (release, nothing to watch) — NOT a silent
|
|
267
|
+
// spawn-started that later stalls into a wrongful retained lock.
|
|
268
|
+
await new Promise<void>((resolve, reject) => {
|
|
269
|
+
const onSpawn = (): void => {
|
|
270
|
+
cleanup();
|
|
271
|
+
resolve();
|
|
272
|
+
};
|
|
273
|
+
const onError = (err: unknown): void => {
|
|
274
|
+
cleanup();
|
|
275
|
+
reject(err instanceof Error ? err : new Error(`spawn failed: ${String(err)}`));
|
|
276
|
+
};
|
|
277
|
+
const cleanup = (): void => {
|
|
278
|
+
proc.removeListener("spawn", onSpawn);
|
|
279
|
+
proc.removeListener("error", onError);
|
|
280
|
+
};
|
|
281
|
+
proc.on("spawn", onSpawn);
|
|
282
|
+
proc.on("error", onError);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const child: ProductionSpawnedChild = { pid: proc.pid, proc, exitPromise };
|
|
286
|
+
return child;
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
awaitSocketAlive: async (socketPath: string, signal: AbortSignal): Promise<void> => {
|
|
290
|
+
// Poll the EXACT path (no gid re-derivation). Connectable → resolve; forged →
|
|
291
|
+
// reject NOW; dead/indeterminate → wait one interval and re-poll, until abort.
|
|
292
|
+
while (!signal.aborted) {
|
|
293
|
+
const inspection = await inspectControlSocketPath(socketPath, lstatFn);
|
|
294
|
+
const mapped = await mapInspectionToLiveness(inspection, probeFn);
|
|
295
|
+
const verdict = socketWatchVerdict(mapped);
|
|
296
|
+
if (verdict === "alive") return;
|
|
297
|
+
if (verdict === "forged") {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`entwurf-v2-spawn-production: forged control-socket address at ${socketPath} (never connected).`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// wait — sleep one interval, honoring abort (rejects → loop exits below).
|
|
303
|
+
await abortableDelay(pollIntervalMs, signal);
|
|
304
|
+
}
|
|
305
|
+
throw new Error("entwurf-v2-spawn-production: awaitSocketAlive aborted before the socket became alive.");
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
awaitChildExit: (child: SpawnedChild, signal: AbortSignal): Promise<number | null> =>
|
|
309
|
+
// Race the EAGER exitPromise (installed at spawn, so a fast exit is already captured —
|
|
310
|
+
// B2) against abort. exitPromise is resolve-only, so the loser never surfaces as an
|
|
311
|
+
// unhandled rejection; only the abort branch rejects.
|
|
312
|
+
new Promise<number | null>((resolve, reject) => {
|
|
313
|
+
const { exitPromise } = requireProductionChild(child);
|
|
314
|
+
if (signal.aborted) {
|
|
315
|
+
reject(new Error("aborted"));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const onAbort = (): void => reject(new Error("aborted"));
|
|
319
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
320
|
+
exitPromise.then((code) => {
|
|
321
|
+
signal.removeEventListener("abort", onAbort);
|
|
322
|
+
resolve(code);
|
|
323
|
+
});
|
|
324
|
+
}),
|
|
325
|
+
|
|
326
|
+
awaitTimeout: (ms: number, signal: AbortSignal): Promise<void> => abortableDelay(ms, signal),
|
|
327
|
+
|
|
328
|
+
killChild: (child: SpawnedChild): void => {
|
|
329
|
+
// SIGTERM only this slice — the watcher's killGrace then waits for the resulting
|
|
330
|
+
// child-exited; if none arrives it returns lock-retained (a SIGKILL escalator is a
|
|
331
|
+
// separate policy + smoke). Best-effort: the watcher catches a throw here.
|
|
332
|
+
requireProductionChild(child).proc.kill("SIGTERM");
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
releaseLock: (lock: LockClaim): void => releaseFn(lock),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
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
|
+
|
|
35
|
+
import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
|
|
36
|
+
import type { LockClaim } from "./entwurf-v2-lock.ts";
|
|
37
|
+
import {
|
|
38
|
+
decideReleasePolicy,
|
|
39
|
+
initialReleaseState,
|
|
40
|
+
type ReleaseEvent,
|
|
41
|
+
type ReleasePolicy,
|
|
42
|
+
type ReleaseState,
|
|
43
|
+
reduceRelease,
|
|
44
|
+
} from "./entwurf-v2-release.ts";
|
|
45
|
+
|
|
46
|
+
/** The spawn-bg plan shape, narrowed from the decider's ExecutionPlan union. */
|
|
47
|
+
export type SpawnBgPlan = Extract<ExecutionPlan, { transport: "spawn-bg" }>;
|
|
48
|
+
|
|
49
|
+
/** A started resume child. `pid` is diagnostic-only (surfaced on a retained lock); the
|
|
50
|
+
* watcher tracks the child by this opaque handle, never by gid. */
|
|
51
|
+
export interface SpawnedChild {
|
|
52
|
+
pid?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every IO seam is a REQUIRED dep — the hand performs ZERO IO of its own so the gate can
|
|
57
|
+
* drive every observation order without a real spawn/socket/timer.
|
|
58
|
+
* - spawnChild — launch the resume child. Resolves a handle on a STARTED process;
|
|
59
|
+
* THROWS on a spawn-time failure (→ `spawn-start-failed`: no child to watch).
|
|
60
|
+
* - awaitSocketAlive — resolve when `expectedSocketPath` becomes observable (the child
|
|
61
|
+
* stood its control socket up). NEVER resolves if the socket never appears — the
|
|
62
|
+
* timeout path handles that. Honors the abort signal so a loser can be torn down.
|
|
63
|
+
* - awaitChildExit — resolve with the child's exit code (null = killed by signal).
|
|
64
|
+
* Honors the abort signal.
|
|
65
|
+
* - awaitTimeout — resolve after `ms` (the observe deadline, and again the kill
|
|
66
|
+
* grace). Honors the abort signal. NOTE: a timeout is NEVER fed to the reducer.
|
|
67
|
+
* - killChild — terminate the started child. Used ONLY to escalate a timeout (or
|
|
68
|
+
* a post-spawn dep failure) into a real `child-exited`; it is never a release itself.
|
|
69
|
+
* - releaseLock — release the held claim; reached ONLY via reduceRelease, at most
|
|
70
|
+
* once, only on a real observation event.
|
|
71
|
+
* - killGraceMs — bounded wait, after a kill, for the resulting observation. Keeps
|
|
72
|
+
* the watcher from hanging on a zombie: if it elapses, the lock is RETAINED (surfaced),
|
|
73
|
+
* not released. A watcher-policy constant (NOT a routing decision — so it lives on the
|
|
74
|
+
* dep, not the decider's plan).
|
|
75
|
+
*/
|
|
76
|
+
export interface SpawnBgResumeDeps {
|
|
77
|
+
spawnChild: (plan: SpawnBgPlan) => Promise<SpawnedChild>;
|
|
78
|
+
awaitSocketAlive: (socketPath: string, signal: AbortSignal) => Promise<void>;
|
|
79
|
+
awaitChildExit: (child: SpawnedChild, signal: AbortSignal) => Promise<number | null>;
|
|
80
|
+
awaitTimeout: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
81
|
+
killChild: (child: SpawnedChild) => void;
|
|
82
|
+
releaseLock: (lock: LockClaim) => void;
|
|
83
|
+
killGraceMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Evidence carried on a `lock-retained` outcome so an operator can SEE (and clear) the
|
|
87
|
+
* lock that no observation released. */
|
|
88
|
+
export interface SpawnRetainedDiagnostic {
|
|
89
|
+
targetGardenId: string;
|
|
90
|
+
pid?: number;
|
|
91
|
+
expectedSocketPath: string;
|
|
92
|
+
lockPath: string;
|
|
93
|
+
observeTimeoutMs: number;
|
|
94
|
+
killGraceMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The terminal outcome of a spawn-bg resume watch. The three RELEASED outcomes each fed
|
|
99
|
+
* exactly one observation event to the reducer; the `lock-retained` outcome fed NONE (it
|
|
100
|
+
* is the observation-less fail-closed — `released:false`, lock left for the operator).
|
|
101
|
+
* - socket-alive — the resumed child stood its socket up.
|
|
102
|
+
* - child-exited — the child exited (any code; null = signal/kill).
|
|
103
|
+
* - spawn-start-failed — the child never started (spawnChild threw); nothing to watch.
|
|
104
|
+
* - lock-retained — no observation was obtainable (kill grace elapsed, or a
|
|
105
|
+
* post-spawn dep failed and the exit could not be observed). Lock NOT released.
|
|
106
|
+
*/
|
|
107
|
+
export type SpawnBgResumeResult =
|
|
108
|
+
| { kind: "socket-alive"; released: true; pid?: number }
|
|
109
|
+
| { kind: "child-exited"; released: true; exitCode: number | null; pid?: number }
|
|
110
|
+
| { kind: "spawn-start-failed"; released: true; error: string }
|
|
111
|
+
| {
|
|
112
|
+
kind: "lock-retained";
|
|
113
|
+
released: false;
|
|
114
|
+
reason: "kill-unconfirmed" | "observe-failed";
|
|
115
|
+
error?: string;
|
|
116
|
+
diagnostic: SpawnRetainedDiagnostic;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// An internal tag for the observation race: which source settled first.
|
|
120
|
+
type Observation = { tag: "socket" } | { tag: "exit"; code: number | null } | { tag: "timeout" };
|
|
121
|
+
|
|
122
|
+
function tagSocket(p: Promise<void>): Promise<Observation> {
|
|
123
|
+
return p.then(() => ({ tag: "socket" }) as const);
|
|
124
|
+
}
|
|
125
|
+
function tagExit(p: Promise<number | null>): Promise<Observation> {
|
|
126
|
+
return p.then((code) => ({ tag: "exit", code }) as const);
|
|
127
|
+
}
|
|
128
|
+
function tagTimeout(p: Promise<void>): Promise<Observation> {
|
|
129
|
+
return p.then(() => ({ tag: "timeout" }) as const);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function errMsg(err: unknown): string {
|
|
133
|
+
return err instanceof Error ? err.message : String(err);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Swallow a watcher promise's eventual rejection when it is abandoned without a race
|
|
137
|
+
* handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
|
|
138
|
+
* loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
|
|
139
|
+
function defuse(p: Promise<unknown> | undefined): void {
|
|
140
|
+
if (p) void p.catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Launch a spawn-bg resume child and watch for the first observable liveness transition,
|
|
145
|
+
* releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
|
|
146
|
+
* surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
|
|
147
|
+
* the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
|
|
148
|
+
* wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
|
|
149
|
+
*
|
|
150
|
+
* A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
|
|
151
|
+
* happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
|
|
152
|
+
* 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
|
|
153
|
+
* state is a returned `SpawnBgResumeResult`.
|
|
154
|
+
*/
|
|
155
|
+
export async function executeSpawnBgResume(
|
|
156
|
+
plan: SpawnBgPlan,
|
|
157
|
+
lock: LockClaim | null,
|
|
158
|
+
deps: SpawnBgResumeDeps,
|
|
159
|
+
): Promise<SpawnBgResumeResult> {
|
|
160
|
+
// Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
|
|
161
|
+
// policy is release-after-spawn-observation and the lock is non-null.
|
|
162
|
+
const policy: ReleasePolicy = decideReleasePolicy(plan, lock);
|
|
163
|
+
const held = lock as LockClaim;
|
|
164
|
+
|
|
165
|
+
let state: ReleaseState = initialReleaseState();
|
|
166
|
+
// The ONLY path to deps.releaseLock: fold a real observation event through the reducer
|
|
167
|
+
// and release iff (and exactly once) the reducer says so. No direct-release hatch.
|
|
168
|
+
const fire = (event: ReleaseEvent): void => {
|
|
169
|
+
const r = reduceRelease(policy, state, event);
|
|
170
|
+
state = r.state;
|
|
171
|
+
if (r.shouldRelease) deps.releaseLock(held);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
|
|
175
|
+
// watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
|
|
176
|
+
let child: SpawnedChild;
|
|
177
|
+
try {
|
|
178
|
+
child = await deps.spawnChild(plan);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
fire({ kind: "spawn-start-failed", error: errMsg(err) });
|
|
181
|
+
return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const { signal } = controller;
|
|
186
|
+
// Created ONCE and reused across the primary race and the kill-grace race; in the
|
|
187
|
+
// timeout branch both are still pending (timeout won), so re-racing them is safe.
|
|
188
|
+
//
|
|
189
|
+
// S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
|
|
190
|
+
// SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
|
|
191
|
+
// the send hand's case-13 backstop covers). The child already exists, so this is a
|
|
192
|
+
// post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
|
|
193
|
+
// Best-effort kill, defuse any watcher that DID start (so its later rejection is not
|
|
194
|
+
// unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
|
|
195
|
+
// observe the exit — awaitChildExit may itself be the thrower; retained is the honest
|
|
196
|
+
// floor. (The async-rejection path is handled later by the race catch → backstop.)
|
|
197
|
+
let socketP!: Promise<Observation>;
|
|
198
|
+
let exitP!: Promise<Observation>;
|
|
199
|
+
try {
|
|
200
|
+
socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
|
|
201
|
+
exitP = tagExit(deps.awaitChildExit(child, signal));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
try {
|
|
204
|
+
deps.killChild(child);
|
|
205
|
+
} catch {
|
|
206
|
+
// best-effort
|
|
207
|
+
}
|
|
208
|
+
controller.abort();
|
|
209
|
+
defuse(socketP);
|
|
210
|
+
defuse(exitP);
|
|
211
|
+
return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
// Primary observation race. `timeout` participates but is NEVER a release event.
|
|
216
|
+
let first: Observation;
|
|
217
|
+
try {
|
|
218
|
+
const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
|
|
219
|
+
first = await Promise.race([socketP, exitP, timeoutP]);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
|
|
222
|
+
return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (first.tag === "socket") {
|
|
226
|
+
fire({ kind: "socket-alive" });
|
|
227
|
+
return { kind: "socket-alive", released: true, pid: child.pid };
|
|
228
|
+
}
|
|
229
|
+
if (first.tag === "exit") {
|
|
230
|
+
fire({ kind: "child-exited", code: first.code });
|
|
231
|
+
return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
|
|
235
|
+
// grace for the kill to produce a real child-exited (or a racing socket-alive).
|
|
236
|
+
try {
|
|
237
|
+
deps.killChild(child);
|
|
238
|
+
} catch {
|
|
239
|
+
// kill itself threw — still try to observe within the grace; if nothing, retain.
|
|
240
|
+
}
|
|
241
|
+
let second: Observation;
|
|
242
|
+
try {
|
|
243
|
+
const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
|
|
244
|
+
second = await Promise.race([socketP, exitP, graceP]);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
// A dep rejected during the grace → cannot confirm; retain the lock, surface it.
|
|
247
|
+
return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
|
|
248
|
+
}
|
|
249
|
+
if (second.tag === "socket") {
|
|
250
|
+
fire({ kind: "socket-alive" });
|
|
251
|
+
return { kind: "socket-alive", released: true, pid: child.pid };
|
|
252
|
+
}
|
|
253
|
+
if (second.tag === "exit") {
|
|
254
|
+
fire({ kind: "child-exited", code: second.code });
|
|
255
|
+
return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
|
|
256
|
+
}
|
|
257
|
+
// Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
|
|
258
|
+
// retain the lock and surface the evidence (bounded return, never a hang).
|
|
259
|
+
return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
|
|
260
|
+
} finally {
|
|
261
|
+
// Tear down the losing watchers (their timers / FS watches) on every exit.
|
|
262
|
+
controller.abort();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
|
|
268
|
+
* before any observation. An observation-less release is forbidden (it reopens the
|
|
269
|
+
* double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
|
|
270
|
+
* release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
|
|
271
|
+
* already-suspect socket watcher) plus a fresh grace timer.
|
|
272
|
+
*/
|
|
273
|
+
async function backstop(
|
|
274
|
+
plan: SpawnBgPlan,
|
|
275
|
+
deps: SpawnBgResumeDeps,
|
|
276
|
+
child: SpawnedChild,
|
|
277
|
+
lockPath: string,
|
|
278
|
+
exitP: Promise<Observation>,
|
|
279
|
+
signal: AbortSignal,
|
|
280
|
+
fire: (event: ReleaseEvent) => void,
|
|
281
|
+
originalError: string,
|
|
282
|
+
): Promise<SpawnBgResumeResult> {
|
|
283
|
+
try {
|
|
284
|
+
deps.killChild(child);
|
|
285
|
+
} catch {
|
|
286
|
+
// best-effort
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
|
|
290
|
+
const obs = await Promise.race([exitP, graceP]);
|
|
291
|
+
if (obs.tag === "exit") {
|
|
292
|
+
fire({ kind: "child-exited", code: obs.code });
|
|
293
|
+
return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// exit watcher itself was the failing dep (or threw again) → fall through to retain.
|
|
297
|
+
}
|
|
298
|
+
return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function retained(
|
|
302
|
+
plan: SpawnBgPlan,
|
|
303
|
+
child: SpawnedChild,
|
|
304
|
+
reason: "kill-unconfirmed" | "observe-failed",
|
|
305
|
+
killGraceMs: number,
|
|
306
|
+
lockPath: string,
|
|
307
|
+
error: string | undefined,
|
|
308
|
+
): SpawnBgResumeResult {
|
|
309
|
+
return {
|
|
310
|
+
kind: "lock-retained",
|
|
311
|
+
released: false,
|
|
312
|
+
reason,
|
|
313
|
+
error,
|
|
314
|
+
diagnostic: {
|
|
315
|
+
targetGardenId: plan.targetGardenId,
|
|
316
|
+
pid: child.pid,
|
|
317
|
+
expectedSocketPath: plan.expectedSocketPath,
|
|
318
|
+
lockPath,
|
|
319
|
+
observeTimeoutMs: plan.observeTimeoutMs,
|
|
320
|
+
killGraceMs,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|