@junghanacs/entwurf 0.39.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 +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -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 +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- 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-mailbox-body.js +10 -4
- 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/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -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/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- 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-mailbox-body.ts +13 -4
- 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/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -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 +122 -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 +20 -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-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -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/control-send-receipt.json +2 -2
- 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/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -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/remote-pair-receive.ts +73 -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,195 @@
|
|
|
1
|
+
/** Internal Pi lifecycle leaf, not an authorization boundary. The future core caller must
|
|
2
|
+
* authorize retirement (and reject self-retirement) before reaching this local socket.
|
|
3
|
+
* No prompt, abort, queue clearing, deferred shutdown, or retry is performed here.
|
|
4
|
+
*/
|
|
5
|
+
import type * as net from "node:net";
|
|
6
|
+
import type { RpcResponse } from "./entwurf-control-rpc.js";
|
|
7
|
+
|
|
8
|
+
export interface RetireContext {
|
|
9
|
+
readonly mode: string;
|
|
10
|
+
readonly sessionManager: { getSessionId(): string };
|
|
11
|
+
isIdle(): boolean;
|
|
12
|
+
hasPendingMessages(): boolean;
|
|
13
|
+
shutdown(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Object identity is the session binding generation, captured when a connection is accepted.
|
|
17
|
+
* A listener may survive same-path rebinds; old accepted connections retain their old binding.
|
|
18
|
+
*/
|
|
19
|
+
export interface ControlBinding {
|
|
20
|
+
readonly gardenId: string;
|
|
21
|
+
readonly nativeSessionId: string;
|
|
22
|
+
readonly context: RetireContext;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RetireState {
|
|
26
|
+
context: RetireContext | null;
|
|
27
|
+
binding: ControlBinding | null;
|
|
28
|
+
closing: boolean;
|
|
29
|
+
retireRequested: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ControlServerState<C extends RetireContext> extends RetireState {
|
|
33
|
+
context: C | null;
|
|
34
|
+
server: net.Server | null;
|
|
35
|
+
socketPath: string | null;
|
|
36
|
+
connections: Set<net.Socket>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Production start/rebind seam. Same-path rebind is entirely synchronous and performs NO
|
|
40
|
+
* listener/path IO. Each accept snapshots the binding via acceptControlConnection below.
|
|
41
|
+
*/
|
|
42
|
+
export async function startOrRebindControlServer<C extends RetireContext>(
|
|
43
|
+
state: ControlServerState<C>,
|
|
44
|
+
ctx: C,
|
|
45
|
+
socketPath: string,
|
|
46
|
+
gardenId: string,
|
|
47
|
+
io: {
|
|
48
|
+
prepare(): Promise<void>;
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
remove(socketPath: string): Promise<void>;
|
|
51
|
+
create(socketPath: string): Promise<net.Server>;
|
|
52
|
+
},
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const binding: ControlBinding = { gardenId, nativeSessionId: ctx.sessionManager.getSessionId(), context: ctx };
|
|
55
|
+
const bind = () => {
|
|
56
|
+
state.context = ctx;
|
|
57
|
+
state.binding = binding;
|
|
58
|
+
state.closing = false;
|
|
59
|
+
state.retireRequested = false;
|
|
60
|
+
};
|
|
61
|
+
if (state.socketPath === socketPath && state.server) {
|
|
62
|
+
bind();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await io.prepare();
|
|
66
|
+
await io.stop();
|
|
67
|
+
await io.remove(socketPath);
|
|
68
|
+
bind();
|
|
69
|
+
state.socketPath = socketPath;
|
|
70
|
+
state.server = await io.create(socketPath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Snapshot exactly once, on accept, NOT when the listener was created or a command arrives. */
|
|
74
|
+
export function acceptControlConnection(
|
|
75
|
+
state: Pick<ControlServerState<RetireContext>, "binding" | "connections">,
|
|
76
|
+
socket: net.Socket,
|
|
77
|
+
): ControlBinding | null {
|
|
78
|
+
state.connections.add(socket);
|
|
79
|
+
socket.once("close", () => state.connections.delete(socket));
|
|
80
|
+
return state.binding;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type RetireResult =
|
|
84
|
+
| { boundary: "shutdown-requested" }
|
|
85
|
+
| { boundary: "unknown"; reason: "shutdown-call-failed" }
|
|
86
|
+
| {
|
|
87
|
+
boundary: "refused";
|
|
88
|
+
reason:
|
|
89
|
+
| "target-closing"
|
|
90
|
+
| "target-stale"
|
|
91
|
+
| "target-context-unavailable"
|
|
92
|
+
| "target-non-tui"
|
|
93
|
+
| "target-busy"
|
|
94
|
+
| "target-pending";
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Entire final check + SDK void call is synchronous. Call only AFTER any async preflight.
|
|
98
|
+
* A returned request receipt proves neither process exit nor persistence/flush completion.
|
|
99
|
+
* After requested/unknown, unobserved exit can leave a connect-alive socket refusing commands
|
|
100
|
+
* as target-closing until exit or a new session_start. No same-generation unlatch or retry.
|
|
101
|
+
*/
|
|
102
|
+
export function requestControlRetire(
|
|
103
|
+
state: RetireState,
|
|
104
|
+
serverBinding: ControlBinding | null,
|
|
105
|
+
residentGardenId: string | null,
|
|
106
|
+
expected: { gardenId: unknown; nativeSessionId: unknown },
|
|
107
|
+
): RetireResult {
|
|
108
|
+
if (state.closing || state.retireRequested) return { boundary: "refused", reason: "target-closing" };
|
|
109
|
+
const ctx = state.context;
|
|
110
|
+
if (
|
|
111
|
+
!serverBinding ||
|
|
112
|
+
!ctx ||
|
|
113
|
+
state.binding !== serverBinding ||
|
|
114
|
+
ctx !== serverBinding.context ||
|
|
115
|
+
residentGardenId !== serverBinding.gardenId ||
|
|
116
|
+
expected.gardenId !== serverBinding.gardenId ||
|
|
117
|
+
expected.nativeSessionId !== serverBinding.nativeSessionId
|
|
118
|
+
) {
|
|
119
|
+
return { boundary: "refused", reason: "target-stale" };
|
|
120
|
+
}
|
|
121
|
+
let shutdown: () => void;
|
|
122
|
+
try {
|
|
123
|
+
if (ctx.sessionManager.getSessionId() !== serverBinding.nativeSessionId) {
|
|
124
|
+
return { boundary: "refused", reason: "target-stale" };
|
|
125
|
+
}
|
|
126
|
+
if (ctx.mode !== "tui") return { boundary: "refused", reason: "target-non-tui" };
|
|
127
|
+
if (!ctx.isIdle()) return { boundary: "refused", reason: "target-busy" };
|
|
128
|
+
if (ctx.hasPendingMessages()) return { boundary: "refused", reason: "target-pending" };
|
|
129
|
+
shutdown = ctx.shutdown;
|
|
130
|
+
} catch {
|
|
131
|
+
// SDK invalidates captured contexts on replacement/reload. Never call through one.
|
|
132
|
+
return { boundary: "refused", reason: "target-context-unavailable" };
|
|
133
|
+
}
|
|
134
|
+
state.retireRequested = true;
|
|
135
|
+
try {
|
|
136
|
+
shutdown.call(ctx);
|
|
137
|
+
} catch {
|
|
138
|
+
// The call may have started shutdown before throwing. Do not invite a replay.
|
|
139
|
+
return { boundary: "unknown", reason: "shutdown-call-failed" };
|
|
140
|
+
}
|
|
141
|
+
return { boundary: "shutdown-requested" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Invalidate BEFORE waiting for server.close: accepted old connections must not act. */
|
|
145
|
+
export function invalidateControlBinding(state: RetireState): void {
|
|
146
|
+
state.closing = true;
|
|
147
|
+
state.binding = null;
|
|
148
|
+
state.context = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The production receiver's one wire envelope. The RpcResponse import is type-only:
|
|
152
|
+
* no runtime dependency on the RPC client is introduced. An absent id is omitted by JSON;
|
|
153
|
+
* error is present only for refused/unknown, matching the strict core-side parser.
|
|
154
|
+
*/
|
|
155
|
+
export function retireResponseEnvelope(result: RetireResult, id?: string): RpcResponse {
|
|
156
|
+
return {
|
|
157
|
+
type: "response",
|
|
158
|
+
command: "retire",
|
|
159
|
+
id,
|
|
160
|
+
success: result.boundary === "shutdown-requested",
|
|
161
|
+
data: result,
|
|
162
|
+
...(result.boundary !== "shutdown-requested" ? { error: result.reason } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One response, then FIN; never wait for SDK termination to acknowledge its request.
|
|
167
|
+
* A failed transport leaves the caller with unknown outcome, not retry authority.
|
|
168
|
+
* The internal client sends one command per connection: concurrent responses on this same
|
|
169
|
+
* connection can be lost when retire sends FIN. It is not a multiplexed lifecycle protocol.
|
|
170
|
+
*/
|
|
171
|
+
export function endRetireResponse(socket: net.Socket, response: unknown): void {
|
|
172
|
+
try {
|
|
173
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
174
|
+
} catch {
|
|
175
|
+
socket.destroy();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Session teardown only: flush accepted writes with FIN, then bound a non-cooperating peer.
|
|
180
|
+
* This timer exists solely for this server.close operation, not as a resident watcher.
|
|
181
|
+
* Applied to all session shutdowns: other in-flight replies may become transport-unknown.
|
|
182
|
+
*/
|
|
183
|
+
export async function closeControlConnections(server: net.Server, sockets: Set<net.Socket>): Promise<void> {
|
|
184
|
+
await new Promise<void>((resolve) => {
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
for (const socket of sockets) socket.destroy();
|
|
187
|
+
}, 250);
|
|
188
|
+
timer.unref();
|
|
189
|
+
server.close(() => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
for (const socket of sockets) socket.end();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -101,7 +101,23 @@ export interface RpcGetInfoCommand {
|
|
|
101
101
|
id?: string;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
/** Internal lifecycle request. The core must authorize it; this local socket is not
|
|
105
|
+
* a cryptographic authorization boundary. Exact target identity is mandatory.
|
|
106
|
+
*/
|
|
107
|
+
export interface RpcRetireCommand {
|
|
108
|
+
type: "retire";
|
|
109
|
+
gardenId: string;
|
|
110
|
+
nativeSessionId: string;
|
|
111
|
+
id?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type RpcCommand =
|
|
115
|
+
| RpcSendCommand
|
|
116
|
+
| RpcGetMessageCommand
|
|
117
|
+
| RpcClearCommand
|
|
118
|
+
| RpcAbortCommand
|
|
119
|
+
| RpcGetInfoCommand
|
|
120
|
+
| RpcRetireCommand;
|
|
105
121
|
|
|
106
122
|
// ============================================================================
|
|
107
123
|
// Accepted-connection disconnect policy (server half of the same wire)
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
|
|
38
|
+
import { randomBytes } from "node:crypto";
|
|
39
|
+
import {
|
|
40
|
+
type CcRetireDeps,
|
|
41
|
+
type CcRetireExpected,
|
|
42
|
+
type CcRetireOptions,
|
|
43
|
+
type CcRetireResult,
|
|
44
|
+
makeCcRetireDeps,
|
|
45
|
+
requestCcRetire,
|
|
46
|
+
} from "./cc-retire-request.ts";
|
|
47
|
+
import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
|
|
48
|
+
import {
|
|
49
|
+
type AcquireLockResult,
|
|
50
|
+
ENTWURF_V2_LOCK_DIR,
|
|
51
|
+
type LockClaim,
|
|
52
|
+
type ReleaseResult,
|
|
53
|
+
acquireLock as realAcquireLock,
|
|
54
|
+
releaseLock as realReleaseLock,
|
|
55
|
+
} from "./entwurf-v2-lock.ts";
|
|
56
|
+
import type {
|
|
57
|
+
RetireDispatcherDeps,
|
|
58
|
+
RetireDispatchOutcome,
|
|
59
|
+
RetireRecordContext,
|
|
60
|
+
RetireTargetBackend,
|
|
61
|
+
} from "./entwurf-v2-retire.ts";
|
|
62
|
+
import { inspectReceiverMarker, type ReceiverInspection } from "./entwurf-v2-visible-resume.ts";
|
|
63
|
+
import {
|
|
64
|
+
defaultMetaReceiversDir,
|
|
65
|
+
defaultMetaSendersDir,
|
|
66
|
+
defaultMetaSessionsDir,
|
|
67
|
+
type MetaIdentity,
|
|
68
|
+
type MetaSenderMarker,
|
|
69
|
+
metaRecordExistsByGardenId,
|
|
70
|
+
readAddressableMetaIdentity,
|
|
71
|
+
readMetaSenderMarker,
|
|
72
|
+
} from "./meta-session.ts";
|
|
73
|
+
import { type PiRetireRequestResult, requestPiRetire } from "./pi-retire-request.ts";
|
|
74
|
+
import {
|
|
75
|
+
appendRetirementEvent,
|
|
76
|
+
RETIREMENT_EVENT_VERSION,
|
|
77
|
+
type RetirementEvent,
|
|
78
|
+
type RetirementOutcomeSummary,
|
|
79
|
+
retirementsDirBesideSessions,
|
|
80
|
+
} from "./retirement-event.ts";
|
|
81
|
+
import { CONTROL_SOCKET_DIR, inspectControlSocketPath, type TargetSocketInspection } from "./socket-discovery.ts";
|
|
82
|
+
import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
|
|
83
|
+
|
|
84
|
+
/** The leaf IO the factory calls. Each defaults to the real owning function; tests override
|
|
85
|
+
* them with spies to prove which directory reaches which seam. */
|
|
86
|
+
export interface ProductionRetireSeams {
|
|
87
|
+
acquireLock: (gid: string, deps: { dir: string }) => AcquireLockResult;
|
|
88
|
+
releaseLock: (claim: LockClaim, deps: { dir: string }) => ReleaseResult;
|
|
89
|
+
metaRecordExists: (gid: string, sessionsDir: string) => boolean;
|
|
90
|
+
readIdentity: (gid: string, sessionsDir: string) => MetaIdentity;
|
|
91
|
+
inspectPath: (socketPath: string) => Promise<TargetSocketInspection>;
|
|
92
|
+
probeSocket: (socketPath: string) => Promise<SocketLiveness>;
|
|
93
|
+
inspectReceiver: (gid: string, receiversDir: string) => ReceiverInspection;
|
|
94
|
+
readSender: (opts: { backend: "claude-code"; ownerPid: number; sendersDir: string }) => MetaSenderMarker | null;
|
|
95
|
+
requestPi: (
|
|
96
|
+
target: MetaIdentity,
|
|
97
|
+
expected: { gardenId: string; nativeSessionId: string },
|
|
98
|
+
options: { controlSocketDir: string },
|
|
99
|
+
) => Promise<PiRetireRequestResult>;
|
|
100
|
+
requestCc: (expected: CcRetireExpected, deps: CcRetireDeps, options: CcRetireOptions) => Promise<CcRetireResult>;
|
|
101
|
+
append: (retirementsDir: string, event: RetirementEvent) => { bytes: number };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type PiReason = Extract<PiRetireRequestResult, { reason: string }>["reason"];
|
|
105
|
+
/** Pi's closed reason union, exhaustively (a Record keyed by it fails to type if one is added or
|
|
106
|
+
* removed). Only these codes reach the sidecar; anything else becomes null. */
|
|
107
|
+
const PI_REASON_CODES: Record<PiReason, true> = {
|
|
108
|
+
"target-backend-unsupported": true,
|
|
109
|
+
"target-identity-mismatch": true,
|
|
110
|
+
"target-closing": true,
|
|
111
|
+
"target-stale": true,
|
|
112
|
+
"target-context-unavailable": true,
|
|
113
|
+
"target-non-tui": true,
|
|
114
|
+
"target-busy": true,
|
|
115
|
+
"target-pending": true,
|
|
116
|
+
"malformed-response": true,
|
|
117
|
+
"transport-error": true,
|
|
118
|
+
"shutdown-call-failed": true,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** The closed summary of an attempt that was recorded as requested. Any other outcome kind is a
|
|
122
|
+
* composition fault and throws, which the dispatcher reports as an outcome-record failure. */
|
|
123
|
+
function summarizeAttempt(backend: RetireTargetBackend, outcome: RetireDispatchOutcome): RetirementOutcomeSummary {
|
|
124
|
+
if (outcome.kind === "pi" && backend === "pi") {
|
|
125
|
+
const { result } = outcome;
|
|
126
|
+
const reason = "reason" in result && Object.hasOwn(PI_REASON_CODES, result.reason) ? result.reason : null;
|
|
127
|
+
return { status: result.boundary, reason };
|
|
128
|
+
}
|
|
129
|
+
if (outcome.kind === "claude-code" && backend === "claude-code")
|
|
130
|
+
return { status: outcome.result.outcome, reason: null };
|
|
131
|
+
if (outcome.kind === "unknown" && outcome.backend === backend) return { status: "unknown", reason: "adapter-threw" };
|
|
132
|
+
if (outcome.kind === "refused" && outcome.reason === "request-aborted") {
|
|
133
|
+
return { status: "refused", reason: "request-aborted" };
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`no retirement summary for a ${outcome.kind} outcome on ${backend}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ProductionRetireOpts {
|
|
139
|
+
/** The trusted host's caller resolution (attribution / self-target refusal), NOT authorization. */
|
|
140
|
+
resolveCaller: () => Promise<SenderEnvelope | null>;
|
|
141
|
+
/** This request's abort signal, for the core cancellation checks and the CC adapter. */
|
|
142
|
+
signal?: AbortSignal;
|
|
143
|
+
lockDir?: string;
|
|
144
|
+
sessionsDir?: string;
|
|
145
|
+
receiversDir?: string;
|
|
146
|
+
sendersDir?: string;
|
|
147
|
+
controlSocketDir?: string;
|
|
148
|
+
/** Test seam overrides — defaults are the real IO. */
|
|
149
|
+
seams?: Partial<ProductionRetireSeams>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function makeProductionRetireDeps(opts: ProductionRetireOpts): RetireDispatcherDeps {
|
|
153
|
+
// ── ONE path set: two module-load constants, three construction-time reads ──
|
|
154
|
+
const lockDir = opts.lockDir ?? ENTWURF_V2_LOCK_DIR;
|
|
155
|
+
const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
|
|
156
|
+
const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
|
|
157
|
+
const sendersDir = opts.sendersDir ?? defaultMetaSendersDir();
|
|
158
|
+
const controlSocketDir = opts.controlSocketDir ?? CONTROL_SOCKET_DIR;
|
|
159
|
+
const retirementsDir = retirementsDirBesideSessions(sessionsDir);
|
|
160
|
+
const signal = opts.signal;
|
|
161
|
+
|
|
162
|
+
const s = opts.seams ?? {};
|
|
163
|
+
const io: ProductionRetireSeams = {
|
|
164
|
+
acquireLock: s.acquireLock ?? realAcquireLock,
|
|
165
|
+
releaseLock: s.releaseLock ?? realReleaseLock,
|
|
166
|
+
metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
|
|
167
|
+
readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
|
|
168
|
+
inspectPath: s.inspectPath ?? ((socketPath) => inspectControlSocketPath(socketPath)),
|
|
169
|
+
probeSocket: s.probeSocket ?? probeSocketLiveness,
|
|
170
|
+
inspectReceiver: s.inspectReceiver ?? inspectReceiverMarker,
|
|
171
|
+
readSender: s.readSender ?? readMetaSenderMarker,
|
|
172
|
+
requestPi: s.requestPi ?? requestPiRetire,
|
|
173
|
+
requestCc: s.requestCc ?? ((expected, deps, options) => requestCcRetire(expected, deps, options)),
|
|
174
|
+
append: s.append ?? appendRetirementEvent,
|
|
175
|
+
};
|
|
176
|
+
const attribution = (context: RetireRecordContext) => ({
|
|
177
|
+
actorGardenId: context.actorGardenId,
|
|
178
|
+
targetGardenId: context.targetGardenId,
|
|
179
|
+
targetBackend: context.targetBackend,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// The CC adapter's three witnesses, closed over the SAME directories as the dispatcher's own
|
|
183
|
+
// reads. Its helper path and spawn are left to the adapter's defaults.
|
|
184
|
+
const ccDeps = makeCcRetireDeps({
|
|
185
|
+
readRecord: (gid) => io.readIdentity(gid, sessionsDir),
|
|
186
|
+
inspectReceiver: (gid) => io.inspectReceiver(gid, receiversDir),
|
|
187
|
+
readSender: (ownerPid) => io.readSender({ backend: "claude-code", ownerPid, sendersDir }),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
resolveCaller: opts.resolveCaller,
|
|
192
|
+
// ONE lock domain: acquire and release are bound to the same directory.
|
|
193
|
+
acquireLock: (gid) => io.acquireLock(gid, { dir: lockDir }),
|
|
194
|
+
releaseLock: (claim) => io.releaseLock(claim, { dir: lockDir }),
|
|
195
|
+
recordExists: (gid) => io.metaRecordExists(gid, sessionsDir),
|
|
196
|
+
readIdentity: (gid) => io.readIdentity(gid, sessionsDir),
|
|
197
|
+
controlSocketDir,
|
|
198
|
+
inspectPath: (socketPath) => io.inspectPath(socketPath),
|
|
199
|
+
probeSocket: (socketPath) => io.probeSocket(socketPath),
|
|
200
|
+
requestPi: (target, expected, options) =>
|
|
201
|
+
io.requestPi(target, expected, { controlSocketDir: options.controlSocketDir }),
|
|
202
|
+
requestCc: (expected) => io.requestCc(expected, ccDeps, { signal }),
|
|
203
|
+
recordRequested: (context) => {
|
|
204
|
+
const eventId = randomBytes(16).toString("hex");
|
|
205
|
+
io.append(retirementsDir, {
|
|
206
|
+
v: RETIREMENT_EVENT_VERSION,
|
|
207
|
+
kind: "retirement-requested",
|
|
208
|
+
eventId,
|
|
209
|
+
at: new Date().toISOString(),
|
|
210
|
+
...attribution(context),
|
|
211
|
+
});
|
|
212
|
+
return eventId;
|
|
213
|
+
},
|
|
214
|
+
recordOutcome: (eventId, context, outcome) => {
|
|
215
|
+
io.append(retirementsDir, {
|
|
216
|
+
v: RETIREMENT_EVENT_VERSION,
|
|
217
|
+
kind: "retirement-outcome",
|
|
218
|
+
eventId,
|
|
219
|
+
at: new Date().toISOString(),
|
|
220
|
+
...attribution(context),
|
|
221
|
+
outcome: summarizeAttempt(context.targetBackend, outcome),
|
|
222
|
+
});
|
|
223
|
+
return undefined;
|
|
224
|
+
},
|
|
225
|
+
signal,
|
|
226
|
+
};
|
|
227
|
+
}
|