@henols/vice-mcp 0.1.11 → 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.
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/disasm-decoder.ts +28 -4
- package/package.json +12 -1
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +431 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +49 -6
package/stock-recycle.ts
ADDED
|
@@ -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;
|