@henols/vice-mcp 0.1.12 → 0.2.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.
@@ -0,0 +1,500 @@
1
+ #!/usr/bin/env node
2
+ // stock-recycle.ts
3
+ //
4
+ // THE stock-backend implementation of `vice_recycle` (TIME-04, D-01). The
5
+ // destructive action itself -- `lease.brokerControl.recycle(lease.targetId)`
6
+ // -- is a broker control-plane RPC already shared by both backends; the
7
+ // thing that was fork-only was the EVIDENCE GATHERER feeding the incident
8
+ // record before that RPC runs. The fork's own gatherWedgeEvidence()
9
+ // (vice-proxy.ts) calls rewriteArguments()/forwardToVice() to translate a
10
+ // screenshot's container path to a host path -- exactly the seam
11
+ // stock-derived.ts's own header names as the SECOND consumer of this
12
+ // project's "derived tools must be intercepted before forwardToVice()"
13
+ // constraint (CLAUDE.md). This file is that fix: a stock-native gatherer
14
+ // built entirely on plan 07-06's already-exported primitives
15
+ // (resolveStockLiveIrqHandler(), gatherStockCheckpointTrapEvidence(),
16
+ // runStockLivenessBracket()), with NO screenshot at all -- SHOT-* was cut
17
+ // from this milestone's scope (ROADMAP), and stock has no
18
+ // vice_display_screenshot to translate a path for in the first place.
19
+ //
20
+ // The record-before-request ordering (D-17) is preserved byte-for-byte:
21
+ // gather evidence -> write the incident record -> only THEN send the
22
+ // recycle RPC. There is no argument, branch or environment read between the
23
+ // write and the RPC that can reach the RPC with the write skipped.
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never import vice-proxy.ts, and never call rewriteArguments() or
27
+ // forwardToVice() -- this is the fix for exactly that coupling, not a
28
+ // second instance of it.
29
+ // - Never build a host path, and never call vice_display_screenshot (it
30
+ // does not exist on stock) -- screenshot/snapshot are left `undefined`
31
+ // on the returned IncidentEvidence, a deliberate scope decision
32
+ // (renderIncidentRecord()'s own documented "undefined = not this
33
+ // record's concern" skip), never an `{ available: false }` placeholder
34
+ // that would read as a failed capture.
35
+ // - Never re-derive the liveness bracket, the checkpoint-trap algorithm or
36
+ // the IRQ-handler resolution here -- reuse resolveStockLiveIrqHandler(),
37
+ // gatherStockCheckpointTrapEvidence() and runStockLivenessBracket()
38
+ // verbatim from stock-diagnose.ts (07-06). A second definition of any of
39
+ // these is the "re-deriving a cross-cutting seam locally" anti-pattern
40
+ // this codebase's own CLAUDE.md names.
41
+ // - Never import clearHeldStockSession() from stock-dispatch.ts.
42
+ // stock-dispatch.ts registers handleRecycleStock into
43
+ // STOCK_DISPATCH_TABLE (plan 07-09), so a runtime back-import here would
44
+ // close the module cycle stock-dispatch.ts -> stock-recycle.ts ->
45
+ // stock-dispatch.ts that load-order.test.ts exists to forbid. Only a
46
+ // type-only import of StockDispatchDeps is permitted (it erases
47
+ // completely under verbatimModuleSyntax).
48
+ // - Never let a single evidence step's failure abort the gather, and
49
+ // never let the gather stall the recycle itself -- every step in
50
+ // gatherStockWedgeEvidence() goes through captureStep(), which races a
51
+ // deadline and always resolves rather than rejecting. A wedged machine
52
+ // that fails every read must still produce a record and still recycle.
53
+ //
54
+ // Registration into STOCK_DISPATCH_TABLE / tools-manifest.stock.json is
55
+ // explicitly plan 07-09's job, matching 07-06's own stated output boundary.
56
+ import { writeIncidentRecord, finaliseIncidentRecord, type IncidentEvidence, type EvidenceItem } from "./incident-record.ts";
57
+ import {
58
+ resolveStockLiveIrqHandler,
59
+ gatherStockCheckpointTrapEvidence,
60
+ runStockLivenessBracket,
61
+ type StockLivenessBracketResult,
62
+ } from "./stock-diagnose.ts";
63
+ import { handleRegistersGet } from "./stock-registers.ts";
64
+ import { stockAnswer, isErrorText, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
65
+ import { stockDisconnect, type StockConnectSession } from "./stock-connect.ts";
66
+ import type { StockDispatchDeps } from "./stock-dispatch.ts";
67
+ import { readEpoch } from "./vice.ts";
68
+
69
+ function describeError(err: unknown): string {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
72
+
73
+ function formatAddress(n: unknown): string {
74
+ return typeof n === "number" ? `$${n.toString(16).toUpperCase().padStart(4, "0")}` : "unknown";
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // captureStep() -- ported from vice-proxy.ts's own step wrapper (task 1's
79
+ // read_first). Races one evidence-gathering step against a deadline,
80
+ // turning any rejection, transport failure or deadline expiry into an
81
+ // explicit `{ available: false, reason }` entry rather than letting it
82
+ // abort the whole gather. Never throws. Module-local -- the fork's own
83
+ // captureStep() is proxy-private too, never exported.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ type CaptureStepResult<T> = { available: true; value: T } | { available: false; reason: string };
87
+
88
+ const DEFAULT_CAPTURE_STEP_TIMEOUT_MS = 8000;
89
+
90
+ /** Read fresh on EVERY call -- deliberately NOT a module-level constant the
91
+ * way vice-proxy.ts's own CAPTURE_STEP_TIMEOUT_MS is. Same rationale
92
+ * stock-diagnose.ts's diagnoseSessionTimeoutMs()/diagnoseBracketWindowMs()
93
+ * already documents: under this project's ESM/verbatimModuleSyntax setup, a
94
+ * static `import` is hoisted ahead of any top-level statement in the
95
+ * IMPORTING file, so a test file cannot set `process.env` before a
96
+ * load-time module constant is computed without a dynamic re-import per
97
+ * test case -- and this module's own test suite needs both the generous
98
+ * production default AND a sub-50ms deadline (the never-settles case)
99
+ * within a single process. Overridable via `VICE_RECYCLE_CAPTURE_TIMEOUT_MS`
100
+ * -- the SAME environment variable vice-proxy.ts's own step deadline reads,
101
+ * so one knob governs both backends. Exported so the test file can assert
102
+ * the default directly without reaching into process.env itself. */
103
+ export function stockCaptureStepTimeoutMs(): number {
104
+ const raw = process.env.VICE_RECYCLE_CAPTURE_TIMEOUT_MS;
105
+ if (raw === undefined || raw === "") return DEFAULT_CAPTURE_STEP_TIMEOUT_MS;
106
+ const parsed = Number(raw);
107
+ // WR-15 (07-REVIEW.md): `> 0`, not `>= 0`. With 0 every capture step's
108
+ // deadline fires immediately, so a DESTRUCTIVE action (a recycle) writes a
109
+ // permanent, repo-tracked incident record containing no evidence at all --
110
+ // and the record itself gives no hint that a misconfiguration, rather than a
111
+ // wedged emulator, is why every item came back unavailable. A rejected value
112
+ // is logged with the default being used; a silent fallback is how a stray 0
113
+ // in a shell profile stays invisible for a whole session.
114
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
115
+ console.error(
116
+ `VICE_RECYCLE_CAPTURE_TIMEOUT_MS=${JSON.stringify(raw)} is not a positive number of milliseconds -- ignoring it and using the ` +
117
+ `default ${DEFAULT_CAPTURE_STEP_TIMEOUT_MS}ms. A value of 0 would make every evidence-capture step time out instantly, ` +
118
+ `producing an evidence-free incident record for a destructive recycle.`,
119
+ );
120
+ return DEFAULT_CAPTURE_STEP_TIMEOUT_MS;
121
+ }
122
+
123
+ async function captureStep<T>(fn: () => Promise<T>): Promise<CaptureStepResult<T>> {
124
+ let timer: NodeJS.Timeout | undefined;
125
+ try {
126
+ const value = await Promise.race([
127
+ fn(),
128
+ new Promise<never>((_, reject) => {
129
+ const timeoutMs = stockCaptureStepTimeoutMs();
130
+ timer = setTimeout(() => reject(new Error(`capture step deadline of ${timeoutMs}ms exceeded`)), timeoutMs);
131
+ }),
132
+ ]);
133
+ return { available: true, value };
134
+ } catch (e) {
135
+ return { available: false, reason: e && (e as Error).message ? (e as Error).message : String(e) };
136
+ } finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // gatherStockWedgeEvidence() -- the four stock-native evidence items.
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** Shapes a completed bracket into the value formatEvidenceValue()'s
146
+ * `bracket` branch renders (it reads `cycles`/`elapsedMs`, unchanged from
147
+ * incident-record.ts): on the `cpu_history` route `cycles` is the real
148
+ * numeric delta; on the `frame_position` route `cycles` carries the
149
+ * position delta AND an explicit inline note that it is a within-one-frame
150
+ * figure, not a true cycle count -- folded into the same field because
151
+ * incident-record.ts's own renderer only ever prints `cycles`/`elapsedMs`
152
+ * and this module may not add a third rendered field to it. Only called
153
+ * once the caller has already excluded `advanced === null` (the bracket
154
+ * genuinely ran and genuinely compared two same-route samples). */
155
+ function bracketEvidenceValue(bracket: StockLivenessBracketResult): Record<string, unknown> {
156
+ if (bracket.before.route === "cpu_history" && bracket.after.route === "cpu_history") {
157
+ // WR-13 (07-REVIEW.md): this value is written into a PERMANENT,
158
+ // repo-tracked incident record, and it used to be the narrowed
159
+ // `Number(...)` with no exact counterpart at all -- so a delta above
160
+ // Number.MAX_SAFE_INTEGER was silently rounded in the one artifact that
161
+ // outlives the session. `cyclesExact` carries the bigint's exact decimal
162
+ // string alongside it (incident-record.ts's renderer only prints
163
+ // `cycles`/`elapsedMs`, so the string rides in `cycles` itself when the
164
+ // narrowing is lossy -- the record must never present a rounded figure as
165
+ // if it were the measurement).
166
+ const exact = bracket.after.cycle - bracket.before.cycle;
167
+ const narrowed = Number(exact);
168
+ if (exact > BigInt(Number.MAX_SAFE_INTEGER)) {
169
+ return {
170
+ cycles: `${exact.toString()} (exact; exceeds Number.MAX_SAFE_INTEGER, so the narrowed JS number ${narrowed} would be rounded)`,
171
+ cyclesExact: exact.toString(),
172
+ elapsedMs: bracket.elapsedMs,
173
+ route: bracket.route,
174
+ };
175
+ }
176
+ return { cycles: narrowed, cyclesExact: exact.toString(), elapsedMs: bracket.elapsedMs, route: bracket.route };
177
+ }
178
+ if (bracket.before.route === "frame_position" && bracket.after.route === "frame_position") {
179
+ const delta = bracket.after.position - bracket.before.position;
180
+ return {
181
+ cycles: `${delta} (frame_position route -- a within-one-frame position delta, not a true cycle count)`,
182
+ elapsedMs: bracket.elapsedMs,
183
+ route: bracket.route,
184
+ };
185
+ }
186
+ // Unreachable in practice -- the caller only reaches here when
187
+ // `advanced !== null`, which runStockLivenessBracket() only produces when
188
+ // before/after share the same non-"unavailable" route. Kept as an
189
+ // explicit, honest fallback rather than a silent cast.
190
+ return { cycles: "unknown", elapsedMs: bracket.elapsedMs, route: bracket.route };
191
+ }
192
+
193
+ /** The bracket's own "cannot measure at all" reason, mirroring
194
+ * stock-diagnose.ts's inconclusiveBracketText() logic (not imported --
195
+ * that function renders a VERDICT explanation, this one renders an
196
+ * EVIDENCE-ITEM `reason`; duplicating the three-line branch is cheaper and
197
+ * clearer than threading a shared formatter across two different rendering
198
+ * contracts). */
199
+ function bracketUnavailableReason(bracket: StockLivenessBracketResult): string {
200
+ if (bracket.before.route === "unavailable") return bracket.before.reason;
201
+ if (bracket.after.route === "unavailable") return bracket.after.reason;
202
+ return `the bracket's route changed mid-measurement (before "${bracket.before.route}", after "${bracket.after.route}")`;
203
+ }
204
+
205
+ /** One `runStockLivenessBracket()` call (07-06, reused verbatim), wrapped in
206
+ * captureStep() so a transport failure or step deadline degrades to
207
+ * unavailable. A bracket that ran but could not measure an advance
208
+ * (`advanced === null`) is ALSO reported unavailable here -- never as a
209
+ * fabricated zero -- matching this plan's own "a wedged machine's honest
210
+ * bracket value is 0 only when the bracket genuinely ran and genuinely
211
+ * observed no advance" requirement. */
212
+ async function gatherBracketEvidence(session: StockConnectSession): Promise<EvidenceItem> {
213
+ const stepResult = await captureStep(() => runStockLivenessBracket(session));
214
+ if (!stepResult.available) {
215
+ return stepResult;
216
+ }
217
+ const bracket = stepResult.value;
218
+ if (bracket.advanced === null) {
219
+ return { available: false, reason: bracketUnavailableReason(bracket) };
220
+ }
221
+ return { available: true, value: bracketEvidenceValue(bracket) };
222
+ }
223
+
224
+ /** The full register map via handleRegistersGet()'s own answer -- the SAME
225
+ * handler vice_registers_get itself calls -- so `PC` is present exactly
226
+ * when the connected build enumerates it, satisfying
227
+ * formatEvidenceValue()'s `registers` branch (it reads `value.PC`
228
+ * directly). Wrapped in captureStep(); an `isError` answer becomes a thrown
229
+ * Error so captureStep() converts it into `{ available: false, reason }`
230
+ * rather than a silently empty map. */
231
+ async function gatherRegistersEvidence(session: StockConnectSession, deps: StockDispatchDeps): Promise<EvidenceItem> {
232
+ return captureStep(async () => {
233
+ const result = await handleRegistersGet({}, session, deps);
234
+ if (result.isError) {
235
+ throw new Error(result.content[0]?.text ?? "vice_registers_get failed with no message");
236
+ }
237
+ const parsed = JSON.parse(result.content[0]!.text) as { registers?: Record<string, number> };
238
+ return parsed.registers ?? {};
239
+ });
240
+ }
241
+
242
+ interface CheckpointEvidenceEntry {
243
+ checkpoint_num: unknown;
244
+ address: string;
245
+ enabled: boolean;
246
+ flag: "stop" | "continue";
247
+ }
248
+
249
+ /** The full checkpoint enumeration from gatherStockCheckpointTrapEvidence()
250
+ * (07-06, reused verbatim -- this is also where the PC read and the IRQ
251
+ * handler resolution this plan's own `irqHandler` item independently
252
+ * re-resolves come from; reusing the whole trap-evidence gatherer here,
253
+ * rather than calling vice_checkpoint_list directly, means this module
254
+ * never re-derives the checkpoint-trap enumeration's own field mapping),
255
+ * mapped onto the `{ checkpoint_num, address, enabled, flag }` shape
256
+ * formatEvidenceValue()'s `checkpoints` branch already renders. When the
257
+ * underlying vice_checkpoint_list call itself refused
258
+ * (`checkpointsUnavailable` set), that refusal is surfaced as THIS step's
259
+ * own unavailability -- an empty-but-available list would misreport a
260
+ * refusal as "no checkpoints armed". */
261
+ async function gatherCheckpointsEvidence(session: StockConnectSession, deps: StockDispatchDeps): Promise<EvidenceItem> {
262
+ return captureStep(async () => {
263
+ const trapEvidence = await gatherStockCheckpointTrapEvidence(session, deps);
264
+ if (trapEvidence.checkpointsUnavailable !== undefined) {
265
+ throw new Error(trapEvidence.checkpointsUnavailable);
266
+ }
267
+ return trapEvidence.checkpoints.map(
268
+ (c): CheckpointEvidenceEntry => ({
269
+ checkpoint_num: c.id,
270
+ address: formatAddress(c.start),
271
+ enabled: c.enabled !== false,
272
+ flag: c.stop === true ? "stop" : "continue",
273
+ }),
274
+ );
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Assembles the stock-native evidence set for an incident record: one
280
+ * liveness bracket, the full register snapshot (PC included), the full
281
+ * checkpoint enumeration, and the resolved live IRQ handler -- each
282
+ * gathered through captureStep() above, so no single step can abort the
283
+ * gather or stall the recycle it feeds. `screenshot` and `snapshot` are
284
+ * deliberately absent from the returned object (never `{ available: false
285
+ * }`): SHOT-* was cut from this milestone's scope, and stock has no
286
+ * vice_display_screenshot to attempt in the first place --
287
+ * renderIncidentRecord()'s own documented `undefined` skip is what makes
288
+ * that absence read as a decision rather than a gap.
289
+ */
290
+ export async function gatherStockWedgeEvidence(session: StockConnectSession, deps: StockDispatchDeps): Promise<IncidentEvidence> {
291
+ const bracket = await gatherBracketEvidence(session);
292
+ const registers = await gatherRegistersEvidence(session, deps);
293
+ const checkpoints = await gatherCheckpointsEvidence(session, deps);
294
+ const irqHandler = await captureStep(() => resolveStockLiveIrqHandler(session));
295
+
296
+ return { bracket, registers, checkpoints, irqHandler };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // handleRecycleStock() -- reason gate, record-before-RPC, teardown.
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /** Mirrors vice-proxy.ts's own recycleAckOutcomeMessage() wording for a
304
+ * broker ack whose kill stage was NOT a successful kill -- the SAME broker
305
+ * produces this ack shape regardless of which backend asked for the
306
+ * recycle (the control-plane RPC is already transport-independent), so the
307
+ * per-outcome vocabulary is the same one. Redeclared locally rather than
308
+ * imported: importing it would mean importing vice-proxy.ts, which this
309
+ * module must never do. */
310
+ function recycleAckOutcomeMessage(ack: { outcome: string; kill_stage: string; reason: string }): string {
311
+ const stage = ack.kill_stage || "unknown";
312
+ const reasonSuffix = ack.reason ? ` (${ack.reason})` : "";
313
+ switch (ack.outcome) {
314
+ case "identity_refused":
315
+ return (
316
+ "the host refused to signal the target -- its process identity did not match the binary recorded in its " +
317
+ `own epoch file (kill stage: ${stage}). The instance was NOT killed and is still running.`
318
+ );
319
+ case "target_lookup_failed":
320
+ return `the host could not resolve this session's own recycle target (kill stage: ${stage})${reasonSuffix}.`;
321
+ case "grant_lookup_failed":
322
+ return `the host found no grant record for this session's target (kill stage: ${stage})${reasonSuffix}.`;
323
+ case "epoch_lookup_failed":
324
+ return `the host could not read the target's epoch file (kill stage: ${stage})${reasonSuffix}.`;
325
+ case "pid_lookup_failed":
326
+ return `the target's own epoch file carries no pid to signal (kill stage: ${stage})${reasonSuffix}.`;
327
+ default:
328
+ return `the host reported outcome "${ack.outcome}" (kill stage: ${stage})${reasonSuffix}.`;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Handles vice_recycle on the stock backend, in load-bearing order:
334
+ *
335
+ * 1. The reason gate, FIRST, before anything else -- a missing,
336
+ * non-string or whitespace-only `reason` refuses before any lease
337
+ * consultation, any gather and any write.
338
+ * 2. Re-consult `deps.ensureLease()` for the `HeldLease` -- the
339
+ * destructive RPC needs `lease.brokerControl`/`lease.targetId`, which
340
+ * `session.brokerControl` (the narrowed claim/release-only interface)
341
+ * does not carry. A non-ok outcome returns its own message verbatim;
342
+ * `lease === null` (the VICE_MCP_URL override) refuses explicitly.
343
+ * 3. Evidence, then record, then RPC -- in that order and no other
344
+ * (D-17): gatherStockWedgeEvidence() cannot throw and cannot stall
345
+ * past its own per-step deadlines; writeIncidentRecord() completes
346
+ * before `lease.brokerControl.recycle()` is ever called, with no
347
+ * branch between them that can reach the RPC with the write skipped.
348
+ * 4. Each non-ok recycle outcome finalises the record with a distinct
349
+ * outcome and returns a well-formed refusal naming the record path and
350
+ * the instance's now-unknown state -- mirroring vice-proxy.ts's own
351
+ * three-way `broker_gone`/`deadline`/anything-else mapping, never
352
+ * inventing a fourth.
353
+ * 5. On a confirmed kill, finalise with the success outcome, build the
354
+ * answer via stockAnswer() (which stamps runState from the STILL-LIVE
355
+ * client), and only THEN tear the session down via stockDisconnect()
356
+ * -- CR-05's discipline: release the socket and the broker-side
357
+ * monitor claim together, so a leaked client never keeps occupying
358
+ * stock VICE's single client slot. Never imports
359
+ * clearHeldStockSession(): the held session's now-disconnected client
360
+ * makes the next ensureStockSession() reconnect, and the epoch has
361
+ * moved, so stockReconnect() raises the correct MachineRestartedError
362
+ * through the existing converter -- do not "fix" this by adding that
363
+ * forbidden import.
364
+ *
365
+ * Never throws: any unexpected error becomes a well-formed `isError: true`
366
+ * result naming whether a record was written and whether the request was
367
+ * sent.
368
+ */
369
+ // Declared as a `function` (hoisted at module INSTANTIATION time), not a
370
+ // `const` arrow expression -- REQUIRED, not stylistic, matching
371
+ // handleDiagnoseStock's identical fix in stock-diagnose.ts. This module
372
+ // already imports resolveStockLiveIrqHandler/gatherStockCheckpointTrapEvidence/
373
+ // runStockLivenessBracket (real, runtime) from stock-diagnose.ts, which
374
+ // itself imports ensureStockSession (real, runtime) from stock-dispatch.ts,
375
+ // which (this plan, 07-09) now imports handleRecycleStock back from THIS
376
+ // file -- a genuine multi-node runtime cycle. A `const` binding only
377
+ // initialises when module EVALUATION reaches its assignment statement; a
378
+ // `function` declaration initialises during module INSTANTIATION, before ANY
379
+ // module in the graph starts evaluating, so it survives being entered from
380
+ // any node in the cycle. Reproduced live: entering via stock-recycle.test.ts
381
+ // (-> this file -> stock-diagnose.ts -> stock-dispatch.ts -> back to this
382
+ // file for handleRecycleStock, and to stock-diagnose.ts for
383
+ // handleDiagnoseStock) crashed with "ReferenceError: Cannot access
384
+ // 'handleDiagnoseStock' before initialization" inside stock-dispatch.ts's own
385
+ // STOCK_DISPATCH_TABLE literal.
386
+ export async function handleRecycleStock(args: Record<string, unknown>, session: StockConnectSession, deps: StockDispatchDeps): Promise<StockToolResult> {
387
+ const rawReason = args && typeof args.reason === "string" ? args.reason : "";
388
+ const reason = rawReason.trim();
389
+ if (!reason) {
390
+ return isErrorText(
391
+ 'vice_recycle requires a non-empty "reason" string naming why this recycle is happening -- it ' +
392
+ "becomes the incident record's own explanation, written before anything is killed. No record " +
393
+ "and no request were written.",
394
+ );
395
+ }
396
+
397
+ let recordWritten = false;
398
+ let requestSent = false;
399
+ let recordPath: string | null = null;
400
+
401
+ try {
402
+ const leaseOutcome = await deps.ensureLease();
403
+ if (!leaseOutcome.ok) {
404
+ // Verbatim -- ensureBrokerLease()'s own broker-liveness diagnostic,
405
+ // never re-worded here. Nothing has been gathered or written.
406
+ return isErrorText(leaseOutcome.message);
407
+ }
408
+
409
+ const lease = leaseOutcome.lease;
410
+ if (lease === null) {
411
+ return isErrorText(
412
+ "vice_recycle: VICE_MCP_URL is set, so there is no broker control session to recycle through -- recycle " +
413
+ "only applies to a broker-managed instance. No record and no request were written.",
414
+ );
415
+ }
416
+
417
+ const at = new Date().toISOString();
418
+ const readEpochFn = session.deps.readEpochFn ?? readEpoch;
419
+ const epochResult = lease.epochFile ? readEpochFn(lease.epochFile) : null;
420
+ const epochBefore = epochResult && epochResult.present ? epochResult.epoch : null;
421
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null;
422
+
423
+ // Evidence, then record, then RPC -- in that order and no other (D-17).
424
+ // gatherStockWedgeEvidence() cannot throw and cannot stall past its own
425
+ // per-step deadlines, so this line always completes.
426
+ const evidence = await gatherStockWedgeEvidence(session, deps);
427
+
428
+ // The record is written BEFORE the request -- capturing is structurally
429
+ // impossible to skip, not a discipline to remember (the fork's own
430
+ // D-17 ordering comment, vice-proxy.ts's handleRecycle()).
431
+ recordPath = writeIncidentRecord({
432
+ at,
433
+ port: lease.port,
434
+ epoch_before: epochBefore,
435
+ reason,
436
+ session_id: sessionId,
437
+ evidence,
438
+ });
439
+ recordWritten = true;
440
+
441
+ const recycled = await lease.brokerControl.recycle(lease.targetId);
442
+ requestSent = true;
443
+
444
+ if (!recycled.ok) {
445
+ const outcome = recycled.kind === "broker_gone" ? "broker_gone" : recycled.kind === "deadline" ? "timeout" : "internal";
446
+ finaliseIncidentRecord(recordPath, { outcome });
447
+ if (recycled.kind === "broker_gone") {
448
+ return isErrorText(
449
+ `vice_recycle: the broker is no longer reachable (${recycled.message}). Incident record: ${recordPath}. ` +
450
+ "This recycle's own kill request may or may not have reached the broker before the connection dropped -- " +
451
+ "the instance's state is now unknown.",
452
+ );
453
+ }
454
+ if (recycled.kind === "deadline") {
455
+ return isErrorText(
456
+ `vice_recycle: no ack arrived from the host within the timeout (${recycled.message}). Incident record: ` +
457
+ `${recordPath}. The instance's state is now unknown -- treat it as neither confirmed killed nor confirmed alive.`,
458
+ );
459
+ }
460
+ return isErrorText(
461
+ `vice_recycle: the recycle request failed (${recycled.kind}: ${recycled.message}). Incident record: ${recordPath}. ` +
462
+ "The instance's state is now unknown -- treat it as neither confirmed killed nor confirmed alive.",
463
+ );
464
+ }
465
+
466
+ const ack = recycled.ack;
467
+ const killStage = ack.kill_stage;
468
+ const successfulKill = killStage === "already_exited" || killStage === "sigterm" || killStage === "sigkill";
469
+
470
+ if (!successfulKill) {
471
+ finaliseIncidentRecord(recordPath, { outcome: ack.outcome || "refused", kill_stage: killStage });
472
+ return isErrorText(`vice_recycle: ${recycleAckOutcomeMessage(ack)} Incident record: ${recordPath}.`);
473
+ }
474
+
475
+ finaliseIncidentRecord(recordPath, { outcome: "ok", kill_stage: killStage });
476
+
477
+ // stockAnswer() stamps runState from session.client -- read BEFORE the
478
+ // teardown below disconnects it, so the answer reports the machine's
479
+ // real last-known state rather than whatever a disconnected client
480
+ // would report.
481
+ const answer = stockAnswer(session.client, { recycled: true, recordPath, killStage });
482
+
483
+ await stockDisconnect(session);
484
+
485
+ return answer;
486
+ } catch (err) {
487
+ return isErrorText(
488
+ `vice_recycle: an unexpected error occurred (${describeError(err)}). Record written: ${recordWritten}` +
489
+ `${recordPath ? ` (${recordPath})` : ""}. Request sent: ${requestSent}.`,
490
+ );
491
+ }
492
+ }
493
+
494
+ // Compile-time-only check that the function declaration above still
495
+ // satisfies StockSessionHandler's shape -- the type annotation moved off the
496
+ // declaration itself (a `function` cannot carry a variable's type
497
+ // annotation the way a `const` could), so this is where that contract is
498
+ // still enforced. Erased entirely at runtime (a type-only reference).
499
+ const _handleRecycleStockShapeCheck: StockSessionHandler = handleRecycleStock;
500
+ void _handleRecycleStockShapeCheck;