@junghanacs/entwurf 0.13.1 → 0.14.1
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 +59 -15
- package/BASELINE.md +3 -3
- package/CHANGELOG.md +74 -0
- package/CONTRIBUTING.md +13 -9
- package/DELIVERY.md +7 -6
- package/README.md +37 -28
- 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 +155 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- 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 +300 -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 +141 -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 +173 -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 +227 -40
- package/pi-extensions/lib/acp/backend.ts +71 -12
- package/pi-extensions/lib/acp/overlay.ts +8 -6
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- 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 +381 -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 +189 -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 +99 -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 +283 -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-bridge-state.py +75 -1
- 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/meta-retire.json +47 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +229 -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-chain-live.ts +11 -3
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +169 -3
- 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
|
@@ -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
|