@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
|
@@ -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;
|