@junghanacs/entwurf 0.40.0 → 0.41.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 +4 -2
- package/BASELINE.md +55 -2
- package/CHANGELOG.md +321 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +18 -2
- package/FAQ.md +8 -4
- package/README.md +26 -8
- package/VERIFY.md +13 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +24 -8
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +93 -19
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
- package/package.json +4 -4
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +78 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +14 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-retire-production — the ctx-free PRODUCTION assembly of `retireCitizen`'s deps
|
|
3
|
+
* (#147). `makeProductionRetireDeps(opts)` binds the existing owning APIs — the v2 target lock,
|
|
4
|
+
* the meta-session readers, the control-socket inspection and probe, and the two internal
|
|
5
|
+
* retire adapters — into one `RetireDispatcherDeps`. Same shape as `makeProductionEntwurfV2Deps`:
|
|
6
|
+
* leaf IO seams are overridable for tests; which closure reaches which seam is not.
|
|
7
|
+
*
|
|
8
|
+
* Wiring, not a grant. Building or holding these deps confers no permission to retire anyone.
|
|
9
|
+
* `resolveCaller` is the trusted host's own caller resolution, injected at the wiring site (the
|
|
10
|
+
* bridge's private resolver, passed through unchanged); the dispatcher uses it for attribution
|
|
11
|
+
* and the self-target refusal only. No tool, index entry or annotation is registered here.
|
|
12
|
+
*
|
|
13
|
+
* ONE path set, from two sources. The lock and control-socket directories default to
|
|
14
|
+
* module-load constants (`ENTWURF_V2_LOCK_DIR`, `CONTROL_SOCKET_DIR`, HOME-derived when their
|
|
15
|
+
* modules load); this factory does not re-derive them. The sessions, receivers and senders
|
|
16
|
+
* directories default to an environment read made ONCE, at construction. Every closure uses
|
|
17
|
+
* that one set — including the Claude Code adapter's own record / receiver / sender witnesses,
|
|
18
|
+
* whose defaults would otherwise resolve their directories again on every call and could read
|
|
19
|
+
* a different store than the dispatcher. The capture covers these five directories (and the
|
|
20
|
+
* sidecar directory derived from the sessions one) only; the runtime environment below them
|
|
21
|
+
* (the CC helper's interpreter lookup, for one) is the adapter's own and is not frozen here.
|
|
22
|
+
* The CC helper path and spawn stay the adapter's defaults.
|
|
23
|
+
*
|
|
24
|
+
* `signal` is request-scoped: build these deps for ONE retire request. It is captured once and
|
|
25
|
+
* handed both to the dispatcher's own cancellation checks and, unchanged, to the CC adapter's
|
|
26
|
+
* abort checks; it is not an approval, and the Pi rail takes none.
|
|
27
|
+
*
|
|
28
|
+
* Recording: the #106 B sidecar directory is derived ONCE from the captured sessions directory
|
|
29
|
+
* (`retirementsDirBesideSessions`), never from a sixth path or environment knob. The two record
|
|
30
|
+
* callbacks are synchronous: each mints its event (random 128-bit id, ISO UTC `at`) and appends
|
|
31
|
+
* it once through the `append` seam; the requested callback returns the id only after its append
|
|
32
|
+
* returned. The outcome summary is a closed semantic code, never a cause, detail or stderr: Pi
|
|
33
|
+
* keeps its own closed reason code, a CC reason is always null (its status is kept; the full
|
|
34
|
+
* result stays on the wire receipt), an adapter throw is `unknown/adapter-threw` and a
|
|
35
|
+
* post-request cancellation `refused/request-aborted`. Nothing here mutates the identity store.
|
|
36
|
+
*/
|
|
37
|
+
import { randomBytes } from "node:crypto";
|
|
38
|
+
import { makeCcRetireDeps, requestCcRetire, } from "./cc-retire-request.js";
|
|
39
|
+
import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
|
|
40
|
+
import { inspectReceiverMarker } from "./entwurf-v2-visible-resume.js";
|
|
41
|
+
import { defaultMetaReceiversDir, defaultMetaSendersDir, defaultMetaSessionsDir, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaSenderMarker, } from "./meta-session.js";
|
|
42
|
+
import { requestPiRetire } from "./pi-retire-request.js";
|
|
43
|
+
import { appendRetirementEvent, RETIREMENT_EVENT_VERSION, retirementsDirBesideSessions, } from "./retirement-event.js";
|
|
44
|
+
import { CONTROL_SOCKET_DIR, inspectControlSocketPath } from "./socket-discovery.js";
|
|
45
|
+
import { probeSocketLiveness } from "./socket-probe.js";
|
|
46
|
+
/** Pi's closed reason union, exhaustively (a Record keyed by it fails to type if one is added or
|
|
47
|
+
* removed). Only these codes reach the sidecar; anything else becomes null. */
|
|
48
|
+
const PI_REASON_CODES = {
|
|
49
|
+
"target-backend-unsupported": true,
|
|
50
|
+
"target-identity-mismatch": true,
|
|
51
|
+
"target-closing": true,
|
|
52
|
+
"target-stale": true,
|
|
53
|
+
"target-context-unavailable": true,
|
|
54
|
+
"target-non-tui": true,
|
|
55
|
+
"target-busy": true,
|
|
56
|
+
"target-pending": true,
|
|
57
|
+
"malformed-response": true,
|
|
58
|
+
"transport-error": true,
|
|
59
|
+
"shutdown-call-failed": true,
|
|
60
|
+
};
|
|
61
|
+
/** The closed summary of an attempt that was recorded as requested. Any other outcome kind is a
|
|
62
|
+
* composition fault and throws, which the dispatcher reports as an outcome-record failure. */
|
|
63
|
+
function summarizeAttempt(backend, outcome) {
|
|
64
|
+
if (outcome.kind === "pi" && backend === "pi") {
|
|
65
|
+
const { result } = outcome;
|
|
66
|
+
const reason = "reason" in result && Object.hasOwn(PI_REASON_CODES, result.reason) ? result.reason : null;
|
|
67
|
+
return { status: result.boundary, reason };
|
|
68
|
+
}
|
|
69
|
+
if (outcome.kind === "claude-code" && backend === "claude-code")
|
|
70
|
+
return { status: outcome.result.outcome, reason: null };
|
|
71
|
+
if (outcome.kind === "unknown" && outcome.backend === backend)
|
|
72
|
+
return { status: "unknown", reason: "adapter-threw" };
|
|
73
|
+
if (outcome.kind === "refused" && outcome.reason === "request-aborted") {
|
|
74
|
+
return { status: "refused", reason: "request-aborted" };
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`no retirement summary for a ${outcome.kind} outcome on ${backend}`);
|
|
77
|
+
}
|
|
78
|
+
export function makeProductionRetireDeps(opts) {
|
|
79
|
+
// ── ONE path set: two module-load constants, three construction-time reads ──
|
|
80
|
+
const lockDir = opts.lockDir ?? ENTWURF_V2_LOCK_DIR;
|
|
81
|
+
const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
|
|
82
|
+
const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
|
|
83
|
+
const sendersDir = opts.sendersDir ?? defaultMetaSendersDir();
|
|
84
|
+
const controlSocketDir = opts.controlSocketDir ?? CONTROL_SOCKET_DIR;
|
|
85
|
+
const retirementsDir = retirementsDirBesideSessions(sessionsDir);
|
|
86
|
+
const signal = opts.signal;
|
|
87
|
+
const s = opts.seams ?? {};
|
|
88
|
+
const io = {
|
|
89
|
+
acquireLock: s.acquireLock ?? realAcquireLock,
|
|
90
|
+
releaseLock: s.releaseLock ?? realReleaseLock,
|
|
91
|
+
metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
|
|
92
|
+
readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
|
|
93
|
+
inspectPath: s.inspectPath ?? ((socketPath) => inspectControlSocketPath(socketPath)),
|
|
94
|
+
probeSocket: s.probeSocket ?? probeSocketLiveness,
|
|
95
|
+
inspectReceiver: s.inspectReceiver ?? inspectReceiverMarker,
|
|
96
|
+
readSender: s.readSender ?? readMetaSenderMarker,
|
|
97
|
+
requestPi: s.requestPi ?? requestPiRetire,
|
|
98
|
+
requestCc: s.requestCc ?? ((expected, deps, options) => requestCcRetire(expected, deps, options)),
|
|
99
|
+
append: s.append ?? appendRetirementEvent,
|
|
100
|
+
};
|
|
101
|
+
const attribution = (context) => ({
|
|
102
|
+
actorGardenId: context.actorGardenId,
|
|
103
|
+
targetGardenId: context.targetGardenId,
|
|
104
|
+
targetBackend: context.targetBackend,
|
|
105
|
+
});
|
|
106
|
+
// The CC adapter's three witnesses, closed over the SAME directories as the dispatcher's own
|
|
107
|
+
// reads. Its helper path and spawn are left to the adapter's defaults.
|
|
108
|
+
const ccDeps = makeCcRetireDeps({
|
|
109
|
+
readRecord: (gid) => io.readIdentity(gid, sessionsDir),
|
|
110
|
+
inspectReceiver: (gid) => io.inspectReceiver(gid, receiversDir),
|
|
111
|
+
readSender: (ownerPid) => io.readSender({ backend: "claude-code", ownerPid, sendersDir }),
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
resolveCaller: opts.resolveCaller,
|
|
115
|
+
// ONE lock domain: acquire and release are bound to the same directory.
|
|
116
|
+
acquireLock: (gid) => io.acquireLock(gid, { dir: lockDir }),
|
|
117
|
+
releaseLock: (claim) => io.releaseLock(claim, { dir: lockDir }),
|
|
118
|
+
recordExists: (gid) => io.metaRecordExists(gid, sessionsDir),
|
|
119
|
+
readIdentity: (gid) => io.readIdentity(gid, sessionsDir),
|
|
120
|
+
controlSocketDir,
|
|
121
|
+
inspectPath: (socketPath) => io.inspectPath(socketPath),
|
|
122
|
+
probeSocket: (socketPath) => io.probeSocket(socketPath),
|
|
123
|
+
requestPi: (target, expected, options) => io.requestPi(target, expected, { controlSocketDir: options.controlSocketDir }),
|
|
124
|
+
requestCc: (expected) => io.requestCc(expected, ccDeps, { signal }),
|
|
125
|
+
recordRequested: (context) => {
|
|
126
|
+
const eventId = randomBytes(16).toString("hex");
|
|
127
|
+
io.append(retirementsDir, {
|
|
128
|
+
v: RETIREMENT_EVENT_VERSION,
|
|
129
|
+
kind: "retirement-requested",
|
|
130
|
+
eventId,
|
|
131
|
+
at: new Date().toISOString(),
|
|
132
|
+
...attribution(context),
|
|
133
|
+
});
|
|
134
|
+
return eventId;
|
|
135
|
+
},
|
|
136
|
+
recordOutcome: (eventId, context, outcome) => {
|
|
137
|
+
io.append(retirementsDir, {
|
|
138
|
+
v: RETIREMENT_EVENT_VERSION,
|
|
139
|
+
kind: "retirement-outcome",
|
|
140
|
+
eventId,
|
|
141
|
+
at: new Date().toISOString(),
|
|
142
|
+
...attribution(context),
|
|
143
|
+
outcome: summarizeAttempt(context.targetBackend, outcome),
|
|
144
|
+
});
|
|
145
|
+
return undefined;
|
|
146
|
+
},
|
|
147
|
+
signal,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { controlSocketPathIn } from "./control-socket-path.js";
|
|
2
|
+
import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
|
|
3
|
+
import { isValidSessionId } from "./session-id.js";
|
|
4
|
+
import { isRecordLessSocketCandidate, mapInspectionToLiveness, } from "./socket-discovery.js";
|
|
5
|
+
const EVENT_ID_RE = /^[0-9a-f]{32}$/;
|
|
6
|
+
function causeOf(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
export async function retireCitizen(targetGardenId, deps) {
|
|
10
|
+
let actor = null;
|
|
11
|
+
const beforeLock = (outcome) => ({
|
|
12
|
+
targetGardenId,
|
|
13
|
+
actor,
|
|
14
|
+
outcome,
|
|
15
|
+
record: { status: "not-attempted" },
|
|
16
|
+
release: { status: "not-acquired" },
|
|
17
|
+
});
|
|
18
|
+
const aborted = () => deps.signal?.aborted === true;
|
|
19
|
+
if (!isValidSessionId(targetGardenId))
|
|
20
|
+
return beforeLock({ kind: "refused", reason: "target-invalid" });
|
|
21
|
+
let acquired;
|
|
22
|
+
try {
|
|
23
|
+
actor = await deps.resolveCaller();
|
|
24
|
+
if (!actor || !isValidSessionId(actor.sessionId) || actor.origin === "external-mcp") {
|
|
25
|
+
return beforeLock({ kind: "refused", reason: "caller-unavailable" });
|
|
26
|
+
}
|
|
27
|
+
if (actor.sessionId === targetGardenId)
|
|
28
|
+
return beforeLock({ kind: "refused", reason: "self-target" });
|
|
29
|
+
if (aborted())
|
|
30
|
+
return beforeLock({ kind: "refused", reason: "request-aborted" });
|
|
31
|
+
acquired = deps.acquireLock(targetGardenId);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return beforeLock({ kind: "preflight-error", cause: causeOf(error) });
|
|
35
|
+
}
|
|
36
|
+
if (!acquired.ok)
|
|
37
|
+
return beforeLock({ kind: "refused", reason: "target-locked", detail: acquired.conflict.detail });
|
|
38
|
+
const claim = acquired.claim;
|
|
39
|
+
const actorGardenId = actor.sessionId;
|
|
40
|
+
let enteredBackend = null;
|
|
41
|
+
// Assigned inside runLocked; the cast keeps control flow from narrowing it to null here.
|
|
42
|
+
let attempt = null;
|
|
43
|
+
let record = { status: "not-attempted" };
|
|
44
|
+
let outcome;
|
|
45
|
+
let release;
|
|
46
|
+
/** The last step before ONE adapter: cancellation, requested record, cancellation again.
|
|
47
|
+
* Returns the outcome that replaces the adapter, or null to enter it. */
|
|
48
|
+
const admit = (targetBackend) => {
|
|
49
|
+
if (aborted())
|
|
50
|
+
return { kind: "refused", reason: "request-aborted" };
|
|
51
|
+
const context = { actorGardenId, targetGardenId, targetBackend };
|
|
52
|
+
let eventId;
|
|
53
|
+
try {
|
|
54
|
+
eventId = deps.recordRequested(context);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
record = { status: "requested-append-error", cause: causeOf(error) };
|
|
58
|
+
return { kind: "refused", reason: "retirement-record-unavailable" };
|
|
59
|
+
}
|
|
60
|
+
if (typeof eventId !== "string" || !EVENT_ID_RE.test(eventId)) {
|
|
61
|
+
record = { status: "requested-append-error", cause: "recorder returned no event id" };
|
|
62
|
+
return { kind: "refused", reason: "retirement-record-unavailable" };
|
|
63
|
+
}
|
|
64
|
+
attempt = { eventId, context };
|
|
65
|
+
if (aborted())
|
|
66
|
+
return { kind: "refused", reason: "request-aborted" };
|
|
67
|
+
enteredBackend = targetBackend;
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
const runLocked = async () => {
|
|
71
|
+
const socketPath = controlSocketPathIn(deps.controlSocketDir, targetGardenId);
|
|
72
|
+
if (!deps.recordExists(targetGardenId)) {
|
|
73
|
+
const inspection = await deps.inspectPath(socketPath);
|
|
74
|
+
return {
|
|
75
|
+
kind: "refused",
|
|
76
|
+
reason: isRecordLessSocketCandidate(inspection) ? "record-less-socket" : "target-not-found",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const identity = deps.readIdentity(targetGardenId);
|
|
80
|
+
if (identity.gardenId !== targetGardenId)
|
|
81
|
+
throw new Error("addressable target read returned a different garden id");
|
|
82
|
+
if (identity.backend !== "pi" && identity.backend !== "claude-code") {
|
|
83
|
+
return { kind: "refused", reason: "target-backend-unsupported" };
|
|
84
|
+
}
|
|
85
|
+
const inspection = await deps.inspectPath(socketPath);
|
|
86
|
+
if (identity.backend === "pi") {
|
|
87
|
+
const live = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
88
|
+
if ("addressConflict" in live)
|
|
89
|
+
return { kind: "refused", reason: "target-address-conflict" };
|
|
90
|
+
if (live.liveness === "dead")
|
|
91
|
+
return { kind: "refused", reason: "target-dead" };
|
|
92
|
+
if (live.liveness !== "alive")
|
|
93
|
+
return { kind: "refused", reason: "target-indeterminate" };
|
|
94
|
+
// Fresh after awaited lstat/connect; timestamps are metadata, not birth identity.
|
|
95
|
+
const current = deps.readIdentity(targetGardenId);
|
|
96
|
+
if (["gardenId", "backend", "nativeSessionId", "createdAt"].some((key) => current[key] !== identity[key])) {
|
|
97
|
+
return { kind: "refused", reason: "target-drifted" };
|
|
98
|
+
}
|
|
99
|
+
const replaced = admit("pi");
|
|
100
|
+
if (replaced)
|
|
101
|
+
return replaced;
|
|
102
|
+
const result = await deps.requestPi(current, { gardenId: current.gardenId, nativeSessionId: current.nativeSessionId }, {
|
|
103
|
+
controlSocketDir: deps.controlSocketDir,
|
|
104
|
+
});
|
|
105
|
+
return { kind: "pi", result };
|
|
106
|
+
}
|
|
107
|
+
// Same out-of-socket-domain conflict policy as entwurf-v2-production. Non-socket
|
|
108
|
+
// regular entries do not claim a garden address; symlinks/socket entries do.
|
|
109
|
+
if (inspection.kind === "indeterminate")
|
|
110
|
+
throw new Error(`control address inspection indeterminate: ${inspection.error}`);
|
|
111
|
+
const sockets = new Set(inspection.kind === "socket-file" ? [targetGardenId] : []);
|
|
112
|
+
const symlinks = new Set(inspection.kind === "address-conflict" && inspection.reason === "symlink" ? [targetGardenId] : []);
|
|
113
|
+
if (isOutOfSocketDomainGardenIdConflict(identity.backend, targetGardenId, sockets, symlinks)) {
|
|
114
|
+
return { kind: "refused", reason: "target-address-conflict" };
|
|
115
|
+
}
|
|
116
|
+
const replaced = admit("claude-code");
|
|
117
|
+
if (replaced)
|
|
118
|
+
return replaced;
|
|
119
|
+
const { gardenId, backend, nativeSessionId, createdAt, attachIncarnation } = identity;
|
|
120
|
+
const result = await deps.requestCc({ gardenId, backend, nativeSessionId, createdAt, attachIncarnation });
|
|
121
|
+
return { kind: "claude-code", result };
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
try {
|
|
125
|
+
outcome = await runLocked();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
outcome =
|
|
129
|
+
enteredBackend === null
|
|
130
|
+
? { kind: "preflight-error", cause: causeOf(error) }
|
|
131
|
+
: { kind: "unknown", backend: enteredBackend, cause: causeOf(error) };
|
|
132
|
+
}
|
|
133
|
+
// An attempt that was recorded as requested gets ONE outcome record, before release.
|
|
134
|
+
if (attempt) {
|
|
135
|
+
const { eventId, context } = attempt;
|
|
136
|
+
try {
|
|
137
|
+
const returned = deps.recordOutcome(eventId, context, outcome);
|
|
138
|
+
record =
|
|
139
|
+
returned === undefined
|
|
140
|
+
? { status: "complete", eventId }
|
|
141
|
+
: { status: "outcome-append-error", eventId, cause: "recorder returned a value" };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
record = { status: "outcome-append-error", eventId, cause: causeOf(error) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
// One acquired claim, one release attempt. No throw here may overwrite outcome.
|
|
150
|
+
try {
|
|
151
|
+
release = { status: deps.releaseLock(claim) };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
release = { status: "error", cause: causeOf(error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { targetGardenId, actor, outcome, record, release };
|
|
158
|
+
}
|
|
@@ -48,7 +48,8 @@ export function actionableRejectHint(reason) {
|
|
|
48
48
|
return ("this citizen's record is intact but its session is not running, and delivery has no way to wake it. " +
|
|
49
49
|
"The resume that used to answer here launched a hidden background child and was withdrawn under the " +
|
|
50
50
|
"visible-first rule; the visible same-id resume is the separate lifecycle verb entwurf_resume_call. " +
|
|
51
|
-
"
|
|
51
|
+
"Only on an explicit operator request, use entwurf_resume_call {target} for Pi or Claude Code; " +
|
|
52
|
+
"it submits no prompt. This capability hint is not permission to resume automatically.");
|
|
52
53
|
case "indeterminate-no-spawn":
|
|
53
54
|
// The socket probe did not settle, so liveness is UNKNOWN — an unestablished probe
|
|
54
55
|
// is not a measured death, and dispatching into it could double-deliver. Say
|