@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
|
@@ -14,33 +14,13 @@
|
|
|
14
14
|
* from mode/action — that would blur the F1 ownership contract).
|
|
15
15
|
* - `renderEntwurfV2Result` — the result union → `{ text, isError }`, surfacing the carry-overs
|
|
16
16
|
* the surface must NOT drop: a reject's reason+diagnostic, a control `rejectReason` (N3), a
|
|
17
|
-
*
|
|
17
|
+
* and the N1 delivered+lock-dirty `execution-failed`.
|
|
18
18
|
* - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
|
|
19
19
|
* surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
|
|
20
20
|
*/
|
|
21
|
-
import * as path from "node:path";
|
|
22
21
|
import { makeProductionEntwurfV2Deps } from "./entwurf-v2-production.js";
|
|
23
22
|
import { runEntwurfV2 } from "./entwurf-v2-runner.js";
|
|
24
23
|
import { FRESH_CUT_PRESCRIPTION } from "./meta-session.js";
|
|
25
|
-
/** The operator-policy SSOT for v2 dispatch's preflight prefix-auto-approve roots (5d-4b).
|
|
26
|
-
* ONE shared env var feeds BOTH surfaces (pi-native + MCP) — a pi session and an MCP child
|
|
27
|
-
* both inherit it, so there is no per-surface config fork. `prefixRoots` is operator policy,
|
|
28
|
-
* not session-local UX, so it is an env var, not a pi flag. */
|
|
29
|
-
export const ENTWURF_PREFIX_ROOTS_ENV = "ENTWURF_PREFIX_ROOTS";
|
|
30
|
-
/** Parse `ENTWURF_PREFIX_ROOTS` into the preflight's `prefixRoots`. `path.delimiter`-
|
|
31
|
-
* separated (`:` on Linux/macOS); entries are trimmed, empty segments dropped. Unset / empty
|
|
32
|
-
* / delimiters-only ⇒ `[]` (no prefix promotion — frozen decision 7, no package default).
|
|
33
|
-
* It does NOT throw on a nonexistent/typo path: `preflight`'s normalize keeps an absolute
|
|
34
|
-
* fallback, so a bad root simply never matches (a typo must not broaden approve, and must not
|
|
35
|
-
* turn every owned-outcome dispatch into a loud failure). `~` is left for preflight to expand. */
|
|
36
|
-
export function parseEntwurfPrefixRootsEnv(raw = process.env[ENTWURF_PREFIX_ROOTS_ENV]) {
|
|
37
|
-
if (!raw)
|
|
38
|
-
return [];
|
|
39
|
-
return raw
|
|
40
|
-
.split(path.delimiter)
|
|
41
|
-
.map((s) => s.trim())
|
|
42
|
-
.filter((s) => s.length > 0);
|
|
43
|
-
}
|
|
44
24
|
/** Surface params → the runner's `DispatchInput`. `wants_reply`→`wantsReply`; `intent` and
|
|
45
25
|
* `message` pass through; `mode`/`wantsReply` are left undefined when absent so the decider's
|
|
46
26
|
* own defaults (follow_up / false) apply — no double-default. */
|
|
@@ -55,27 +35,36 @@ export function toDispatchInput(params) {
|
|
|
55
35
|
}
|
|
56
36
|
/**
|
|
57
37
|
* Detour B (B-a) — actionable rendering of an honest reject. The decider is UNCHANGED:
|
|
58
|
-
* a reject stays a reject (Hard Rule 3)
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* reading as "delivery impossible". Returns undefined for rejects with no useful next step.
|
|
38
|
+
* a reject stays a reject (Hard Rule 3). This only appends a one-line "what to do instead"
|
|
39
|
+
* to the reject TEXT, so an honest reject stops reading as "delivery impossible". Returns
|
|
40
|
+
* undefined for rejects with no useful next step.
|
|
62
41
|
*/
|
|
63
42
|
export function actionableRejectHint(reason) {
|
|
64
43
|
switch (reason) {
|
|
65
|
-
case "
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
44
|
+
case "dormant-fire-forget-unsupported":
|
|
45
|
+
// The cell that carries the whole cost of the visible-first cut. Naming the real
|
|
46
|
+
// state — the citizen exists and is not running — matters more than ever now that
|
|
47
|
+
// there is no verb behind it: a caller that reads "reject" as "wrong id" goes
|
|
48
|
+
// looking in the wrong place.
|
|
49
|
+
return ("this citizen's record is intact but its session is not running, and delivery has no way to wake it. " +
|
|
50
|
+
"The resume that used to answer here launched a hidden background child and was withdrawn under the " +
|
|
51
|
+
"visible-first rule; the visible same-id resume is the separate lifecycle verb entwurf_resume_call. " +
|
|
52
|
+
"Reopen the citizen with entwurf_resume_call {target} (pi targets only; it runs no turn), then dispatch again.");
|
|
53
|
+
case "indeterminate-no-spawn":
|
|
54
|
+
// The socket probe did not settle, so liveness is UNKNOWN — an unestablished probe
|
|
55
|
+
// is not a measured death, and dispatching into it could double-deliver. Say
|
|
56
|
+
// "the PROBE was inconclusive", never "the socket answered inconclusively":
|
|
57
|
+
// `indeterminate` also covers a probe that got no answer at all, was refused by
|
|
58
|
+
// permissions, or timed out, and "answered" claims a reply that may never have
|
|
59
|
+
// existed. The wire id is FROZEN and still spells "-no-spawn"; it names the rule
|
|
60
|
+
// (never dispatch into an indeterminate target), not a capability that still
|
|
61
|
+
// exists. So the hint's job is to say plainly what did NOT happen: nothing
|
|
62
|
+
// delivered, nothing started.
|
|
63
|
+
return ("the control-socket probe was inconclusive, so the target's liveness is UNKNOWN — this is not a " +
|
|
64
|
+
"measured death. NOTHING was delivered and NO process was started (the reason id keeps its frozen " +
|
|
65
|
+
"'-no-spawn' wire spelling from an era when one could be; entwurf_v2 starts nothing on any rail). " +
|
|
66
|
+
"Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
|
|
67
|
+
"that garden id by a session that died without cleaning up.");
|
|
79
68
|
case "native-push-target-dead":
|
|
80
69
|
// The adapter probe found no live host process for the conversation.
|
|
81
70
|
return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
|
|
@@ -97,7 +86,7 @@ export function actionableRejectHint(reason) {
|
|
|
97
86
|
/** Render the outcome-rich result to `{ text, isError }`. A reject or a thrown/failed/dirty
|
|
98
87
|
* delivery is `isError:true`; a sent/fallback-sent/enqueued/observed delivery is `isError:false`.
|
|
99
88
|
* A control in-band `rejected` is a non-delivery (isError:true) and carries N3 `rejectReason`
|
|
100
|
-
* when present
|
|
89
|
+
* when present. */
|
|
101
90
|
export function renderEntwurfV2Result(result) {
|
|
102
91
|
switch (result.kind) {
|
|
103
92
|
case "rejected": {
|
|
@@ -125,27 +114,6 @@ export function renderEntwurfV2Result(result) {
|
|
|
125
114
|
isError: !delivered,
|
|
126
115
|
};
|
|
127
116
|
}
|
|
128
|
-
if (o.transport === "spawn-bg") {
|
|
129
|
-
const res = o.result;
|
|
130
|
-
if (res.kind === "lock-retained") {
|
|
131
|
-
const d = res.diagnostic;
|
|
132
|
-
return {
|
|
133
|
-
text: `entwurf_v2 spawn-bg LOCK RETAINED (${res.reason}) — lock NOT released, operator must clear:` +
|
|
134
|
-
`\n target: ${d.targetGardenId}` +
|
|
135
|
-
`\n lockPath: ${d.lockPath}` +
|
|
136
|
-
`\n expectedSocketPath: ${d.expectedSocketPath}` +
|
|
137
|
-
`\n observeTimeoutMs: ${d.observeTimeoutMs}, killGraceMs: ${d.killGraceMs}` +
|
|
138
|
-
(res.error ? `\n error: ${res.error}` : ""),
|
|
139
|
-
isError: true,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
if (res.kind === "spawn-start-failed") {
|
|
143
|
-
return { text: `entwurf_v2 spawn-bg failed to start: ${res.error}`, isError: true };
|
|
144
|
-
}
|
|
145
|
-
const pid = "pid" in res && res.pid !== undefined ? ` (pid ${res.pid})` : "";
|
|
146
|
-
const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
|
|
147
|
-
return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
|
|
148
|
-
}
|
|
149
117
|
if (o.transport === "native-push") {
|
|
150
118
|
// direct-inject succeeded; note if the 1-shot re-probe retry fired.
|
|
151
119
|
return {
|
|
@@ -179,12 +147,7 @@ export function renderEntwurfV2Result(result) {
|
|
|
179
147
|
* fence types stay behind this one entry point.
|
|
180
148
|
*/
|
|
181
149
|
export async function runAndRenderEntwurfV2FromSurface(params, opts) {
|
|
182
|
-
const prodOpts = {
|
|
183
|
-
senderProvider: opts.senderProvider,
|
|
184
|
-
agentDir: opts.agentDir,
|
|
185
|
-
// Explicit opts win (test / future surface override); otherwise the shared env SSOT.
|
|
186
|
-
prefixRoots: opts.prefixRoots ?? parseEntwurfPrefixRootsEnv(),
|
|
187
|
-
};
|
|
150
|
+
const prodOpts = { senderProvider: opts.senderProvider };
|
|
188
151
|
const result = await runEntwurfV2(toDispatchInput(params), makeProductionEntwurfV2Deps(prodOpts));
|
|
189
152
|
return renderEntwurfV2Result(result);
|
|
190
153
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-visible-resume — stand a dormant pi citizen back up in a window the operator can
|
|
3
|
+
* see, under the same garden id, and say only what was actually observed.
|
|
4
|
+
*
|
|
5
|
+
* ── Where this sits ──
|
|
6
|
+
*
|
|
7
|
+
* surface (entwurf-control.ts · mcp bridge index.ts) composition root: supplies `launch`
|
|
8
|
+
* └─ entwurf-v2-visible-resume (this file) v2 leaves ONLY: lock, record, socket
|
|
9
|
+
* └─ deps.launch(argv, cwd) ── INJECTED SEAM ──→ mux-resume-call → placement leaf
|
|
10
|
+
*
|
|
11
|
+
* The seam is what keeps `docs/mux-launch-rail.md` §11 intact in both directions: v2 never
|
|
12
|
+
* imports mux, mux never imports entwurf core. This module cannot open a window; it can only
|
|
13
|
+
* ask whoever wired it to. That is deliberate — it means the delivery fence stays exactly where
|
|
14
|
+
* `check-mux-launch` scans for it.
|
|
15
|
+
*
|
|
16
|
+
* ── Why a resume is structurally SMALLER than a fresh call on identity, and LARGER on the lock ──
|
|
17
|
+
*
|
|
18
|
+
* `mux-fresh-call` mints no identity: the sibling calls back and its envelope IS the address.
|
|
19
|
+
* A resume already knows the address — that is what "same-id" means — so there is no nonce, no
|
|
20
|
+
* callback, and no correlation receipt. What it gains instead is the lock: two visible resumes
|
|
21
|
+
* of one garden id would put two processes on one transcript, and `entwurf_v2`'s in-domain
|
|
22
|
+
* dispatch takes the SAME per-gid lock, so a resume and a live send serialise against each other
|
|
23
|
+
* for free. The lock is consistency, not defence — there is no watcher behind it.
|
|
24
|
+
*
|
|
25
|
+
* ── The observation is a startup wait, not supervision ──
|
|
26
|
+
*
|
|
27
|
+
* Measured 2026-08-06: the resumed control socket appeared ~2s (ACP) and ~4s (native) after the
|
|
28
|
+
* window opened. A single immediate probe would therefore have observed `dead` and reported a
|
|
29
|
+
* successful resume as unobserved. So the observation is ONE bounded operation with a short
|
|
30
|
+
* interval, and everything that would make it supervision is deliberately absent:
|
|
31
|
+
*
|
|
32
|
+
* - no relaunch, ever. A refused or dead launch is a result, not a retry trigger.
|
|
33
|
+
* - no pane watcher. Until the deadline this module looks at ONE thing: the socket.
|
|
34
|
+
* - no env knob on the timeout. A tunable would make "unobserved" mean different things on
|
|
35
|
+
* different hosts, and the receipt would stop being comparable.
|
|
36
|
+
*
|
|
37
|
+
* On timeout the lock is RELEASED and the window is LEFT OPEN. That is the same rule fresh-call
|
|
38
|
+
* already ships ("a launch with no callback is a real outcome"): the window is visible, the
|
|
39
|
+
* operator can look, and a retained lock on a visible artifact is the worse failure — it would
|
|
40
|
+
* have to be cleared by hand on every slow host.
|
|
41
|
+
*
|
|
42
|
+
* ── Two receipts, never merged ──
|
|
43
|
+
*
|
|
44
|
+
* `launch` says tmux made a window and was asked to start pi. `observation` says whether the
|
|
45
|
+
* citizen's socket came back under the same id. Neither says the resumed citizen did anything:
|
|
46
|
+
* a resume runs no turn (measured — the transcript stayed byte-identical), so there is no turn
|
|
47
|
+
* outcome for this module to claim.
|
|
48
|
+
*/
|
|
49
|
+
import { buildResumePiArgs } from "./entwurf-resume-args.js";
|
|
50
|
+
import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
|
|
51
|
+
import { ResumeBackendUnsupportedError, resolveResumeLaunchIdentity, } from "./resume-launch-identity.js";
|
|
52
|
+
import { isValidSessionId } from "./session-id.js";
|
|
53
|
+
import { CONTROL_SOCKET_DIR, inspectTargetControlSocket, mapInspectionToLiveness, } from "./socket-discovery.js";
|
|
54
|
+
import { probeSocketLiveness } from "./socket-probe.js";
|
|
55
|
+
/** How long ONE observation operation waits for the resumed citizen's socket. Fixed on purpose:
|
|
56
|
+
* see the module header on why this is not an env knob. */
|
|
57
|
+
export const RESUME_OBSERVE_TIMEOUT_MS = 30_000;
|
|
58
|
+
/** How often that one operation re-inspects. Short enough that a 2s startup is reported as the
|
|
59
|
+
* success it is, long enough that a 30s wait is ~60 lstats and not a spin. */
|
|
60
|
+
export const RESUME_OBSERVE_INTERVAL_MS = 500;
|
|
61
|
+
/** Production wiring for everything except the launch seam — the surface owns that, because it
|
|
62
|
+
* is the only layer allowed to know both halves. */
|
|
63
|
+
export function makeVisibleResumeDeps(launch, overrides = {}) {
|
|
64
|
+
return {
|
|
65
|
+
acquireLock: (gid) => realAcquireLock(gid, { dir: ENTWURF_V2_LOCK_DIR }),
|
|
66
|
+
releaseLock: (claim) => realReleaseLock(claim, { dir: ENTWURF_V2_LOCK_DIR }),
|
|
67
|
+
resolveIdentity: resolveResumeLaunchIdentity,
|
|
68
|
+
inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
|
|
69
|
+
probeSocket: (socketPath) => probeSocketLiveness(socketPath),
|
|
70
|
+
launch,
|
|
71
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
72
|
+
now: () => Date.now(),
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Reopen `targetGardenId` in a visible window.
|
|
78
|
+
*
|
|
79
|
+
* Order is the safety argument. The lock is taken FIRST, before the liveness question, so the
|
|
80
|
+
* answer cannot change under us between reading it and acting on it — a competing resume would
|
|
81
|
+
* otherwise pass its own `dead` check while ours was launching. Identity is resolved under that
|
|
82
|
+
* lock and BEFORE any window opens, so a citizen with no transcript (measured: a pi that never
|
|
83
|
+
* took a turn has a socket and a record but no transcriptPath) fails loud with its own cause and
|
|
84
|
+
* leaves nothing behind.
|
|
85
|
+
*/
|
|
86
|
+
export async function visibleResume(targetGardenId, deps) {
|
|
87
|
+
// The address grammar is `session-id.js`'s, not a local copy. The socket filename, the lock
|
|
88
|
+
// path and the record key are all keyed on that SAME id, so a second regex here could drift
|
|
89
|
+
// into accepting an id one of those three would reject — and this value becomes a lock path
|
|
90
|
+
// and a socket path a few lines down.
|
|
91
|
+
if (!isValidSessionId(targetGardenId)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: "target-invalid",
|
|
95
|
+
detail: `${JSON.stringify(targetGardenId)} is not a garden id (YYYYMMDDTHHMMSS-xxxxxx)`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const acquired = deps.acquireLock(targetGardenId);
|
|
99
|
+
if (!acquired.ok) {
|
|
100
|
+
return { ok: false, reason: "target-locked", detail: acquired.conflict.detail };
|
|
101
|
+
}
|
|
102
|
+
const claim = acquired.claim;
|
|
103
|
+
let released = false;
|
|
104
|
+
/** Release exactly once, and never let a failed release read as success. A lock this module
|
|
105
|
+
* took and could not give back is a fact the operator has to know about — the next resume or
|
|
106
|
+
* send against that citizen will be refused, and silence would send them looking at the wrong
|
|
107
|
+
* layer. */
|
|
108
|
+
const release = () => {
|
|
109
|
+
if (released)
|
|
110
|
+
return;
|
|
111
|
+
released = true;
|
|
112
|
+
const outcome = deps.releaseLock(claim);
|
|
113
|
+
if (outcome !== "released") {
|
|
114
|
+
throw new Error(`entwurf-v2-visible-resume: attempted to release the lock for ${targetGardenId}; the release returned ` +
|
|
115
|
+
`"${outcome}" instead of "released" (${claim.lockPath}) — the per-gid lock may still be held; clear ` +
|
|
116
|
+
`it by hand before resuming or dispatching to that citizen again.`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
// Record authority, under the lock, before any window. Every failure here is a cause-rich
|
|
121
|
+
// throw from the leaf (foreign transcript, relative or deleted session file, no recorded
|
|
122
|
+
// model, unresolvable bridge) and is deliberately NOT re-typed into a reject — with ONE
|
|
123
|
+
// exception, matched on the error's own `reason` FIELD rather than its wording: a citizen
|
|
124
|
+
// of a backend that has no same-id resume. That is a capability boundary, not a defect, so
|
|
125
|
+
// it is a named refusal. The record is not read a second time to ask which backend it was;
|
|
126
|
+
// the error already carries it.
|
|
127
|
+
let identity;
|
|
128
|
+
try {
|
|
129
|
+
identity = deps.resolveIdentity(targetGardenId);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err instanceof ResumeBackendUnsupportedError) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: err.reason,
|
|
136
|
+
detail: `${targetGardenId} is a ${err.backend} citizen; only pi citizens stand a control socket up, ` +
|
|
137
|
+
`so only they can be reopened under the same garden id`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
// Liveness preflight, still under the lock. A live citizen is addressed with
|
|
143
|
+
// `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
|
|
144
|
+
// windows existing for one garden id.
|
|
145
|
+
const inspection = await deps.inspectSocket(targetGardenId);
|
|
146
|
+
const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
147
|
+
if ("addressConflict" in mapped) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "target-address-conflict",
|
|
151
|
+
detail: `the control-socket path for ${targetGardenId} is a symlink or not a socket — that address is ` +
|
|
152
|
+
`corrupt, and resuming would stand a citizen up behind it`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (mapped.liveness === "alive") {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: "target-live",
|
|
159
|
+
detail: `${targetGardenId} is already live at ${mapped.socketPath} — send to it with entwurf_v2 fire-and-forget instead`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (mapped.liveness === "indeterminate") {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "target-indeterminate",
|
|
166
|
+
detail: `the control-socket probe for ${targetGardenId} was inconclusive — it is not provably dormant, ` +
|
|
167
|
+
`and resuming a citizen that is actually running would put two processes on one transcript`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const runtimeArgs = buildResumePiArgs({
|
|
171
|
+
sessionFile: identity.sessionFile,
|
|
172
|
+
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
173
|
+
provider: identity.provider,
|
|
174
|
+
model: identity.model,
|
|
175
|
+
});
|
|
176
|
+
const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
|
|
177
|
+
if (!launched.ok) {
|
|
178
|
+
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
179
|
+
}
|
|
180
|
+
const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
184
|
+
observation,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
release();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
|
|
193
|
+
* only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
|
|
194
|
+
* is a normal intermediate state, not a verdict, so it is simply not-yet.
|
|
195
|
+
*/
|
|
196
|
+
export async function observeSameGidSocket(targetGardenId, socketPath, deps, timeoutMs = RESUME_OBSERVE_TIMEOUT_MS, intervalMs = RESUME_OBSERVE_INTERVAL_MS) {
|
|
197
|
+
const started = deps.now();
|
|
198
|
+
for (;;) {
|
|
199
|
+
const inspection = await deps.inspectSocket(targetGardenId);
|
|
200
|
+
const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
201
|
+
if (!("addressConflict" in mapped) && mapped.liveness === "alive") {
|
|
202
|
+
return { kind: "socket-alive", socketPath: mapped.socketPath, waitedMs: deps.now() - started };
|
|
203
|
+
}
|
|
204
|
+
if (deps.now() - started >= timeoutMs) {
|
|
205
|
+
return { kind: "resume-unobserved", socketPath, waitedMs: deps.now() - started };
|
|
206
|
+
}
|
|
207
|
+
await deps.sleep(intervalMs);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const REJECT_HINT = {
|
|
211
|
+
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
212
|
+
"target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
|
|
213
|
+
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
214
|
+
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
215
|
+
"target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
|
|
216
|
+
"target-address-conflict": "that citizen's control-socket path is corrupt",
|
|
217
|
+
"launch-refused": "tmux was never asked to open a window",
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* ONE renderer for both surfaces — the same reason `mux-fresh-call` has one: the two
|
|
221
|
+
* registrations are separate literals, and a shared renderer is what keeps the operator-visible
|
|
222
|
+
* answer from drifting apart between native pi and the MCP bridge.
|
|
223
|
+
*
|
|
224
|
+
* The success text keeps the two receipts visibly apart, including in the good case. A reader
|
|
225
|
+
* who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
|
|
226
|
+
* a reader who sees them as two facts can tell which one is missing when only one is there.
|
|
227
|
+
*/
|
|
228
|
+
export function renderVisibleResume(result) {
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
return {
|
|
231
|
+
text: `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
|
|
232
|
+
`${result.detail}. No window was opened.`,
|
|
233
|
+
isError: true,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const h = result.launch.handle;
|
|
237
|
+
const observed = result.observation.kind === "socket-alive";
|
|
238
|
+
return {
|
|
239
|
+
text: `[entwurf resume call ↻]\n` +
|
|
240
|
+
` target: ${result.launch.targetGardenId}\n` +
|
|
241
|
+
` session: ${result.launch.sessionFile}\n` +
|
|
242
|
+
` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
|
|
243
|
+
` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
|
|
244
|
+
` pane: ${h.paneId} pid ${h.panePid}\n` +
|
|
245
|
+
`\n` +
|
|
246
|
+
`LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
|
|
247
|
+
(observed
|
|
248
|
+
? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
|
|
249
|
+
`${result.observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
|
|
250
|
+
`now reaches it. No turn was run by this call.`
|
|
251
|
+
: `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
|
|
252
|
+
`${result.observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
|
|
253
|
+
`released, nothing was retried, and nothing was killed.`),
|
|
254
|
+
isError: false,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -411,7 +411,7 @@ export function parseMetaIdentity(json) {
|
|
|
411
411
|
// `wakeMode` describes the last centimetre, not citizen rank. The control-socket
|
|
412
412
|
// adapter injects the body into a pi turn, so backend `pi` is `direct-inject`;
|
|
413
413
|
// Claude's mailbox is `self-fetch`; native-push adapters are direct injection.
|
|
414
|
-
//
|
|
414
|
+
// Resuming a dormant citizen is a separate lifecycle capability and does not
|
|
415
415
|
// turn the pi record into a mailbox citizen.
|
|
416
416
|
// ---------------------------------------------------------------------------
|
|
417
417
|
/** Bump only on a breaking capability-registry shape change; the parser refuses other versions. */
|
|
@@ -890,6 +890,21 @@ export function listAllMetaIdentities(entries, readRecord, opts = {}) {
|
|
|
890
890
|
errors.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
|
|
891
891
|
return { identities, errors };
|
|
892
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* The fs-bound listing — the same binding shape {@link certifyActiveStoreDir} gives the
|
|
895
|
+
* certification: one resolved dir, kind-carrying entries, the fd-guarded record reader.
|
|
896
|
+
* Exists so an in-repo facts surface (`meta-facts`, #65) reuses THE listing contract
|
|
897
|
+
* instead of re-binding readdir/read by hand — the exact drift that made external
|
|
898
|
+
* consumers carry a decaying copy of the certification. No semantics of its own:
|
|
899
|
+
* everything it returns is {@link listAllMetaIdentities} over the real store.
|
|
900
|
+
*/
|
|
901
|
+
export function listAllMetaIdentitiesDir(dir) {
|
|
902
|
+
const resolved = path.resolve(expandTilde(dir));
|
|
903
|
+
return {
|
|
904
|
+
dir: resolved,
|
|
905
|
+
...listAllMetaIdentities(readActiveStoreEntries(resolved), makeStoreRecordReader(resolved)),
|
|
906
|
+
};
|
|
907
|
+
}
|
|
893
908
|
/**
|
|
894
909
|
* The pure core of the `upsert` CLI (3D-4; v3 identity since the #50 cut). Keyed
|
|
895
910
|
* on RECORD EXISTENCE, never on a backend `source` field:
|
|
@@ -1423,8 +1438,13 @@ export function readMetaReceiverMarker(opts) {
|
|
|
1423
1438
|
* A narrower scan is not a smaller version of this: asking only about MY
|
|
1424
1439
|
* `nativeSessionId` writes happily beside a drifted or duplicated record that the
|
|
1425
1440
|
* doctor refuses, which is how a host ends up certified by one surface and not
|
|
1426
|
-
* the other.
|
|
1427
|
-
*
|
|
1441
|
+
* the other.
|
|
1442
|
+
*
|
|
1443
|
+
* The write is split by ACTION (#66): ATTACH rewrites its OWN record atomically
|
|
1444
|
+
* in place (tmp + rename, #30 crash-safety); CREATE publishes exclusively
|
|
1445
|
+
* (tmp + link) and fails loud if the minted garden id's final path already
|
|
1446
|
+
* exists — certification cannot see a future minted gid, so replacement-free
|
|
1447
|
+
* publish is the only collision safety a create has.
|
|
1428
1448
|
*/
|
|
1429
1449
|
export function upsertMetaSession(opts) {
|
|
1430
1450
|
const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
|
|
@@ -1436,15 +1456,81 @@ export function upsertMetaSession(opts) {
|
|
|
1436
1456
|
const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
|
|
1437
1457
|
const decision = decideUpsert(existing, opts.input, opts.now);
|
|
1438
1458
|
const file = path.join(dir, metaRecordFilename(decision.record));
|
|
1439
|
-
|
|
1459
|
+
if (decision.action === "create")
|
|
1460
|
+
publishExclusiveIdentity(file, decision.record);
|
|
1461
|
+
else
|
|
1462
|
+
atomicWriteIdentity(file, decision.record);
|
|
1440
1463
|
return { action: decision.action, record: decision.record, dir, path: file };
|
|
1441
1464
|
}
|
|
1442
|
-
/**
|
|
1465
|
+
/**
|
|
1466
|
+
* ATTACH replace primitive: tmp-file + rename so a crash never leaves a
|
|
1467
|
+
* half-written record (#30 crash-safety). rename(2) REPLACES an existing final
|
|
1468
|
+
* path, which is exactly right for attach (the record being rewritten is the
|
|
1469
|
+
* caller's own) and exactly wrong for create — a freshly minted garden id must
|
|
1470
|
+
* never land on this path (#66).
|
|
1471
|
+
*/
|
|
1443
1472
|
function atomicWriteIdentity(file, identity) {
|
|
1444
1473
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1445
1474
|
fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
|
|
1446
1475
|
fs.renameSync(tmp, file);
|
|
1447
1476
|
}
|
|
1477
|
+
/**
|
|
1478
|
+
* CREATE publish primitive: same-directory tmp + `link(2)` to the final path.
|
|
1479
|
+
* link fails with EEXIST when the final path already exists — the kernel makes
|
|
1480
|
+
* the existence check and the publish one atomic step, so there is no
|
|
1481
|
+
* check-then-rename TOCTOU window and a same-second 24-bit garden-id suffix
|
|
1482
|
+
* collision can never silently replace whatever already holds the final path
|
|
1483
|
+
* (#66). The occupied entry's bytes are untouched on refusal; tmp cleanup is
|
|
1484
|
+
* attempted on every path, and a published-with-residue state fails loud.
|
|
1485
|
+
*
|
|
1486
|
+
* @internal Exported only as the production subject for the meta-session gate's
|
|
1487
|
+
* deterministic collision oracle (the minted suffix is random, so the public
|
|
1488
|
+
* upsert seam cannot force a collision deterministically). Not an
|
|
1489
|
+
* operator/package API.
|
|
1490
|
+
*/
|
|
1491
|
+
export function publishExclusiveIdentity(file, identity) {
|
|
1492
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1493
|
+
fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
|
|
1494
|
+
try {
|
|
1495
|
+
fs.linkSync(tmp, file);
|
|
1496
|
+
}
|
|
1497
|
+
catch (err) {
|
|
1498
|
+
try {
|
|
1499
|
+
fs.unlinkSync(tmp);
|
|
1500
|
+
}
|
|
1501
|
+
catch (cleanupErr) {
|
|
1502
|
+
// The primary link error stays the truth of this publish; a cleanup errno
|
|
1503
|
+
// never masks it. ENOENT is silent (no residue = desired state); any other
|
|
1504
|
+
// cleanup failure is an operator diagnostic on stderr, then the primary throws.
|
|
1505
|
+
if (cleanupErr.code !== "ENOENT") {
|
|
1506
|
+
process.stderr.write(`[meta-session] tmp cleanup after a failed CREATE publish also failed (${tmp}): ` +
|
|
1507
|
+
`${cleanupErr.message}\n`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (err.code === "EEXIST") {
|
|
1511
|
+
throw new MetaRecordError(`meta-record CREATE collision: garden id "${identity.gardenId}" already has an entry at ` +
|
|
1512
|
+
`${file}. Exclusive publish refused to replace it — the occupied final-path entry was ` +
|
|
1513
|
+
`not modified and no ACTIVE record was published for nativeSessionId ` +
|
|
1514
|
+
`"${identity.nativeSessionId}".`);
|
|
1515
|
+
}
|
|
1516
|
+
throw err;
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
fs.unlinkSync(tmp);
|
|
1520
|
+
}
|
|
1521
|
+
catch (err) {
|
|
1522
|
+
// ENOENT is the goal state, not a failure: the record is published and no
|
|
1523
|
+
// tmp remains, so there is nothing left to clean.
|
|
1524
|
+
if (err.code === "ENOENT")
|
|
1525
|
+
return;
|
|
1526
|
+
// Never roll the final back: the record IS published (link succeeded).
|
|
1527
|
+
throw new MetaRecordError(`meta-record CREATE for garden id "${identity.gardenId}" is already published at ${file}, ` +
|
|
1528
|
+
`but temp cleanup failed (${tmp}): ${err.message}. The caller aborts here, so ` +
|
|
1529
|
+
`it received no successful birth/registration receipt; remove the temp file and retry — ` +
|
|
1530
|
+
`a retry with the same nativeSessionId will ATTACH to the published record, not mint a ` +
|
|
1531
|
+
`second identity.`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1448
1534
|
// ---------------------------------------------------------------------------
|
|
1449
1535
|
// Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
|
|
1450
1536
|
// authority (a sender may only deliver to a known garden citizen); the mailbox
|