@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,994 @@
1
+ #!/usr/bin/env node
2
+ // stock-diagnose.ts
3
+ //
4
+ // THE stock-backend implementation of `vice_diagnose` (TIME-04) --
5
+ // vice-wedge-triage's documented opening move, today refused by name on the
6
+ // stock backend because dispatchStock()'s miss branch has no entry for it.
7
+ // This file ports the fork's already-live-tested checkpoint-trap algorithm
8
+ // (transport swapped, logic unchanged) and replaces its ping-poll-while-
9
+ // running cycle bracket -- which stock cannot do, since every inbound byte
10
+ // halts the machine (monitor_binary.c:281) -- with the snapshot-resume-
11
+ // wait-halt-compare bracket built on 07-05's readCycleBaseline() (Task 2,
12
+ // added on top of this task's own IRQ-handler/checkpoint-trap port).
13
+ //
14
+ // THIS TASK (1 of 3): resolveStockLiveIrqHandler() and
15
+ // gatherStockCheckpointTrapEvidence() -- the fork's resolveLiveIrqHandler()/
16
+ // gatherCheckpointTrapEvidence() ported onto stock's own MEM_GET/
17
+ // REGISTERS_GET/CHECKPOINT_LIST primitives. Logic unchanged; only the three
18
+ // call("vice_...", ...) invocations are replaced. Makes NO resume and NO
19
+ // stopwatch call -- checking this before any liveness bracket is the whole
20
+ // point (matching the fork's own D-14/T-01.3-08 ordering). Task 2 (a later
21
+ // commit in this same file) adds the liveness bracket and the five-verdict
22
+ // handleDiagnoseStock() built on top of these two exports.
23
+ //
24
+ // WHAT NOT TO DO:
25
+ // - Never import vice-proxy.ts, and never call rewriteArguments() or
26
+ // forwardToVice() -- port the fork's algorithm, do not reach for it
27
+ // (stock-derived.ts's own WHAT NOT TO DO list).
28
+ // - Never send CommandType.Exit (or any resume) from either function in
29
+ // this file -- a checkpoint-trap verdict must never be reached by way
30
+ // of a resume, or it stops being distinguishable from a verdict a
31
+ // liveness bracket actually had to run to establish.
32
+ // - Never hardcode HIRAM_MASK as an inline literal at the comparison site
33
+ // -- it is a named constant (below) for exactly this reason.
34
+ import {
35
+ CommandType,
36
+ memGetBody,
37
+ StockFramingError,
38
+ StockDesyncError,
39
+ StockResponseMismatchError,
40
+ StockConnectionClosedError,
41
+ StockRequestTimeoutError,
42
+ } from "./stock-protocol.ts";
43
+ import { handleCheckpointList } from "./stock-checkpoints.ts";
44
+ import { handleRegistersGet } from "./stock-registers.ts";
45
+ import { readCycleBaseline, type CycleBaseline } from "./stock-timing.ts";
46
+ import { stockAnswer, derivedAnswer, isErrorText, type StockToolResult } from "./stock-handler.ts";
47
+ import { ensureStockSession, type StockDispatchDeps, type EnsureStockSessionOutcome } from "./stock-dispatch.ts";
48
+ import type { DerivedPureHandler } from "./stock-derived.ts";
49
+ import type { StockConnectSession } from "./stock-connect.ts";
50
+ import { runStateFor, jamObservedFor } from "./stock-runstate.ts";
51
+ import { MachineRestartedError, readEpoch, type EpochResult } from "./vice.ts";
52
+ import { MonitorOwnershipError } from "./vice-broker-client.ts";
53
+
54
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
55
+ * an array. Matches this module tree's own isPlainObject() convention,
56
+ * redeclared privately here per the established per-module precedent. */
57
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function formatAddress(n: number | null | undefined): string {
62
+ return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(4, "0")}`;
63
+ }
64
+
65
+ function formatByte(n: number | null | undefined): string {
66
+ return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(2, "0")}`;
67
+ }
68
+
69
+ // Bit 1 (HIRAM) of the 6510 processor port at $01. SET -- the KERNAL ROM is
70
+ // banked in, and the RAM IRQ vector pair ($0314/$0315) is what the KERNAL's
71
+ // own dispatch actually reads. CLEAR -- the KERNAL is replaced by RAM and
72
+ // the CPU reads the hardware IRQ/BRK vector pair ($FFFE/$FFFF) directly,
73
+ // with no ROM indirection. Named constant, never an inline magic number at
74
+ // the comparison site (07-06-PLAN.md's own acceptance criterion).
75
+ const HIRAM_MASK = 0x02;
76
+
77
+ /** The live-IRQ-handler lookup's own return shape -- shared by
78
+ * gatherStockCheckpointTrapEvidence() below and, per this plan's own
79
+ * key_links, reused verbatim by plan 07-07's stock evidence gatherer. */
80
+ export interface StockIrqHandlerResolution {
81
+ target: number | null;
82
+ pairLabel: string;
83
+ explanation: string;
84
+ }
85
+
86
+ function wordFromBytes(bytes: Uint8Array): number | null {
87
+ return bytes.length >= 2 ? bytes[0]! | (bytes[1]! << 8) : null;
88
+ }
89
+
90
+ /**
91
+ * The single definition of the live-IRQ-handler lookup on the stock backend:
92
+ * three reads through session.client.send() directly -- $01, the RAM vector
93
+ * pair, and (only when $01 says the ROMs are banked out) the hardware vector
94
+ * pair. Leaves the bank argument at memGetBody()'s default (0x0000, the CPU
95
+ * view) -- Phase 5's CR-01 banking discipline applies to I/O-space registers
96
+ * ($D000-$DFFF); $01, $0314 and $FFFE are the processor port, RAM and ROM in
97
+ * the CPU's own view, which is exactly what the IRQ dispatch itself reads.
98
+ * Do NOT "fix" this into an io-bank read.
99
+ *
100
+ * Memoises NOTHING: a disk swap, a reset or a different game retargets the
101
+ * handler, so a cached address would silently resolve the wrong pair.
102
+ */
103
+ export async function resolveStockLiveIrqHandler(session: StockConnectSession): Promise<StockIrqHandlerResolution> {
104
+ const portResponse = await session.client.send(CommandType.MemoryGet, memGetBody({ sidefx: false, start: 0x01, end: 0x01, memspace: 0x00 }));
105
+ if (portResponse.type !== "memory_get") {
106
+ throw new Error(`resolveStockLiveIrqHandler: expected a memory_get reply for $01, got "${portResponse.type}"`);
107
+ }
108
+ const port01 = portResponse.bytes.length > 0 ? portResponse.bytes[0]! : null;
109
+ const bankedOut = port01 !== null && (port01 & HIRAM_MASK) === 0;
110
+
111
+ const ramResponse = await session.client.send(CommandType.MemoryGet, memGetBody({ sidefx: false, start: 0x0314, end: 0x0315, memspace: 0x00 }));
112
+ if (ramResponse.type !== "memory_get") {
113
+ throw new Error(`resolveStockLiveIrqHandler: expected a memory_get reply for $0314, got "${ramResponse.type}"`);
114
+ }
115
+ const ramTarget = wordFromBytes(ramResponse.bytes);
116
+
117
+ if (!bankedOut) {
118
+ return {
119
+ target: ramTarget,
120
+ pairLabel: "the RAM KERNAL IRQ vector pair ($0314/$0315)",
121
+ explanation:
122
+ `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked in, so the RAM IRQ vector pair ` +
123
+ `($0314/$0315) is the pair this session's IRQ dispatch actually reads; it resolves to ${formatAddress(ramTarget)}.`,
124
+ };
125
+ }
126
+
127
+ const hwResponse = await session.client.send(CommandType.MemoryGet, memGetBody({ sidefx: false, start: 0xfffe, end: 0xffff, memspace: 0x00 }));
128
+ if (hwResponse.type !== "memory_get") {
129
+ throw new Error(`resolveStockLiveIrqHandler: expected a memory_get reply for $FFFE, got "${hwResponse.type}"`);
130
+ }
131
+ const hwTarget = wordFromBytes(hwResponse.bytes);
132
+ return {
133
+ target: hwTarget,
134
+ pairLabel: "the hardware IRQ/BRK vector pair ($FFFE/$FFFF)",
135
+ explanation:
136
+ `$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked OUT, so the CPU dispatches directly through ` +
137
+ `the hardware IRQ/BRK vector pair ($FFFE/$FFFF) with no ROM indirection; it resolves to ${formatAddress(hwTarget)}.`,
138
+ };
139
+ }
140
+
141
+ /** A single vice_checkpoint_list entry, as handleCheckpointList's own JSON
142
+ * answer shapes it (stock-checkpoints.ts) -- typed loosely and read
143
+ * defensively, matching the fork's own CheckpointInfo precedent. Field names
144
+ * are stock's OWN spelling (`id`, `hitCount`, `operation.flags`), never the
145
+ * fork's `checkpoint_num`/`hit_count`. */
146
+ interface StockCheckpointEntry {
147
+ id?: unknown;
148
+ start?: unknown;
149
+ end?: unknown;
150
+ stop?: unknown;
151
+ enabled?: unknown;
152
+ operation?: { value?: unknown; flags?: unknown };
153
+ hitCount?: unknown;
154
+ [key: string]: unknown;
155
+ }
156
+
157
+ export interface StockCheckpointTrapEvidence {
158
+ isTrap: boolean;
159
+ checkpoints: StockCheckpointEntry[];
160
+ /** Set (and checkpoints left []) when vice_checkpoint_list itself refused
161
+ * -- the gather continues rather than aborting, per this plan's own
162
+ * "does not abort the gather" instruction. */
163
+ checkpointsUnavailable?: string;
164
+ pc: number | null;
165
+ handler: StockIrqHandlerResolution;
166
+ trapCheckpoint: StockCheckpointEntry | null;
167
+ trapReason: "pc" | "handler" | null;
168
+ }
169
+
170
+ /** Reads PC through the register catalog (handleRegistersGet, the same
171
+ * handler vice_registers_get itself calls) -- `null` on any refusal, never
172
+ * thrown, since a missing PC must not abort the trap-evidence gather. */
173
+ async function readStockPc(session: StockConnectSession, deps: StockDispatchDeps): Promise<number | null> {
174
+ const result = await handleRegistersGet({}, session, deps);
175
+ if (result.isError) {
176
+ return null;
177
+ }
178
+ const parsed = JSON.parse(result.content[0]!.text) as { registers?: unknown };
179
+ const registers = isPlainObject(parsed.registers) ? parsed.registers : undefined;
180
+ const pc = registers ? registers.PC : undefined;
181
+ return typeof pc === "number" ? pc : null;
182
+ }
183
+
184
+ function hasExecFlag(operation: StockCheckpointEntry["operation"]): boolean {
185
+ return isPlainObject(operation) && Array.isArray(operation.flags) && (operation.flags as unknown[]).includes("exec");
186
+ }
187
+
188
+ /**
189
+ * Enumerate armed checkpoints, read the current PC, resolve the live IRQ
190
+ * handler, and decide the checkpoint-trap verdict on the same two named
191
+ * shapes the fork's gatherCheckpointTrapEvidence() uses: an enabled,
192
+ * stopping, exec checkpoint sitting exactly at the current PC; or one
193
+ * sitting at the resolved handler entry with a hit count of exactly zero
194
+ * (the corroborating tell that it has never actually fired). Makes NO
195
+ * resume and NO stopwatch call -- checking this before any liveness bracket
196
+ * is the whole point (matching the fork's own D-14/T-01.3-08 ordering).
197
+ *
198
+ * Exported: plan 07-07's stock evidence gatherer reuses this verbatim.
199
+ */
200
+ export async function gatherStockCheckpointTrapEvidence(
201
+ session: StockConnectSession,
202
+ deps: StockDispatchDeps,
203
+ ): Promise<StockCheckpointTrapEvidence> {
204
+ let checkpoints: StockCheckpointEntry[] = [];
205
+ let checkpointsUnavailable: string | undefined;
206
+
207
+ const checkpointsResult = await handleCheckpointList({}, session, deps);
208
+ if (checkpointsResult.isError) {
209
+ checkpointsUnavailable = checkpointsResult.content[0]?.text ?? "vice_checkpoint_list failed with no message";
210
+ } else {
211
+ const parsed = JSON.parse(checkpointsResult.content[0]!.text) as { checkpoints?: unknown };
212
+ checkpoints = Array.isArray(parsed.checkpoints) ? (parsed.checkpoints as StockCheckpointEntry[]) : [];
213
+ }
214
+
215
+ const pc = await readStockPc(session, deps);
216
+ const handler = await resolveStockLiveIrqHandler(session);
217
+
218
+ const armedStopping = checkpoints.filter((c) => c.enabled !== false && c.stop === true && hasExecFlag(c.operation));
219
+
220
+ const atPc = pc !== null ? armedStopping.find((c) => c.start === pc) : undefined;
221
+ const atHandler =
222
+ !atPc && handler.target !== null && handler.target !== undefined
223
+ ? armedStopping.find((c) => c.start === handler.target && c.hitCount === 0)
224
+ : undefined;
225
+
226
+ const trapCheckpoint = atPc ?? atHandler ?? null;
227
+ return {
228
+ isTrap: Boolean(trapCheckpoint),
229
+ checkpoints,
230
+ ...(checkpointsUnavailable !== undefined ? { checkpointsUnavailable } : {}),
231
+ pc,
232
+ handler,
233
+ trapCheckpoint,
234
+ trapReason: atPc ? "pc" : atHandler ? "handler" : null,
235
+ };
236
+ }
237
+
238
+ const CHECKPOINT_TRAP_INCIDENT_REF =
239
+ ".planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md";
240
+
241
+ /** Renders the checkpoint_trap verdict's report -- an explanation, never a
242
+ * remedy: names the armed checkpoints, the resolved handler, the PC's
243
+ * relation to the trap, states plainly this is self-inflicted and not a
244
+ * wedge, names the agent's own next moves without performing any of them,
245
+ * and closes with the not-guaranteed paragraph (matching the fork's own
246
+ * renderCheckpointTrapReport()). Consumed by Task 2's handleDiagnoseStock. */
247
+ export function renderStockCheckpointTrapReport(evidence: StockCheckpointTrapEvidence): string {
248
+ const { checkpoints, checkpointsUnavailable, pc, handler, trapCheckpoint, trapReason } = evidence;
249
+ const checkpointList = checkpointsUnavailable
250
+ ? `checkpoints could not be enumerated (${checkpointsUnavailable})`
251
+ : checkpoints.length === 0
252
+ ? "none armed"
253
+ : checkpoints
254
+ .map((c) => {
255
+ const addr = formatAddress(typeof c.start === "number" ? c.start : null);
256
+ const flag = c.stop ? "stop" : "trace";
257
+ const enabled = c.enabled === false ? "disabled" : "enabled";
258
+ const hitCount = typeof c.hitCount === "number" ? c.hitCount : "unknown";
259
+ return `#${String(c.id)} ${addr} (${flag}, ${enabled}, hitCount ${hitCount})`;
260
+ })
261
+ .join("; ");
262
+
263
+ const pcRelation =
264
+ trapReason === "pc"
265
+ ? `exactly at armed checkpoint #${String(trapCheckpoint!.id)} -- that is why the machine is stopped here`
266
+ : trapReason === "handler"
267
+ ? `not at the armed checkpoint's own address, but checkpoint #${String(trapCheckpoint!.id)} sits at the ` +
268
+ "resolved live IRQ handler entry with hitCount 0 -- the corroborating tell that this checkpoint has " +
269
+ "never actually fired, not merely that it fired between reads"
270
+ : "no relation established";
271
+
272
+ return [
273
+ "vice_diagnose verdict: checkpoint_trap",
274
+ "",
275
+ `Armed checkpoints: ${checkpointList}.`,
276
+ `Resolved live IRQ handler: ${handler.explanation}`,
277
+ `Current PC: ${formatAddress(pc)} -- ${pcRelation}.`,
278
+ "",
279
+ "This is a self-inflicted stop, not a wedge: the machine paused because an armed checkpoint fired or sits " +
280
+ "exactly here, not because it stopped retiring cycles on its own. Recycling now would destroy a healthy " +
281
+ "instance -- no liveness bracket was run to reach this verdict.",
282
+ "",
283
+ "Next moves available to you (this report performs none of them): vice_checkpoint_delete the offending " +
284
+ "checkpoint, or vice_checkpoint_toggle it disabled; vice_execution_step past it; then re-run vice_diagnose.",
285
+ "",
286
+ "Not guaranteed: deleting the checkpoint is not guaranteed to unfreeze the machine. The recorded incident " +
287
+ `(${CHECKPOINT_TRAP_INCIDENT_REF}) shows checkpoint delete, then a soft reset, then a hard reset, then an ` +
288
+ "explicit single step ALL leaving the machine frozen in sequence -- a checkpoint trap may be the onset " +
289
+ "without being the whole story. If a liveness bracket still shows no advance after the checkpoint is gone, " +
290
+ "the verdict becomes wedged and recycle is the fallback after all.",
291
+ ].join("\n");
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Task 2 (this commit): runStockLivenessBracket() and the five-verdict
296
+ // handleDiagnoseStock().
297
+ //
298
+ // WHY THE HANDLER OWNS ITS OWN SESSION ACQUISITION: this tool is registered
299
+ // (07-09) with withDerivedTool("vice_diagnose", { needsSession: false }, ...)
300
+ // yet calls ensureStockSession(deps) itself, inside its own try/catch --
301
+ // the ONE declared exception to DerivedPureHandler's doc comment in
302
+ // stock-derived.ts ("a needsSession:false handler structurally cannot reach
303
+ // the wire"). The reason: the fork's handleDiagnose() never throws past its
304
+ // own boundary and always answers isError:false with a verdict, but
305
+ // withDerivedTool({ needsSession: true })'s own preamble would convert a
306
+ // thrown MonitorOwnershipError into refusal TEXT before this handler's own
307
+ // return value ever existed -- turning the fifth verdict into exactly the
308
+ // generic error string it exists to replace. Amending stock-derived.ts's
309
+ // doc comment for this exception is plan 07-09's job (the registration
310
+ // plan), not this one's.
311
+ //
312
+ // Because this handler owns its own acquisition, it also owns constructing
313
+ // its own answer: when a session was actually obtained, every answer goes
314
+ // through stockAnswer(session.client, payload) (D-06 -- a real client
315
+ // exists, so a real runState is knowable). When acquisition itself failed
316
+ // or timed out -- monitor_held_elsewhere, restarted from a thrown
317
+ // MachineRestartedError, or the bounded-acquisition timeout -- there IS no
318
+ // client (stockConnect() rejected before or while building one), so those
319
+ // paths go through derivedAnswer() instead, whose honest "unknown" runState
320
+ // is exactly right: this handler genuinely never observed the machine.
321
+ //
322
+ // WHAT NOT TO DO (Task 2's own additions to this file's list):
323
+ // - Never invent a sixth verdict, and never report one of the five
324
+ // STOCK_DIAGNOSE_VERDICTS the evidence did not actually establish. The
325
+ // bounded-acquisition timeout and the bracket's "unavailable" route both
326
+ // answer isError:true refusal text naming what could not be
327
+ // established, never a verdict field.
328
+ // - Never resume the machine (send CommandType.Exit) anywhere except
329
+ // inside runStockLivenessBracket().
330
+ // - Never cache DIAGNOSE_SESSION_TIMEOUT_MS/DIAGNOSE_BRACKET_WINDOW_MS as a
331
+ // module-level constant the way vice-proxy.ts's CAPTURE_STEP_TIMEOUT_MS
332
+ // is -- this file's own test drives many distinct timeout/window values
333
+ // within ONE process, and a value frozen at import time could never be
334
+ // overridden per test without a dynamic re-import for every case. Read
335
+ // the environment variable fresh on every call instead (below).
336
+ // - Never let a raw bigint (CycleBaseline's cpu_history `cycle` field)
337
+ // reach JSON.stringify() -- stockAnswer()/derivedAnswer() both
338
+ // serialize the payload, and JSON.stringify() throws on a bigint.
339
+ // serializeCycleBaseline()/serializeBracket() below are the ONE place a
340
+ // CycleBaseline is converted to a JSON-safe shape.
341
+ // ---------------------------------------------------------------------------
342
+
343
+ function describeStockError(err: unknown): string {
344
+ return err instanceof Error ? err.message : String(err);
345
+ }
346
+
347
+ const DEFAULT_DIAGNOSE_SESSION_TIMEOUT_MS = 10000;
348
+ const DEFAULT_DIAGNOSE_BRACKET_WINDOW_MS = 250;
349
+
350
+ /**
351
+ * WR-15 (07-REVIEW.md): the bound must be `> 0`, not `>= 0`.
352
+ *
353
+ * With `VICE_STOCK_DIAGNOSE_SESSION_TIMEOUT_MS=0` the `Promise.race` in
354
+ * handleDiagnoseStock() always resolves the timeout branch, and 07-15 changed
355
+ * what the caller is then told: it now receives
356
+ * `diagnosis_unavailable (monitor_acquisition_timeout)` with the guidance
357
+ * "this is behaviourally indistinguishable from a second client already
358
+ * holding the monitor socket ... retry once the current holder releases". A
359
+ * misconfiguration was thereby reported as a specific, plausible, actionable
360
+ * diagnosis of the emulator's ENVIRONMENT -- strictly worse than the
361
+ * unclassified refusal it replaced, because the caller now has a wrong theory
362
+ * instead of no theory.
363
+ *
364
+ * A rejected value is logged with the default being used instead, once per
365
+ * read: a silent fallback is how a `0` in a shell profile stays invisible for
366
+ * a whole session. Never widen this back to `>= 0` "for tests" -- a test that
367
+ * needs an instant timeout can pass 1.
368
+ */
369
+ function envMs(envVar: string, fallback: number): number {
370
+ const raw = process.env[envVar];
371
+ if (raw === undefined || raw === "") {
372
+ return fallback;
373
+ }
374
+ const parsed = Number(raw);
375
+ if (Number.isFinite(parsed) && parsed > 0) {
376
+ return parsed;
377
+ }
378
+ console.error(
379
+ `${envVar}=${JSON.stringify(raw)} is not a positive number of milliseconds -- ignoring it and using the default ${fallback}ms. ` +
380
+ `A value of 0 would make every bounded wait expire instantly, which vice_diagnose would then report as a real ` +
381
+ `monitor_acquisition_timeout diagnosis of the emulator rather than as this misconfiguration.`,
382
+ );
383
+ return fallback;
384
+ }
385
+
386
+ /** Read fresh on EVERY call -- deliberately not a module-level constant the
387
+ * way vice-proxy.ts's CAPTURE_STEP_TIMEOUT_MS is (see this file's own
388
+ * WHAT NOT TO DO). Defaults to 10000ms; overridable via
389
+ * VICE_STOCK_DIAGNOSE_SESSION_TIMEOUT_MS. */
390
+ export function diagnoseSessionTimeoutMs(): number {
391
+ return envMs("VICE_STOCK_DIAGNOSE_SESSION_TIMEOUT_MS", DEFAULT_DIAGNOSE_SESSION_TIMEOUT_MS);
392
+ }
393
+
394
+ /** Read fresh on EVERY call, same rationale as diagnoseSessionTimeoutMs()
395
+ * above. Defaults to 250ms; overridable via VICE_STOCK_DIAGNOSE_BRACKET_MS. */
396
+ export function diagnoseBracketWindowMs(): number {
397
+ return envMs("VICE_STOCK_DIAGNOSE_BRACKET_MS", DEFAULT_DIAGNOSE_BRACKET_WINDOW_MS);
398
+ }
399
+
400
+ /** The verdict set is EXACTLY the five of D-03.
401
+ * `stale_read_path` is deliberately absent: it exists on the fork only
402
+ * because the fork mixes a non-pausing vice_ping with pausing reads, and on
403
+ * stock every read pauses uniformly, so that state is unreachable by
404
+ * construction. Frozen so the manifest enum (plan 07-09) and this file's
405
+ * own tests both drive one list. */
406
+ export const STOCK_DIAGNOSE_VERDICTS = Object.freeze([
407
+ "restarted",
408
+ "checkpoint_trap",
409
+ "wedged",
410
+ "monitor_held_elsewhere",
411
+ "live",
412
+ ] as const);
413
+
414
+ export type StockDiagnoseVerdict = (typeof STOCK_DIAGNOSE_VERDICTS)[number];
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // diagnosis_unavailable -- the named, classified non-verdict outcome (Gap 3 /
418
+ // Gap 4 / CR-01). D-03 locks STOCK_DIAGNOSE_VERDICTS at exactly the five
419
+ // above; this is NOT a sixth verdict -- it is a named outcome on the existing
420
+ // `isError: true` channel, the only shape the manifest's
421
+ // `required: ["verdict", ...]` output schema permits when no verdict could be
422
+ // established. `diagnosis_unavailable` must never be added to
423
+ // STOCK_DIAGNOSE_VERDICTS above.
424
+ // ---------------------------------------------------------------------------
425
+
426
+ /** Frozen outcome name, exported so tests and documentation (07-16, 07-18)
427
+ * can name it without re-deriving the literal string. */
428
+ export const STOCK_DIAGNOSE_UNAVAILABLE_OUTCOME = "diagnosis_unavailable" as const;
429
+
430
+ /** Frozen reason-class list -- every classification `classifyDiagnoseUnavailable()`
431
+ * and the route table below can produce. */
432
+ export const STOCK_DIAGNOSE_UNAVAILABLE_REASONS = Object.freeze([
433
+ "protocol_decode_failure",
434
+ "connection_lost",
435
+ "request_timeout",
436
+ "monitor_acquisition_timeout",
437
+ "session_refused",
438
+ "evidence_gathering_failed",
439
+ // 07-REVIEW.md WR-02: the inconclusive-bracket path. NOT an error and NOT
440
+ // a wedge -- the bracket could not be MEASURED at all, which is the
441
+ // documented stock-3.9-class outcome (no CPUHISTORY_GET, no LIN/CYC
442
+ // enumerated), i.e. exactly the population this backend exists for. Before
443
+ // this reason class existed that path answered isError:true with no
444
+ // `diagnosis_unavailable (<reason>)` prefix, so an agent following the
445
+ // SKILL's own instruction (match the prefix, read the reason class, act on
446
+ // the table) fell off the contract on the most likely outcome.
447
+ "liveness_unmeasurable",
448
+ "unknown",
449
+ ] as const);
450
+
451
+ export type StockDiagnoseUnavailableReason = (typeof STOCK_DIAGNOSE_UNAVAILABLE_REASONS)[number];
452
+
453
+ /**
454
+ * Maps an error's class to a reason class. `MonitorOwnershipError` and
455
+ * `MachineRestartedError` must NEVER reach this classifier -- they carry real
456
+ * verdicts (`monitor_held_elsewhere`/`restarted`) and are handled by their own
457
+ * branches in handleDiagnoseStock() before this function is ever called.
458
+ */
459
+ export function classifyDiagnoseUnavailable(err: unknown): StockDiagnoseUnavailableReason {
460
+ if (err instanceof StockFramingError || err instanceof StockDesyncError || err instanceof StockResponseMismatchError) {
461
+ return "protocol_decode_failure";
462
+ }
463
+ if (err instanceof StockConnectionClosedError) {
464
+ return "connection_lost";
465
+ }
466
+ if (err instanceof StockRequestTimeoutError) {
467
+ return "request_timeout";
468
+ }
469
+ return "unknown";
470
+ }
471
+
472
+ /** Per-reason-class guidance text: the concrete next step a triage agent
473
+ * should take, in the exact terms vice-wedge-triage/SKILL.md's verdict table
474
+ * already uses. */
475
+ function diagnoseUnavailableGuidance(reason: StockDiagnoseUnavailableReason, detail: string): string {
476
+ switch (reason) {
477
+ case "protocol_decode_failure":
478
+ return (
479
+ "the connected build answered a frame this client could not decode " +
480
+ `(${detail}) -- see docs/stock-vice-parity.md for known decode gaps.`
481
+ );
482
+ case "connection_lost":
483
+ case "request_timeout":
484
+ return "retry once, and if the same failure recurs, check the broker (a crashed or recycled instance can look like this).";
485
+ case "monitor_acquisition_timeout":
486
+ return (
487
+ "this is behaviourally indistinguishable from a second client already holding the monitor socket (stock " +
488
+ "VICE services exactly one binary-monitor client) -- if a second client is not the cause, retry once the " +
489
+ "current holder releases."
490
+ );
491
+ case "session_refused":
492
+ return `the upstream session refused with: ${detail}`;
493
+ case "evidence_gathering_failed":
494
+ return "a read failed mid-diagnosis -- the machine is very likely halted, so vice_execution_run may be needed.";
495
+ case "liveness_unmeasurable":
496
+ return (
497
+ "liveness could not be MEASURED on this build -- this is not a wedge and not an error: a bracket that cannot " +
498
+ "measure at all must never be mistaken for one that measured zero advance. Route A needs CPUHISTORY_GET " +
499
+ "(VICE >= 3.10); Route B needs a build enumerating LIN/CYC by name. On a build with neither, use the fork " +
500
+ "backend for liveness, or judge it from outside the monitor (a screenshot, or the process itself)."
501
+ );
502
+ case "unknown":
503
+ default:
504
+ return `an unclassified failure occurred (${detail}).`;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Builds the diagnosis_unavailable outcome text -- structured and greppable,
510
+ * opening with a stable, machine-parseable prefix. States, in order: (1) no
511
+ * verdict was established and this is deliberately not one of the five
512
+ * documented verdicts; (2) the machine's state is therefore unknown -- never
513
+ * `live`, never a wedge; (3) recycling on this answer alone is wrong,
514
+ * `vice_recycle` is destructive and `wedged` was not established; (4) the
515
+ * concrete next step for this reason class; (5) the raw detail string last,
516
+ * for stable machine-readable prefix parsing.
517
+ */
518
+ export function diagnoseUnavailableResult(reason: StockDiagnoseUnavailableReason, detail: string): StockToolResult {
519
+ const text = [
520
+ `vice_diagnose: ${STOCK_DIAGNOSE_UNAVAILABLE_OUTCOME} (${reason}) -- no verdict could be established. This is ` +
521
+ `deliberately NOT one of the five documented verdicts (${STOCK_DIAGNOSE_VERDICTS.join(", ")}).`,
522
+ "The emulated machine's state is therefore UNKNOWN -- do not read this as live and do not treat it as a wedge.",
523
+ "Recycling on this answer alone is wrong: vice_recycle is destructive and wedged was not established.",
524
+ diagnoseUnavailableGuidance(reason, detail),
525
+ detail,
526
+ ].join(" ");
527
+ return isErrorText(text);
528
+ }
529
+
530
+ export interface StockLivenessBracketResult {
531
+ route: CycleBaseline["route"];
532
+ before: CycleBaseline;
533
+ after: CycleBaseline;
534
+ /** null when either sample's route is "unavailable" (or the route changed
535
+ * mid-bracket, e.g. a reconnect) -- the verdict path must then report the
536
+ * bracket as inconclusive rather than as wedged (never fabricate a false
537
+ * "no advance"). */
538
+ advanced: boolean | null;
539
+ elapsedMs: number;
540
+ }
541
+
542
+ /**
543
+ * Pattern 5 (07-RESEARCH.md), exported for plan 07-07. Exactly: one
544
+ * readCycleBaseline() (a halting read), one CommandType.Exit (resume), one
545
+ * await on a real wall-clock setTimeout of diagnoseBracketWindowMs() with
546
+ * ZERO socket traffic inside the wait, then one more readCycleBaseline()
547
+ * (a halting read which both re-pauses and samples).
548
+ *
549
+ * This deliberately uses wall-clock time, unlike vice-sync.ts's standing
550
+ * "poll on hit_count, never on paused state" rule -- that rule's rationale
551
+ * depended on a non-pausing vice_ping, and stock has no non-pausing
552
+ * observation of any kind (monitor_binary.c:281 halts on any inbound byte),
553
+ * so there is nothing to poll without itself perturbing the machine.
554
+ */
555
+ export async function runStockLivenessBracket(session: StockConnectSession): Promise<StockLivenessBracketResult> {
556
+ const before = await readCycleBaseline(session);
557
+ const startedAt = Date.now();
558
+ await session.client.send(CommandType.Exit);
559
+ const windowMs = diagnoseBracketWindowMs();
560
+ await new Promise<void>((resolve) => {
561
+ setTimeout(resolve, windowMs);
562
+ });
563
+ const after = await readCycleBaseline(session);
564
+ const elapsedMs = Date.now() - startedAt;
565
+
566
+ let advanced: boolean | null;
567
+ if (before.route === "unavailable" || after.route === "unavailable" || before.route !== after.route) {
568
+ advanced = null;
569
+ } else if (before.route === "cpu_history" && after.route === "cpu_history") {
570
+ advanced = after.cycle > before.cycle;
571
+ } else if (before.route === "frame_position" && after.route === "frame_position") {
572
+ advanced = after.position !== before.position || after.pc !== before.pc;
573
+ } else {
574
+ advanced = null;
575
+ }
576
+
577
+ return { route: after.route, before, after, advanced, elapsedMs };
578
+ }
579
+
580
+ /** Converts a CycleBaseline to a JSON-safe shape -- the ONE place a raw
581
+ * bigint (`cycle`) is turned into a string before it can reach
582
+ * JSON.stringify() inside stockAnswer()/derivedAnswer(). */
583
+ function serializeCycleBaseline(baseline: CycleBaseline): Record<string, unknown> {
584
+ if (baseline.route === "cpu_history") {
585
+ return { route: baseline.route, cycle: baseline.cycle.toString(), pc: baseline.pc };
586
+ }
587
+ if (baseline.route === "frame_position") {
588
+ return {
589
+ route: baseline.route,
590
+ lin: baseline.lin,
591
+ cyc: baseline.cyc,
592
+ pc: baseline.pc,
593
+ position: baseline.position,
594
+ standard: baseline.standard.name,
595
+ standardAssumed: baseline.standard.assumed,
596
+ };
597
+ }
598
+ return { route: baseline.route, reason: baseline.reason };
599
+ }
600
+
601
+ function serializeBracket(bracket: StockLivenessBracketResult): Record<string, unknown> {
602
+ return {
603
+ route: bracket.route,
604
+ before: serializeCycleBaseline(bracket.before),
605
+ after: serializeCycleBaseline(bracket.after),
606
+ advanced: bracket.advanced,
607
+ elapsedMs: bracket.elapsedMs,
608
+ };
609
+ }
610
+
611
+ function renderStockRestartedReport(baselineEpoch: number | null | undefined, currentEpoch: number | null | undefined): string {
612
+ return (
613
+ "vice_diagnose verdict: restarted\n\n" +
614
+ `The instance's epoch changed from ${baselineEpoch ?? "unknown"} to ${currentEpoch ?? "unknown"} -- the emulator ` +
615
+ "behind this session restarted (or its identity across a reconnect could not be proven at all, which is " +
616
+ "treated the same way, per D-3's own posture). This is answered at zero-to-minimal emulator cost; no " +
617
+ "checkpoint enumeration and no liveness bracket were attempted. Any run in flight before this point is void."
618
+ );
619
+ }
620
+
621
+ function renderMonitorHeldElsewhereReport(err: MonitorOwnershipError): string {
622
+ return (
623
+ "vice_diagnose verdict: monitor_held_elsewhere\n\n" +
624
+ `This instance's monitor socket is already claimed by a different grant (grant ${err.holderGrantId ?? "unknown"}, ` +
625
+ `claimed at ${err.holderClaimedAt ?? "unknown"}, port ${err.port ?? "unknown"}). Stock VICE services exactly ` +
626
+ "one binary-monitor client, so this session could not open its own connection at all -- at zero emulator " +
627
+ "calls. This is not a wedge and recycling would not help: the instance is healthy, just claimed elsewhere."
628
+ );
629
+ }
630
+
631
+ function renderStockLiveReport(bracket: StockLivenessBracketResult): string {
632
+ return (
633
+ "vice_diagnose verdict: live\n\n" +
634
+ `The liveness bracket measured an advance on route "${bracket.route}" in ~${bracket.elapsedMs}ms. Load-bearing ` +
635
+ "evidence: the bracket's own before/after sample -- one resume call, one wall-clock wait with zero socket " +
636
+ "traffic, one halting read. Machine state left: paused, after the bracket that reached this verdict -- " +
637
+ "resuming is your own deliberate next call."
638
+ );
639
+ }
640
+
641
+ function renderStockWedgedReport(bracket1: StockLivenessBracketResult, bracket2: StockLivenessBracketResult): string {
642
+ return (
643
+ "vice_diagnose verdict: wedged\n\n" +
644
+ `Two consecutive liveness brackets showed no advance (bracket 1: route "${bracket1.route}", ~${bracket1.elapsedMs}ms; ` +
645
+ `bracket 2: route "${bracket2.route}", ~${bracket2.elapsedMs}ms). This is the definitive liveness test on ` +
646
+ "stock -- every read pauses the machine uniformly, so there is no separate stale-read-path state to " +
647
+ "distinguish here (unlike the fork). Machine state left: paused, after two zero-advance brackets. Capture " +
648
+ "evidence before recovering, then vice_recycle with a real reason as a last resort."
649
+ );
650
+ }
651
+
652
+ /** The `detail` half of the `liveness_unmeasurable` refusal (07-REVIEW.md
653
+ * WR-02). Deliberately carries NO "vice_diagnose:" prefix of its own --
654
+ * diagnoseUnavailableResult() owns the documented, machine-parseable prefix,
655
+ * and a second one embedded here would make the answer unparseable by the
656
+ * rule the manifest and the SKILL both publish. */
657
+ function inconclusiveBracketText(bracket: StockLivenessBracketResult): string {
658
+ const reason =
659
+ bracket.before.route === "unavailable"
660
+ ? bracket.before.reason
661
+ : bracket.after.route === "unavailable"
662
+ ? bracket.after.reason
663
+ : `the bracket's route changed mid-measurement (before "${bracket.before.route}", after "${bracket.after.route}")`;
664
+ return (
665
+ `the liveness bracket could not be measured (${reason}). This is reported as inconclusive, ` +
666
+ "never as wedged -- a bracket that cannot measure at all must not be mistaken for one that measured zero " +
667
+ "advance. Establishing liveness needs CPUHISTORY_GET (VICE >= 3.10) or a build enumerating LIN/CYC by name."
668
+ );
669
+ }
670
+
671
+ /** The source label carried alongside `machinePaused`, so a caller can tell
672
+ * an observation from an inference:
673
+ * - "no_session" -- no session was ever obtained (the two pre-session
674
+ * verdicts, `monitor_held_elsewhere` and the thrown-`MachineRestartedError`
675
+ * acquisition path). Nothing in this process touched the machine, so no
676
+ * claim about a pause is being made; `machinePaused` is `false`.
677
+ * - "observed" -- `runStateFor(session.client)` reported `"stopped"`
678
+ * directly from the wire's own stopped/jam events, agreeing with what
679
+ * this path's own halting reads must have caused.
680
+ * - "structural" -- the tracker reported `"unknown"`, OR it reported
681
+ * `"running"` and thereby CONTRADICTED this path's own reads (see
682
+ * deriveMachinePaused()'s comment on the reproduced stale-tracker race).
683
+ * Either way the value is an inference, never a direct observation. */
684
+ export type MachinePausedSource = "no_session" | "observed" | "structural";
685
+
686
+ /**
687
+ * WR-03: `machinePaused` is derived HERE, from the observed run state, and
688
+ * NEVER hand-passed by a call site again -- a hand-passed flag drifts from
689
+ * reality the moment a call site changes (exactly what happened before this
690
+ * fix: the `checkpoint_trap` verdict hardcoded `false` even though every
691
+ * evidence-gathering read that got it there halts the machine on stock).
692
+ *
693
+ * `session === null` -> `false`/"no_session": no session was ever obtained,
694
+ * so no claim about a pause is being made.
695
+ *
696
+ * `session !== null` -> read `runStateFor(session.client)`:
697
+ * - "stopped" -> `true`/"observed". The wire said stopped and this path's
698
+ * own halting reads say the same; the two agree, so it is reported as an
699
+ * observation.
700
+ * - "running" -> `true`/"structural". 07-REVIEW.md WR-03: this branch used
701
+ * to answer `false`/"observed", which labelled as a direct wire
702
+ * observation the one projection this phase itself proved can be stale.
703
+ * Commit c5ac707 ("absorb a real stale-tracker race") records the
704
+ * empirical failure in the OTHER direction -- the tracker read "stopped"
705
+ * at t+0/1ms while the real machine had already resumed -- and
706
+ * resumeUntilCheckpointHits() exists solely because a "stopped" read
707
+ * could not be trusted. The symmetric error is a stale "running" after a
708
+ * halting read, and by construction EVERY path that reaches a verdict has
709
+ * already sent at least one halting read (D-05; every inbound byte halts
710
+ * the machine on stock, `monitor_binary.c:281`). So a "running" reading
711
+ * here is a CONTRADICTION of this path's own reads, not an observation of
712
+ * the machine. Report the structural conclusion (paused) and label it
713
+ * "structural" so the caller knows nothing was directly observed --
714
+ * never hand back `false`/"observed", which invites the caller to skip a
715
+ * resume it actually needs.
716
+ * - "unknown" -> `true`/"structural". By the time ANY verdict is built,
717
+ * this file's own path has already sent at least one wire read, every
718
+ * inbound byte halts the machine on stock (`monitor_binary.c:281`,
719
+ * CLAUDE.md's Protocol constraint), and no function in this file ever
720
+ * sends a resume except runStockLivenessBracket(), which itself ends
721
+ * with a read. So "not observed running" after this path's reads means
722
+ * paused -- but the tracker's own event-driven update can still lag a
723
+ * command reply, so this is an inference, never an observation, and the
724
+ * "structural" label is what keeps it honest.
725
+ */
726
+ function deriveMachinePaused(session: StockConnectSession | null): { machinePaused: boolean; machinePausedSource: MachinePausedSource } {
727
+ if (session === null) {
728
+ return { machinePaused: false, machinePausedSource: "no_session" };
729
+ }
730
+ const runState = runStateFor(session.client);
731
+ if (runState === "stopped") {
732
+ return { machinePaused: true, machinePausedSource: "observed" };
733
+ }
734
+ // WR-03: "running" and "unknown" collapse to the same answer on purpose --
735
+ // neither is an observation of a machine this path has already halted. Do
736
+ // not split "running" back out into `false`/"observed".
737
+ return { machinePaused: true, machinePausedSource: "structural" };
738
+ }
739
+
740
+ /** WR-04: the sentence appended to EVERY verdict's report when a JAM was
741
+ * observed. Wording is deliberately imperative about the recovery, because
742
+ * both verdicts a jam can reach carry the wrong default action: `wedged`
743
+ * points at vice_recycle (destroys a healthy-but-jammed instance, which a
744
+ * reset would have fixed) and `live` points at "carry on" (for a CPU that
745
+ * will never execute another instruction). */
746
+ const JAM_OBSERVED_NOTE =
747
+ "\n\nJAM OBSERVED: a JAM (0x61) event arrived on this instance's wire -- the 6510 executed an illegal " +
748
+ "opcode and the CPU is dead, regardless of what the verdict above says. With -jamaction 2 the machine " +
749
+ "stops and the brackets read zero, which looks like a wedge; with the default jamaction the emulator " +
750
+ "keeps burning cycles refetching the same opcode, so the brackets ADVANCE and it looks live. Neither is " +
751
+ "a wedge. Recover with vice_machine_reset -- do NOT vice_recycle, which destroys an instance a reset " +
752
+ "would have fixed.";
753
+
754
+ /** WR-04: `jamObserved` is derived HERE, from the same seam as
755
+ * `machinePaused`, and stamped into EVERY verdict's evidence -- not added
756
+ * per-call-site, for the same reason WR-03 removed the hand-passed
757
+ * `machinePaused`. It is evidence on the existing five verdicts, never a
758
+ * sixth verdict (D-03). Always present (never omitted when false), so an
759
+ * absent field can never be read as "no jam". */
760
+ function diagnoseVerdictResult(
761
+ session: StockConnectSession | null,
762
+ verdict: StockDiagnoseVerdict,
763
+ evidence: Record<string, unknown>,
764
+ report: string,
765
+ ): StockToolResult {
766
+ const { machinePaused, machinePausedSource } = deriveMachinePaused(session);
767
+ const jamObserved = session === null ? false : jamObservedFor(session.client);
768
+ const payload: Record<string, unknown> = {
769
+ verdict,
770
+ evidence: { ...evidence, jamObserved },
771
+ report: jamObserved ? report + JAM_OBSERVED_NOTE : report,
772
+ machinePaused,
773
+ machinePausedSource,
774
+ };
775
+ return session ? stockAnswer(session.client, payload) : derivedAnswer(payload);
776
+ }
777
+
778
+
779
+ /**
780
+ * Handles vice_diagnose on the stock backend. Fixed check order, cheap to
781
+ * expensive, mirroring the fork's own D-14 ordering:
782
+ * 1. Bounded session acquisition (finding 4) -- MonitorOwnershipError ->
783
+ * monitor_held_elsewhere; MachineRestartedError -> restarted; a
784
+ * deadline expiry -> a plain refusal naming the bound, never a sixth
785
+ * verdict and never one of the five unestablished.
786
+ * 2. Epoch comparison -- zero emulator calls.
787
+ * 3. gatherStockCheckpointTrapEvidence() -- several reads, no resume.
788
+ * 4. The liveness bracket(s) -- the only step that resumes.
789
+ * Never throws past this point -- every branch is a well-formed isError:false
790
+ * (carrying a verdict) or isError:true (naming what could not be established)
791
+ * result.
792
+ */
793
+ // Declared as a `function` (hoisted at module INSTANTIATION time), not a
794
+ // `const` arrow expression -- REQUIRED, not stylistic, given this plan's own
795
+ // registration (07-09): stock-dispatch.ts now imports this name AND
796
+ // stock-diagnose.ts imports ensureStockSession (a real, non-type-only
797
+ // runtime import) FROM stock-dispatch.ts, so the two modules form a genuine
798
+ // two-node runtime cycle. A `const` binding is only initialised when module
799
+ // EVALUATION reaches its assignment statement; a `function` declaration is
800
+ // initialised during module INSTANTIATION, before ANY module in the whole
801
+ // graph starts evaluating -- so it is immune to which module the cycle is
802
+ // entered through. Reproduced live: entering the cycle via stock-recycle.ts
803
+ // (which also imports resolveStockLiveIrqHandler et al. from this file)
804
+ // crashed with "ReferenceError: Cannot access 'handleDiagnoseStock' before
805
+ // initialization" at stock-dispatch.ts's own STOCK_DISPATCH_TABLE literal,
806
+ // while entering via stock-dispatch.ts itself did not -- an entry-point-
807
+ // order-dependent crash is exactly the hazard a `function` declaration
808
+ // avoids structurally, matching ensureStockSession's OWN `function`
809
+ // declaration in stock-dispatch.ts (never a `const`, for the identical
810
+ // reason). See also handleRecycleStock's identical fix in stock-recycle.ts.
811
+ // The `DerivedPureHandler` type is still enforced -- see the `satisfies`
812
+ // check just below this function.
813
+ export async function handleDiagnoseStock(_args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
814
+ try {
815
+ const timeoutMs = diagnoseSessionTimeoutMs();
816
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
817
+ const timeoutSignal = new Promise<{ timedOut: true }>((resolve) => {
818
+ timeoutHandle = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
819
+ });
820
+
821
+ let outcome: EnsureStockSessionOutcome;
822
+ // WR-19 (07-REVIEW.md): the in-flight acquisition is OBSERVED, not
823
+ // abandoned. When the timeout branch wins the race, ensureStockSession()
824
+ // keeps running -- a later success installs a module-level `heldSession`
825
+ // AFTER this tool answered `diagnosis_unavailable`, and a later rejection
826
+ // (including a MonitorOwnershipError, which has its own verdict) was
827
+ // absorbed by the already-settled race and reached nothing, not even
828
+ // stderr. Both are invisible state changes attributable to a call that
829
+ // reported failure.
830
+ //
831
+ // This cannot be fixed by cancelling: there is no cancellation in
832
+ // ensureStockSession()'s contract, and inventing one here would race the
833
+ // broker claim. What CAN be fixed is the silence -- so the outcome is
834
+ // recorded on stderr either way, and the refusal text says an acquisition
835
+ // may still be in flight so the caller knows a later `heldSession` is not a
836
+ // ghost.
837
+ //
838
+ // The observer is attached ONLY inside the timed-out branch, deliberately.
839
+ // Attaching it before the race looks equivalent but is not: both the race's
840
+ // own continuation and the observer would be queued on the SAME promise,
841
+ // the observer (registered first) would run first, and any "already
842
+ // settled" flag the race's continuation sets would still be false -- so the
843
+ // NORMAL path would log a spurious abandonment line. Inside the branch the
844
+ // acquisition is by definition still pending, so no settlement can be
845
+ // missed, and a later rejection cannot go unhandled either (the race has
846
+ // already settled and would otherwise drop it).
847
+ const acquisition = ensureStockSession(deps);
848
+
849
+ try {
850
+ const raced = await Promise.race([acquisition.then((o) => ({ timedOut: false as const, outcome: o })), timeoutSignal]);
851
+ if (raced.timedOut) {
852
+ acquisition.then(
853
+ (o) => {
854
+ console.error(
855
+ `handleDiagnoseStock: the session acquisition abandoned by the ${timeoutMs}ms bound COMPLETED afterwards ` +
856
+ `(ok=${o.ok}) -- vice_diagnose already answered diagnosis_unavailable (monitor_acquisition_timeout), and a ` +
857
+ `held session may now exist that that answer did not describe.`,
858
+ );
859
+ },
860
+ (err) => {
861
+ console.error(
862
+ `handleDiagnoseStock: the session acquisition abandoned by the ${timeoutMs}ms bound FAILED afterwards ` +
863
+ `(${describeStockError(err)}) -- vice_diagnose already answered diagnosis_unavailable ` +
864
+ `(monitor_acquisition_timeout), so this failure reached no caller.` +
865
+ (err instanceof MonitorOwnershipError
866
+ ? " NOTE: this was a MonitorOwnershipError, which has its own monitor_held_elsewhere verdict."
867
+ : ""),
868
+ );
869
+ },
870
+ );
871
+ return diagnoseUnavailableResult(
872
+ "monitor_acquisition_timeout",
873
+ `session acquisition did not complete within ${timeoutMs}ms. NOTE: that acquisition may still be IN FLIGHT -- it is not ` +
874
+ `cancelled, so a session may be established (and held) shortly after this answer; its outcome is written to stderr. ` +
875
+ `Do not treat a later-appearing held session as a ghost.`,
876
+ );
877
+ }
878
+ outcome = raced.outcome;
879
+ } catch (err) {
880
+ // The race REJECTED, so the acquisition's outcome did reach a caller --
881
+ // no observer was ever attached on this path, and none is wanted.
882
+ if (err instanceof MonitorOwnershipError) {
883
+ return diagnoseVerdictResult(
884
+ null,
885
+ "monitor_held_elsewhere",
886
+ { holderGrantId: err.holderGrantId ?? null, holderClaimedAt: err.holderClaimedAt ?? null, port: err.port ?? null },
887
+ renderMonitorHeldElsewhereReport(err),
888
+ );
889
+ }
890
+ if (err instanceof MachineRestartedError) {
891
+ return diagnoseVerdictResult(
892
+ null,
893
+ "restarted",
894
+ { baselineEpoch: err.baselineEpoch ?? null, currentEpoch: err.currentEpoch ?? null },
895
+ renderStockRestartedReport(err.baselineEpoch, err.currentEpoch),
896
+ );
897
+ }
898
+ return diagnoseUnavailableResult(classifyDiagnoseUnavailable(err), describeStockError(err));
899
+ } finally {
900
+ if (timeoutHandle !== undefined) {
901
+ clearTimeout(timeoutHandle);
902
+ }
903
+ }
904
+
905
+ if (!outcome.ok) {
906
+ return diagnoseUnavailableResult("session_refused", outcome.message);
907
+ }
908
+ const session = outcome.session;
909
+
910
+ // Step 2: epoch comparison, zero emulator calls.
911
+ const readEpochFn = session.deps.readEpochFn ?? readEpoch;
912
+ const currentRecord: EpochResult | null = session.deps.epochPath ? readEpochFn(session.deps.epochPath) : null;
913
+ if (session.baselineEpoch !== null && currentRecord?.present && currentRecord.epoch !== session.baselineEpoch) {
914
+ return diagnoseVerdictResult(
915
+ session,
916
+ "restarted",
917
+ { baselineEpoch: session.baselineEpoch, currentEpoch: currentRecord.epoch },
918
+ renderStockRestartedReport(session.baselineEpoch, currentRecord.epoch),
919
+ );
920
+ }
921
+
922
+ // Step 3: checkpoint-trap evidence, no resume.
923
+ let trapEvidence: StockCheckpointTrapEvidence;
924
+ try {
925
+ trapEvidence = await gatherStockCheckpointTrapEvidence(session, deps);
926
+ } catch (err) {
927
+ return diagnoseUnavailableResult("evidence_gathering_failed", `gathering checkpoint-trap evidence failed (${describeStockError(err)}).`);
928
+ }
929
+ if (trapEvidence.isTrap) {
930
+ return diagnoseVerdictResult(
931
+ session,
932
+ "checkpoint_trap",
933
+ trapEvidence as unknown as Record<string, unknown>,
934
+ renderStockCheckpointTrapReport(trapEvidence),
935
+ );
936
+ }
937
+
938
+ // Step 4: the liveness bracket -- the only step that resumes.
939
+ let bracket1: StockLivenessBracketResult;
940
+ try {
941
+ bracket1 = await runStockLivenessBracket(session);
942
+ } catch (err) {
943
+ return diagnoseUnavailableResult("evidence_gathering_failed", `the liveness bracket failed (${describeStockError(err)}).`);
944
+ }
945
+
946
+ if (bracket1.advanced === null) {
947
+ return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket1));
948
+ }
949
+ if (bracket1.advanced) {
950
+ return diagnoseVerdictResult(session, "live", { bracketsRun: 1, bracket: serializeBracket(bracket1) }, renderStockLiveReport(bracket1));
951
+ }
952
+
953
+ // Run a second bracket only when the first shows no advance -- mirroring
954
+ // the fork's own short-circuit.
955
+ let bracket2: StockLivenessBracketResult;
956
+ try {
957
+ bracket2 = await runStockLivenessBracket(session);
958
+ } catch (err) {
959
+ return diagnoseUnavailableResult("evidence_gathering_failed", `the second liveness bracket failed (${describeStockError(err)}).`);
960
+ }
961
+
962
+ if (bracket2.advanced === null) {
963
+ return diagnoseUnavailableResult("liveness_unmeasurable", inconclusiveBracketText(bracket2));
964
+ }
965
+ if (bracket2.advanced) {
966
+ return diagnoseVerdictResult(
967
+ session,
968
+ "live",
969
+ { bracketsRun: 2, bracket1: serializeBracket(bracket1), bracket2: serializeBracket(bracket2) },
970
+ renderStockLiveReport(bracket2),
971
+ );
972
+ }
973
+
974
+ return diagnoseVerdictResult(
975
+ session,
976
+ "wedged",
977
+ { bracketsRun: 2, bracket1: serializeBracket(bracket1), bracket2: serializeBracket(bracket2) },
978
+ renderStockWedgedReport(bracket1, bracket2),
979
+ );
980
+ } catch (err) {
981
+ // WR-02: the outer catch-all goes through the classifier too, so there is
982
+ // no isError answer this handler can produce that lacks the documented
983
+ // `vice_diagnose: diagnosis_unavailable (<reason>)` prefix.
984
+ return diagnoseUnavailableResult("unknown", `an unexpected error occurred (${describeStockError(err)}).`);
985
+ }
986
+ }
987
+
988
+ // Compile-time-only check that the function declaration above still
989
+ // satisfies DerivedPureHandler's shape -- the type annotation moved off the
990
+ // declaration itself (a `function` cannot carry a variable's type
991
+ // annotation the way a `const` could), so this is where that contract is
992
+ // still enforced. Erased entirely at runtime (a type-only reference).
993
+ const _handleDiagnoseStockShapeCheck: DerivedPureHandler = handleDiagnoseStock;
994
+ void _handleDiagnoseStockShapeCheck;