@henols/vice-mcp 0.2.2 → 0.2.3
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/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1674 -99
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1175 -89
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-join.ts +480 -0
- package/anno-provenance-ledger.ts +472 -0
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +518 -2
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +275 -2
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +365 -210
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/vice-proxy.ts
CHANGED
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
// deleted them) and its parent (the last commit where they still
|
|
52
52
|
// existed).
|
|
53
53
|
// 2. Re-point every tool's dispatch: each tool's `execute` body
|
|
54
|
-
// (`
|
|
54
|
+
// (`stockDispatch.dispatchStock(def.name, args, ...)` as of the
|
|
55
|
+
// fork-backend removal -- UNCHANGED by this rollback either way, it
|
|
55
56
|
// predates and outlives the swap) currently runs inside the
|
|
56
57
|
// `CallToolRequestSchema` override's per-tool lookup; re-wire that
|
|
57
58
|
// same lookup into a single hand-rolled `handleToolsCall()`
|
|
@@ -79,27 +80,15 @@
|
|
|
79
80
|
// every other file in this repo had by default before this phase.
|
|
80
81
|
// A rollback restores that protection as a side effect of removing
|
|
81
82
|
// the only import that ever needed the flag.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
mcpHost,
|
|
92
|
-
type ActiveInstance,
|
|
93
|
-
type EpochResult,
|
|
94
|
-
type SessionInfo,
|
|
95
|
-
type ToolInfo,
|
|
96
|
-
} from "./vice.ts";
|
|
97
|
-
// Sibling import, same relocation as above. probeInstance() is the
|
|
98
|
-
// deliberately-fragile liveness check (see that file's own header): one
|
|
99
|
-
// 1500ms-budget round trip, no retry, no dependency on vice.ts's resilient
|
|
100
|
-
// reconnect ladder.
|
|
101
|
-
import { probeInstance, type ProbeResult } from "./vice-probe.ts";
|
|
102
|
-
import { repoRoot } from "./repo-root.ts";
|
|
83
|
+
// The lease-state accessors and shared error hierarchy (used by BOTH
|
|
84
|
+
// backends, since buildHeldLease() reads activeInstance() on every stock
|
|
85
|
+
// tool call too) live in vice-errors.ts. The fork's own HTTP/JSON-RPC
|
|
86
|
+
// transport module (its outer-name refusal array, the session-identity
|
|
87
|
+
// apparatus, `call()`/`callTool`, `serverInfo()`) is gone entirely: every
|
|
88
|
+
// remaining tool dispatch in this file goes through stockDispatch, never
|
|
89
|
+
// through a fork transport.
|
|
90
|
+
import { activeInstance, useInstance, mcpHost, type ActiveInstance, type ToolInfo } from "./vice-errors.ts";
|
|
91
|
+
import { repoRoot, toolsDir } from "./repo-root.ts";
|
|
103
92
|
// The single version-resolution seam (quick-260819-tsz, D-5) -- PROXY_VERSION
|
|
104
93
|
// below is the only consumer in this file; see version.ts's own header for
|
|
105
94
|
// why this file must never re-derive any part of the algorithm itself.
|
|
@@ -109,15 +98,15 @@ import { hostPath, SET_ENV_HINT } from "./hostpath.ts";
|
|
|
109
98
|
// own host-local coordinates before useInstance() ever adopts them (this
|
|
110
99
|
// task, quick-260801-ccn). Consuming this from the proxy -- rather than
|
|
111
100
|
// hand-translating a host path here -- is what keeps the host-path consumer
|
|
112
|
-
// set closed to a fixed, traced list of exactly
|
|
113
|
-
// (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
|
|
114
|
-
//
|
|
101
|
+
// set closed to a fixed, traced list of exactly four production modules
|
|
102
|
+
// (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts),
|
|
103
|
+
// pinned by hostpath-consumers.test.ts.
|
|
115
104
|
import { containerizeRecord } from "./containerpath.ts";
|
|
116
105
|
// The container-side half of the on-demand broker protocol (Phase 01.2).
|
|
117
106
|
// This module deliberately does NOT import hostpath.mjs itself -- the
|
|
118
|
-
// host-path consumer set stays closed to exactly
|
|
119
|
-
// (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts,
|
|
120
|
-
//
|
|
107
|
+
// host-path consumer set stays closed to exactly four production modules
|
|
108
|
+
// (containerpath.ts, install-resources.ts, stock-paths.ts, vice-proxy.ts),
|
|
109
|
+
// pinned by hostpath-consumers.test.ts, and this file is
|
|
121
110
|
// already on that list, so any broker-related host path text is built HERE.
|
|
122
111
|
// Tasks 1+2 (this plan) swap acquisition, release AND recycle onto the TCP
|
|
123
112
|
// control session (openBrokerControl()/BrokerControlSession, plan 06's
|
|
@@ -130,33 +119,28 @@ import { containerizeRecord } from "./containerpath.ts";
|
|
|
130
119
|
// deadline, task 3's renamed successor to the now-deleted
|
|
131
120
|
// RECYCLE_ACK_TIMEOUT_MS) is reused below as the bound the post-kill
|
|
132
121
|
// epoch-and-readiness poll uses -- a concern this swap does not touch.
|
|
122
|
+
// RECYCLE_TIMEOUT_MS is no longer imported here: the fork-only generic
|
|
123
|
+
// forwarding function and its own wedge-evidence gatherer that used it for
|
|
124
|
+
// their post-kill epoch/readiness poll are deleted -- vice_recycle's
|
|
125
|
+
// stock implementation (stock-recycle.ts, reached via stockDispatch) owns
|
|
126
|
+
// that timeout itself now. The incident-record import (writeIncidentRecord,
|
|
127
|
+
// finaliseIncidentRecord, incidentAssetPath, incidentAssetStem,
|
|
128
|
+
// IncidentEvidence, IncidentAssetStemOptions) is gone for the same reason:
|
|
129
|
+
// its only caller was the fork-only handleRecycle() body that wrote a
|
|
130
|
+
// pre-kill incident record over call() -- stock-recycle.ts's own
|
|
131
|
+
// handleRecycleStock() does this natively now, never through this file.
|
|
133
132
|
import {
|
|
134
133
|
readBrokerLiveness,
|
|
135
134
|
brokerRootDir,
|
|
136
|
-
RECYCLE_TIMEOUT_MS,
|
|
137
135
|
openBrokerControl,
|
|
138
136
|
type BrokerLivenessResult,
|
|
139
137
|
type BrokerControlSession,
|
|
140
138
|
type ControlFailureKind,
|
|
141
139
|
type HeldLease,
|
|
142
140
|
} from "./vice-broker-client.ts";
|
|
143
|
-
// The recycle path's own incident record (plan 01.3-01) -- written BEFORE
|
|
144
|
-
// anything is killed (D-17), never through any network call of its own.
|
|
145
|
-
// incidentAssetPath()/incidentAssetStem() (plan 01.3-03) are the SAME stem-
|
|
146
|
-
// building logic incidentRecordPath() itself uses -- imported here so the
|
|
147
|
-
// evidence gatherer's screenshot and the pre-kill snapshot's name can never
|
|
148
|
-
// drift onto a second, independent naming rule.
|
|
149
|
-
import {
|
|
150
|
-
writeIncidentRecord,
|
|
151
|
-
finaliseIncidentRecord,
|
|
152
|
-
incidentAssetPath,
|
|
153
|
-
incidentAssetStem,
|
|
154
|
-
type IncidentEvidence,
|
|
155
|
-
type IncidentAssetStemOptions,
|
|
156
|
-
} from "./incident-record.ts";
|
|
157
141
|
import { readFileSync } from "node:fs";
|
|
158
142
|
import { fileURLToPath } from "node:url";
|
|
159
|
-
import { dirname, join,
|
|
143
|
+
import { dirname, join, resolve } from "node:path";
|
|
160
144
|
// The wire-layer replacement (this plan, D-01): MCPServer owns tools/list's
|
|
161
145
|
// schema-conversion dispatch; the CallToolRequestSchema override installed
|
|
162
146
|
// below (immediately after startStdio(), see that call site's own comment)
|
|
@@ -180,11 +164,6 @@ import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
|
180
164
|
// and every call site.
|
|
181
165
|
import * as backendDetect from "./backend-detect.mts";
|
|
182
166
|
import * as stockDispatch from "./stock-dispatch.ts";
|
|
183
|
-
// Plan 08-02: the single per-backend capability lookup (BACK-05), consumed
|
|
184
|
-
// only inside the CallToolRequestSchema override's tools[name] miss branch
|
|
185
|
-
// below, strictly after the DENY_LIST check -- see the comment at that call
|
|
186
|
-
// site for why the ordering is load-bearing.
|
|
187
|
-
import { capabilityRefusalMessage } from "./capability-registry.ts";
|
|
188
167
|
// Plan 29-01: the curated anno_* tool surface's DEFINITIONS, imported
|
|
189
168
|
// STATICALLY -- registration below happens synchronously at module scope, so
|
|
190
169
|
// a dynamic import cannot serve it. This costs nothing at module load: no
|
|
@@ -206,11 +185,11 @@ import { ANNO_TOOL_DEFINITIONS, runAnnoTool } from "./anno-tools.ts";
|
|
|
206
185
|
// work in all three routes.
|
|
207
186
|
//
|
|
208
187
|
// This branch runs as the first executable statement of the module body,
|
|
209
|
-
// deliberately ABOVE `
|
|
210
|
-
// binary
|
|
188
|
+
// deliberately ABOVE `RESOLVED_BINARY`'s own path resolution (which stats the
|
|
189
|
+
// binary), above the manifest read, and above
|
|
211
190
|
// `new MCPServer(...)`/`server.startStdio()` far below -- a CLI invocation
|
|
212
|
-
// must never open a socket, never
|
|
213
|
-
// JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
|
|
191
|
+
// must never open a socket, never touch a binary's filesystem identity, and
|
|
192
|
+
// never write a byte of JSON-RPC to stdout. WHAT NOT TO DO: never let this branch fall through
|
|
214
193
|
// into the server path, and never print anything on stdout on the server
|
|
215
194
|
// path that a CLI caller could confuse for `anno` output.
|
|
216
195
|
//
|
|
@@ -312,13 +291,16 @@ if (process.argv[2] === "anno") {
|
|
|
312
291
|
|
|
313
292
|
const HERE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
314
293
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
294
|
+
// FORKRM-01 (plan 52-06): there is one backend now, so there is nothing left
|
|
295
|
+
// to select between here -- this used to settle a backend verdict constant
|
|
296
|
+
// once, at module scope, for the manifest selection, the tools construction
|
|
297
|
+
// loop's dispatch choice, the final ready log line, and a cross-check
|
|
298
|
+
// against the broker's own verdict. All four backend-conditional call sites
|
|
299
|
+
// now pass the literal `"stock"` directly; the only thing still resolved
|
|
300
|
+
// here is the binary's own PATH, kept under a narrowly-named constant
|
|
301
|
+
// instead of a backend-shaped object.
|
|
320
302
|
//
|
|
321
|
-
// `
|
|
303
|
+
// `RESOLVED_BINARY.binPath` is what `vice_ping`'s `resolvedBinaryPath` field
|
|
322
304
|
// reports (see stock-dispatch.ts's `handlePing()`). It is resolved exactly
|
|
323
305
|
// ONCE here, at MCP-server process startup, by a bare `x64sc` `$PATH` probe
|
|
324
306
|
// run in THIS process's own environment -- it is NOT re-probed per request
|
|
@@ -334,7 +316,7 @@ const HERE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
334
316
|
// (2026-08-19 finding, closed as a documentation fix by Phase 15 plan 15-09
|
|
335
317
|
// rather than a per-request requery, which would be a behavioural change out
|
|
336
318
|
// of a disposition phase's remit).
|
|
337
|
-
const
|
|
319
|
+
const RESOLVED_BINARY = backendDetect.resolvedBackend();
|
|
338
320
|
|
|
339
321
|
// -------------------------------------------------------------- JSON-RPC
|
|
340
322
|
//
|
|
@@ -569,12 +551,13 @@ const DIAGNOSE_TOOL: ToolDefinition = {
|
|
|
569
551
|
};
|
|
570
552
|
|
|
571
553
|
// Edit 1 (plan 02-10): delegates to stock-dispatch.ts's own selector function
|
|
572
|
-
// -- the ONE manifest site this file keeps
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
554
|
+
// -- the ONE manifest site this file keeps. FORKRM-01: always resolves the
|
|
555
|
+
// stock manifest now, since there is nothing else to select between; the
|
|
556
|
+
// existing malformed-manifest fallbacks in readManifestTools() below are
|
|
557
|
+
// untouched: a missing or unreadable stock manifest still answers tools/list
|
|
558
|
+
// with an empty array rather than crashing the server.
|
|
576
559
|
function manifestPath(): string {
|
|
577
|
-
return stockDispatch.manifestPathForBackend(
|
|
560
|
+
return stockDispatch.manifestPathForBackend(HERE_DIR, process.env.VICE_TOOLS_MANIFEST);
|
|
578
561
|
}
|
|
579
562
|
|
|
580
563
|
function readManifestTools(): ToolInfo[] {
|
|
@@ -613,101 +596,27 @@ function readManifestTools(): ToolInfo[] {
|
|
|
613
596
|
|
|
614
597
|
// --------------------------------------------------------------- tools/call
|
|
615
598
|
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
// `tools/call` params (a missing/non-string `name`) are
|
|
621
|
-
//
|
|
622
|
-
// (installed at the construction site near the bottom of this file)
|
|
623
|
-
// there is no `ProtocolError`/`handleMessage()` pair left in this file to
|
|
624
|
-
// catch that case.
|
|
625
|
-
//
|
|
626
|
-
// Two hazards are enforced HERE, at the proxy seam, as independent layers on
|
|
627
|
-
// top of what `call()` already does internally:
|
|
599
|
+
// Every advertised tool dispatches through stockDispatch.dispatchStock(),
|
|
600
|
+
// which owns its own reconnect and epoch-drift handling (stock-connect.ts).
|
|
601
|
+
// This proxy layer performs no per-call epoch re-check of its own -- the
|
|
602
|
+
// generic forwarding function that once needed one here is gone. Malformed
|
|
603
|
+
// `tools/call` params (a missing/non-string `name`) are rejected one layer
|
|
604
|
+
// further out, by the SDK's own `CallToolRequestSchema` zod validation
|
|
605
|
+
// (installed at the construction site near the bottom of this file).
|
|
628
606
|
//
|
|
629
|
-
// 1. vice_disk_list refusal. `call()` already refuses it (throwing a
|
|
630
|
-
// ViceError), but this proxy refuses it FIRST, before any forwarding
|
|
631
|
-
// logic runs and before any network attempt, so the refusal is
|
|
632
|
-
// observable with zero HTTP traffic and a well-formed MCP frame rather
|
|
633
|
-
// than one more layer of catch between the hazard and the answer.
|
|
634
|
-
//
|
|
635
|
-
// 2. Per-call epoch re-check (decision D-D). The proxy does NOT call
|
|
636
|
-
// assertSameMachine() and does NOT probe vice_checkpoint_list -- a
|
|
637
|
-
// state-reading call that pauses the emulated CPU and never resumes it,
|
|
638
|
-
// and the proxy arms no checkpoints of its own to probe with anyway.
|
|
639
|
-
// The narrowed contract is a plain readEpoch() comparison, before AND
|
|
640
|
-
// after every forwarded call: a changed epoch refuses the call (or
|
|
641
|
-
// discards its result, if the change happened mid-call) with a loud,
|
|
642
|
-
// evidence-carrying error naming both epoch values, then adopts the new
|
|
643
|
-
// value as the baseline so the SESSION stays usable -- a restart report
|
|
644
|
-
// is never cached, per criterion 6.
|
|
645
607
|
// NEVER-CACHE-A-NEGATIVE-RESULT INVARIANT (plan 01.1-03 task 1, criterion 6;
|
|
646
608
|
// extended to the broker path by plan 01.2-03 task 1, C11): nothing below
|
|
647
|
-
// this line may memoise "the
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// just failed one 200ms ago", or "let's remember the broker was absent last
|
|
659
|
-
// call so we don't bother checking again") -- don't, for either path. A
|
|
660
|
-
// cached negative here is exactly the "quiet wrong answer" failure class
|
|
661
|
-
// this codebase rejects elsewhere (MachineRestartedError, the epoch
|
|
662
|
-
// re-check itself): the call after a human starts the broker must just
|
|
663
|
-
// work, with no session restart required.
|
|
664
|
-
let viceSession: SessionInfo | null = null; // beginSession()'s return value, set lazily on the first forwarded call
|
|
665
|
-
let epochBaseline: EpochResult | null = null; // the rolling comparison point; updated on every re-baseline
|
|
666
|
-
|
|
667
|
-
function ensureViceSession(): void {
|
|
668
|
-
if (!viceSession) {
|
|
669
|
-
viceSession = beginSession();
|
|
670
|
-
epochBaseline = viceSession.baseline;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function currentEpoch(): EpochResult {
|
|
675
|
-
return readEpoch((viceSession as SessionInfo).epochPath);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function epochChanged(baseline: EpochResult | null, current: EpochResult | null): boolean {
|
|
679
|
-
return Boolean(baseline?.present) && Boolean(current?.present) && baseline!.epoch !== current!.epoch;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function epochDriftMessage(when: string, baseline: EpochResult, current: EpochResult): string {
|
|
683
|
-
const pidNote = current && current.pid != null ? `, pid ${current.pid}` : "";
|
|
684
|
-
const spawnedNote = current && current.spawned_at ? `, spawned_at ${current.spawned_at}` : "";
|
|
685
|
-
return (
|
|
686
|
-
`vice: treat every result since the previous call as void and redo that work -- epoch drift was ` +
|
|
687
|
-
`detected ${when} (epoch changed from ${baseline.epoch} to ${current.epoch}${pidNote}${spawnedNote}).`
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Compare the rolling baseline against a fresh epoch read. Returns an error
|
|
693
|
-
* MESSAGE string if the comparison proves a restart (and re-baselines to the
|
|
694
|
-
* new value so the next call is not refused again), or `null` if the call
|
|
695
|
-
* may proceed (including the "absent baseline, now present" case, which is
|
|
696
|
-
* adopted silently -- a supervisor merely started, not a restart, mirroring
|
|
697
|
-
* vice.ts's own "only compare when both are present" rule).
|
|
698
|
-
*/
|
|
699
|
-
function checkEpochAndRebaseline(when: string): string | null {
|
|
700
|
-
const current = currentEpoch();
|
|
701
|
-
if (epochChanged(epochBaseline, current)) {
|
|
702
|
-
const msg = epochDriftMessage(when, epochBaseline as EpochResult, current);
|
|
703
|
-
epochBaseline = current; // never cache a negative result (criterion 6)
|
|
704
|
-
return msg;
|
|
705
|
-
}
|
|
706
|
-
if (!(epochBaseline as EpochResult).present && current.present) {
|
|
707
|
-
epochBaseline = current;
|
|
708
|
-
}
|
|
709
|
-
return null;
|
|
710
|
-
}
|
|
609
|
+
// this line may memoise "the broker is absent" as a fact that outlives a
|
|
610
|
+
// single tools/call. There is no cached probe verdict, no sticky "last known
|
|
611
|
+
// unreachable" flag, and no early-return short-circuit keyed off a PREVIOUS
|
|
612
|
+
// failure -- ensureBrokerLease()'s readBrokerLiveness() call reads
|
|
613
|
+
// broker.json fresh every time it is reached, never memoised at module
|
|
614
|
+
// scope. This is deliberate and easy to break by a later, performance-minded
|
|
615
|
+
// edit ("let's remember the broker was absent last call so we don't bother
|
|
616
|
+
// checking again") -- don't. A cached negative here is exactly the "quiet
|
|
617
|
+
// wrong answer" failure class this codebase rejects elsewhere
|
|
618
|
+
// (MachineRestartedError): the call after a human starts the broker must
|
|
619
|
+
// just work, with no session restart required.
|
|
711
620
|
|
|
712
621
|
interface ErrorTextResult {
|
|
713
622
|
content: { type: "text"; text: string }[];
|
|
@@ -720,857 +629,83 @@ function isErrorText(text: string): ErrorTextResult {
|
|
|
720
629
|
|
|
721
630
|
/** The shape every tools/call outcome takes (Pattern 2): success or failure,
|
|
722
631
|
* never a JSON-RPC `error` object. Shared by handleRecycle(), handleDiagnose(),
|
|
723
|
-
* handleResultContinue(), wrapPossiblyChunked() and
|
|
632
|
+
* handleResultContinue(), wrapPossiblyChunked() and the CallToolRequestSchema
|
|
633
|
+
* override itself (near the bottom of this file). */
|
|
724
634
|
interface OkTextResult {
|
|
725
635
|
content: { type: "text"; text: string }[];
|
|
726
636
|
isError: false;
|
|
727
637
|
}
|
|
728
638
|
type ToolCallResult = ErrorTextResult | OkTextResult;
|
|
729
639
|
|
|
730
|
-
//
|
|
640
|
+
// -------------------------------------------------------- dispatchStockFor
|
|
731
641
|
//
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
/** Renders a human-facing message for a recycle ack whose kill stage was
|
|
746
|
-
* NOT a successful kill -- named per outcome so an operator reading the
|
|
747
|
-
* result can tell "no grant record" from "unreadable epoch file" from "no
|
|
748
|
-
* pid recorded" from "identity mismatch" without opening the broker log
|
|
749
|
-
* (matches resources/vice-broker.sh's own per-outcome ack strings). */
|
|
750
|
-
function recycleAckOutcomeMessage(ack: Record<string, unknown>): string {
|
|
751
|
-
const outcome = ack && typeof ack.outcome === "string" ? ack.outcome : "unknown";
|
|
752
|
-
const stage = ack && typeof ack.kill_stage === "string" ? ack.kill_stage : "unknown";
|
|
753
|
-
const reason = ack && typeof ack.reason === "string" && ack.reason ? ` (${ack.reason})` : "";
|
|
754
|
-
switch (outcome) {
|
|
755
|
-
case "identity_refused":
|
|
756
|
-
return (
|
|
757
|
-
`vice_recycle: the host refused to signal the target -- its process identity did not match ` +
|
|
758
|
-
`the binary recorded in its own epoch file (kill stage: ${stage}). The instance was NOT ` +
|
|
759
|
-
`killed and is still running.`
|
|
760
|
-
);
|
|
761
|
-
case "target_lookup_failed":
|
|
762
|
-
return `vice_recycle: the host could not resolve this session's own recycle target (kill stage: ${stage})${reason}.`;
|
|
763
|
-
case "grant_lookup_failed":
|
|
764
|
-
return `vice_recycle: the host found no grant record for this session's target (kill stage: ${stage})${reason}.`;
|
|
765
|
-
case "epoch_lookup_failed":
|
|
766
|
-
return `vice_recycle: the host could not read the target's epoch file (kill stage: ${stage})${reason}.`;
|
|
767
|
-
case "pid_lookup_failed":
|
|
768
|
-
return `vice_recycle: the target's own epoch file carries no pid to signal (kill stage: ${stage})${reason}.`;
|
|
769
|
-
default:
|
|
770
|
-
return `vice_recycle: the host reported outcome "${outcome}" (kill stage: ${stage})${reason}.`;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Handles the destructive vice_recycle tool. Fixed order, and the order is
|
|
776
|
-
* the point (plan 01.3-01 task 1): read the current epoch first; refuse
|
|
777
|
-
* (no incident record, no request) when no broker lease is held yet or an
|
|
778
|
-
* explicit VICE_MCP_URL override is in effect -- there is no broker to ask
|
|
779
|
-
* and no supervisor to respawn either way; write the incident record BEFORE
|
|
780
|
-
* anything else touches the host (D-17); only then write the recycle
|
|
781
|
-
* request; await the ack; on anything other than a successful kill,
|
|
782
|
-
* finalise the record with that outcome and return a well-formed error
|
|
783
|
-
* naming the stage verbatim; on a successful kill, poll for the epoch to
|
|
784
|
-
* move and probe readiness as two SEPARATE facts (T-01.3-03), finalise the
|
|
785
|
-
* record, re-baseline, and return success. Never throws past this point --
|
|
786
|
-
* every branch is a well-formed isError result (a dead stdio proxy is
|
|
787
|
-
* unrecoverable for the session).
|
|
788
|
-
*/
|
|
789
|
-
// Declared as `const ... = async function handleRecycle(args) { ... }` (a
|
|
790
|
-
// contextually-typed function EXPRESSION), not `async function
|
|
791
|
-
// handleRecycle(args: ...) { ... }` (a typed declaration): the latter's
|
|
792
|
-
// exact param-list text would drift from vice-proxy.test.mjs's own
|
|
793
|
-
// structural oracle (`indexOf("async function handleRecycle(args)")`),
|
|
794
|
-
// which is off-limits to edit in this plan. The variable's own type
|
|
795
|
-
// annotation gives `args` a real, checked type via TS's ordinary contextual
|
|
796
|
-
// typing for a function expression assigned to a typed const -- verified
|
|
797
|
-
// live this session against a scratch file (see RE-FINDINGS.md) -- so this
|
|
798
|
-
// is real typing, not a suppression: every field read below still narrows
|
|
799
|
-
// `args` the same way every other handler in this file does.
|
|
800
|
-
const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
|
|
801
|
-
const rawReason = args && typeof args.reason === "string" ? args.reason : "";
|
|
802
|
-
const reason = rawReason.trim();
|
|
803
|
-
if (!reason) {
|
|
804
|
-
return isErrorText(
|
|
805
|
-
'vice_recycle requires a non-empty "reason" string naming why this recycle is happening -- it ' +
|
|
806
|
-
"becomes the incident record's own explanation, written before anything is killed. No record " +
|
|
807
|
-
"and no request were written."
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const preKillEpoch = readEpoch();
|
|
812
|
-
|
|
813
|
-
if (process.env.VICE_MCP_URL) {
|
|
814
|
-
return isErrorText(
|
|
815
|
-
"vice_recycle: VICE_MCP_URL is set, so this session talks to an explicitly overridden endpoint " +
|
|
816
|
-
"with no broker to ask and no supervisor to respawn it. Recycle only applies to a broker-" +
|
|
817
|
-
"granted instance. No record and no request were written."
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
if (!controlSession) {
|
|
821
|
-
return isErrorText(
|
|
822
|
-
"vice_recycle: no broker lease is held yet for this session -- recycle only applies to an " +
|
|
823
|
-
"instance already granted to this session. Make at least one other forwarded call first. " +
|
|
824
|
-
"No record and no request were written."
|
|
825
|
-
);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
829
|
-
const { port } = activeInstance();
|
|
830
|
-
const epochBefore = preKillEpoch.present ? preKillEpoch.epoch : null;
|
|
831
|
-
const at = new Date().toISOString();
|
|
832
|
-
|
|
833
|
-
// Plan 01.3-03 (D-17, extended): gather the FULL criterion-4 evidence set
|
|
834
|
-
// -- including the best-effort pre-kill snapshot -- BEFORE the record is
|
|
835
|
-
// written. There is no argument, environment variable or branch between
|
|
836
|
-
// here and the record write that can reach the request write with any of
|
|
837
|
-
// this still ungathered; every step above degrades to unavailable rather
|
|
838
|
-
// than aborting, so this line always completes.
|
|
839
|
-
const evidence = await gatherWedgeEvidence({ at, port, epoch: epochBefore });
|
|
840
|
-
evidence.snapshot = await captureSnapshotAttempt({ at, port, epoch: epochBefore });
|
|
841
|
-
|
|
842
|
-
// D-17: the record is written BEFORE the request -- capturing is
|
|
843
|
-
// structurally impossible to skip, not a discipline to remember.
|
|
844
|
-
const recordPath = writeIncidentRecord({
|
|
845
|
-
at,
|
|
846
|
-
port,
|
|
847
|
-
epoch_before: epochBefore,
|
|
848
|
-
reason,
|
|
849
|
-
session_id: sessionId,
|
|
850
|
-
evidence,
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
// Plan 01.6.2-07 task 2: the request write + ack poll are replaced by one
|
|
854
|
-
// recycle request over the connection this session already holds -- the
|
|
855
|
-
// client_pid this session used to send with a recycle request has no
|
|
856
|
-
// successor field on the wire, since the connection itself already
|
|
857
|
-
// identifies which grant this is (broker-control.mts's own T-01.6.2-31
|
|
858
|
-
// discipline: a connection may only recycle the grant it itself holds).
|
|
859
|
-
const recycled = await controlSession.recycle(grantId as string);
|
|
860
|
-
if (!recycled.ok) {
|
|
861
|
-
if (recycled.kind === "broker_gone") {
|
|
862
|
-
// D-14 (plan 08): distinct from an acknowledgement carrying a refusal
|
|
863
|
-
// (T-01.6.2-46) -- the instance's state is unknown in both cases, but
|
|
864
|
-
// the operator's next action differs, a refusal means the target is
|
|
865
|
-
// alive and uncooperative, broker_gone means there is no longer
|
|
866
|
-
// anyone to ask. Reuses sessionMustRestartMessage() -- the SAME
|
|
867
|
-
// fresh-machine vocabulary a forwarded call's own broker-gone path
|
|
868
|
-
// (handleGrantedInstanceUnreachable() above) produces -- rather than
|
|
869
|
-
// a bare transport error string. Deliberately does NOT attempt to
|
|
870
|
-
// open a fresh session and acquire a replacement the way a forwarded
|
|
871
|
-
// call does: the instance THIS recycle was trying to kill is now of
|
|
872
|
-
// genuinely unknown state (the kill request may or may not have
|
|
873
|
-
// reached the broker before the connection dropped), and silently
|
|
874
|
-
// handing back a different "replacement" instance under the name of
|
|
875
|
-
// a recycle result would claim more certainty about that kill than
|
|
876
|
-
// this proxy actually has.
|
|
877
|
-
finaliseIncidentRecord(recordPath, { outcome: "broker_gone" });
|
|
878
|
-
return isErrorText(
|
|
879
|
-
`${sessionMustRestartMessage(recycled)} Incident record: ${recordPath}. This recycle's own kill ` +
|
|
880
|
-
`request may or may not have reached the broker before the connection dropped -- the instance's ` +
|
|
881
|
-
`state is now unknown.`
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
if (recycled.kind === "deadline") {
|
|
885
|
-
finaliseIncidentRecord(recordPath, { outcome: "timeout" });
|
|
886
|
-
return isErrorText(
|
|
887
|
-
`vice_recycle: no ack arrived from the host within the timeout (${recycled.message}). Incident ` +
|
|
888
|
-
`record: ${recordPath}. The instance's state is now unknown -- treat it as neither confirmed ` +
|
|
889
|
-
`killed nor confirmed alive.`
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
// Any other control-plane failure (protocol/unauthorized/bad_request/
|
|
893
|
-
// denied/internal) -- an unexpected shape from the broker's own
|
|
894
|
-
// response, not exhaustively enumerated here (D-14's full vocabulary is
|
|
895
|
-
// plan 08's); still a well-formed, non-throwing result either way.
|
|
896
|
-
finaliseIncidentRecord(recordPath, { outcome: "internal" });
|
|
897
|
-
return isErrorText(
|
|
898
|
-
`vice_recycle: the recycle request failed (${recycled.kind}: ${recycled.message}). Incident record: ${recordPath}.`
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
const ack = recycled.ack;
|
|
903
|
-
const killStage: string | null = ack.kill_stage;
|
|
904
|
-
const successfulKill = killStage === "already_exited" || killStage === "sigterm" || killStage === "sigkill";
|
|
905
|
-
|
|
906
|
-
if (!successfulKill) {
|
|
907
|
-
finaliseIncidentRecord(recordPath, { outcome: ack.outcome || "refused", kill_stage: killStage });
|
|
908
|
-
return isErrorText(`${recycleAckOutcomeMessage({ ...ack })} Incident record: ${recordPath}.`);
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// The kill succeeded -- confirm the machine actually came back. The epoch
|
|
912
|
-
// bump and the readiness probe are reported as two SEPARATE facts
|
|
913
|
-
// (T-01.3-03): "the epoch moved" is bookkeeping, "the instance answers"
|
|
914
|
-
// is evidence, and neither substitutes for the other.
|
|
915
|
-
const epochDeadline = Date.now() + RECYCLE_TIMEOUT_MS;
|
|
916
|
-
let afterEpoch = readEpoch();
|
|
917
|
-
const epochMoved = () =>
|
|
918
|
-
afterEpoch.present && (!preKillEpoch.present || (afterEpoch.epoch as number) > (preKillEpoch.epoch as number));
|
|
919
|
-
while (Date.now() < epochDeadline && !epochMoved()) {
|
|
920
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
921
|
-
afterEpoch = readEpoch();
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
const { url, port: instancePort } = activeInstance();
|
|
925
|
-
const probe = await probeInstance({ url, port: instancePort });
|
|
926
|
-
|
|
927
|
-
// 2026-08-05 defect fix: the persisted record's own epoch_after must never
|
|
928
|
-
// carry a stale value equal to epoch_before -- that pair reads as
|
|
929
|
-
// "confirmed unchanged" to a future reader, which is a false claim for a
|
|
930
|
-
// kill that just genuinely succeeded (killStage is one of the three
|
|
931
|
-
// successful-kill stages here, by construction of the guard above). Only
|
|
932
|
-
// the poll loop's own epochMoved() -- not merely afterEpoch.present -- may
|
|
933
|
-
// promote the read into the record; anything else stays the honest `null`
|
|
934
|
-
// ("not yet known", per renderIncidentRecord()'s existing rendering) so a
|
|
935
|
-
// future investigation is never handed a pair that looks complete but
|
|
936
|
-
// isn't.
|
|
937
|
-
finaliseIncidentRecord(recordPath, {
|
|
938
|
-
outcome: "ok",
|
|
939
|
-
kill_stage: killStage,
|
|
940
|
-
epoch_after: epochMoved() ? afterEpoch.epoch : null,
|
|
642
|
+
// The one place every stock tool call's shared deps object is built -- used
|
|
643
|
+
// by the manifest loop below and by handleRecycle()/handleDiagnose() alike,
|
|
644
|
+
// so there is exactly one definition of "what dispatchStock needs" rather
|
|
645
|
+
// than three copies that could drift apart. Kept as a single-line-callable
|
|
646
|
+
// helper (not inlined at each call site) so every registration reads as one
|
|
647
|
+
// source line -- vice-proxy.test.ts's own registration scanner keys each
|
|
648
|
+
// `tools[...] = ...;` line by its raw captured text and expects one
|
|
649
|
+
// registration per line.
|
|
650
|
+
function dispatchStockFor(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
651
|
+
return stockDispatch.dispatchStock(name, args, {
|
|
652
|
+
ensureLease: ensureBrokerLease,
|
|
653
|
+
resolvedBinaryPath: RESOLVED_BINARY.binPath,
|
|
654
|
+
resolvedBinaryPathIsResolved: RESOLVED_BINARY.binPathResolved,
|
|
941
655
|
});
|
|
942
|
-
|
|
943
|
-
// Immediately before returning success -- the deliberate identity change
|
|
944
|
-
// this tool exists to cause would otherwise make every subsequent
|
|
945
|
-
// forwarded call fail the drift guard.
|
|
946
|
-
rebaselineEpochAfterRecycle();
|
|
947
|
-
|
|
948
|
-
const snapshotNote =
|
|
949
|
-
evidence.snapshot && evidence.snapshot.available
|
|
950
|
-
? `accepted (name: ${(evidence.snapshot.value as { name: string }).name})`
|
|
951
|
-
: `unavailable (${evidence.snapshot && evidence.snapshot.reason ? evidence.snapshot.reason : "no reason recorded"})`;
|
|
952
|
-
|
|
953
|
-
return {
|
|
954
|
-
content: [
|
|
955
|
-
{
|
|
956
|
-
type: "text",
|
|
957
|
-
text:
|
|
958
|
-
`vice_recycle: kill stage "${killStage}". Epoch before: ${preKillEpoch.present ? preKillEpoch.epoch : "unknown"}, ` +
|
|
959
|
-
`epoch after: ${afterEpoch.present ? afterEpoch.epoch : "unknown"} (${epochMoved() ? "moved" : "did not move within the timeout"}). ` +
|
|
960
|
-
`Readiness probe: ${probe.alive ? "the respawned instance answered" : `not yet answering (${probe.reason})`}. ` +
|
|
961
|
-
`Snapshot: ${snapshotNote}. ` +
|
|
962
|
-
`Incident record: ${recordPath}. This run is VOID -- resume from the last recorded milestone snapshot.`,
|
|
963
|
-
},
|
|
964
|
-
],
|
|
965
|
-
isError: false,
|
|
966
|
-
};
|
|
967
656
|
}
|
|
968
657
|
|
|
969
|
-
//
|
|
658
|
+
// ------------------------------------------------------------ vice_recycle
|
|
970
659
|
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
if (typeof value === "string") {
|
|
991
|
-
const s = value.trim().replace(/^\$/, "").replace(/^0x/i, "");
|
|
992
|
-
const n = parseInt(s, 16);
|
|
993
|
-
if (Number.isFinite(n)) return n;
|
|
994
|
-
}
|
|
995
|
-
return null;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function formatAddress(n: number | null | undefined): string {
|
|
999
|
-
return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function formatByte(n: number | null | undefined): string {
|
|
1003
|
-
return n === null || n === undefined ? "unknown" : `$${n.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
/** Decode a vice_memory_read result into a plain byte array, accepting
|
|
1007
|
-
* either the compact "hex" string encoding (requested below) or the legacy
|
|
1008
|
-
* per-byte "bytes" array shape -- an untrusted payload degrades to an empty
|
|
1009
|
-
* array, never a thrown exception (T-01.3-06). */
|
|
1010
|
-
function bytesFromMemoryReadResult(result: unknown): number[] {
|
|
1011
|
-
if (isPlainObject(result) && typeof result.hex === "string") {
|
|
1012
|
-
const clean = result.hex.replace(/[^0-9a-fA-F]/g, "");
|
|
1013
|
-
const bytes: number[] = [];
|
|
1014
|
-
for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.slice(i, i + 2), 16));
|
|
1015
|
-
return bytes;
|
|
1016
|
-
}
|
|
1017
|
-
if (isPlainObject(result) && Array.isArray(result.bytes)) {
|
|
1018
|
-
return (result.bytes as unknown[])
|
|
1019
|
-
.map((b) => (typeof b === "string" ? parseInt(b.replace(/^\$/, ""), 16) : Number(b)))
|
|
1020
|
-
.filter((n) => Number.isFinite(n));
|
|
1021
|
-
}
|
|
1022
|
-
return [];
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
function wordFromBytes(bytes: number[]): number | null {
|
|
1026
|
-
return bytes.length >= 2 ? bytes[0] | (bytes[1] << 8) : null;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Bit 1 (HIRAM) of the 6510 processor port at $01. SET -- the KERNAL ROM is
|
|
1030
|
-
// banked in, and the RAM IRQ vector pair ($0314/$0315) is what the KERNAL's
|
|
1031
|
-
// own dispatch actually reads (RE-FINDINGS.md's own vector-table entry).
|
|
1032
|
-
// CLEAR -- the KERNAL is replaced by RAM and the CPU reads the hardware
|
|
1033
|
-
// IRQ/BRK vector pair ($FFFE/$FFFF) directly, with no ROM indirection.
|
|
1034
|
-
const HIRAM_MASK = 0x02;
|
|
1035
|
-
|
|
1036
|
-
/** The live-IRQ-handler lookup's own return shape -- shared by
|
|
1037
|
-
* gatherCheckpointTrapEvidence() below and by plan 01.3-03's evidence
|
|
1038
|
-
* gatherer (gatherWedgeEvidence()). */
|
|
1039
|
-
interface IrqHandlerResolution {
|
|
1040
|
-
target: number | null;
|
|
1041
|
-
pairLabel: string;
|
|
1042
|
-
explanation: string;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/**
|
|
1046
|
-
* The single definition of the live-IRQ-handler lookup (Key Finding 6):
|
|
1047
|
-
* three forwarded reads through the normal call() path -- $01, the RAM
|
|
1048
|
-
* vector pair, and (only when $01 says the ROMs are banked out) the hardware
|
|
1049
|
-
* vector pair. Consumed by the checkpoint-trap check below and, per this
|
|
1050
|
-
* plan's own key_links, by plan 01.3-03's evidence gatherer. Memoises
|
|
1051
|
-
* NOTHING: a disk swap, a reset or a different game retargets the handler,
|
|
1052
|
-
* so a cached address would silently resolve the wrong pair.
|
|
1053
|
-
*/
|
|
1054
|
-
async function resolveLiveIrqHandler(): Promise<IrqHandlerResolution> {
|
|
1055
|
-
const portResult = await call("vice_memory_read", { address: "$01", size: 1, encoding: "hex" });
|
|
1056
|
-
const portBytes = bytesFromMemoryReadResult(portResult);
|
|
1057
|
-
const port01 = portBytes.length > 0 ? portBytes[0] : null;
|
|
1058
|
-
const bankedOut = port01 !== null && (port01 & HIRAM_MASK) === 0;
|
|
1059
|
-
|
|
1060
|
-
const ramResult = await call("vice_memory_read", { address: "$0314", size: 2, encoding: "hex" });
|
|
1061
|
-
const ramTarget = wordFromBytes(bytesFromMemoryReadResult(ramResult));
|
|
1062
|
-
|
|
1063
|
-
if (!bankedOut) {
|
|
1064
|
-
return {
|
|
1065
|
-
target: ramTarget,
|
|
1066
|
-
pairLabel: "the RAM KERNAL IRQ vector pair ($0314/$0315)",
|
|
1067
|
-
explanation:
|
|
1068
|
-
`$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked in, so the RAM IRQ vector pair ` +
|
|
1069
|
-
`($0314/$0315) is the pair this session's IRQ dispatch actually reads; it resolves to ${formatAddress(ramTarget)}.`,
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
const hwResult = await call("vice_memory_read", { address: "$FFFE", size: 2, encoding: "hex" });
|
|
1074
|
-
const hwTarget = wordFromBytes(bytesFromMemoryReadResult(hwResult));
|
|
1075
|
-
return {
|
|
1076
|
-
target: hwTarget,
|
|
1077
|
-
pairLabel: "the hardware IRQ/BRK vector pair ($FFFE/$FFFF)",
|
|
1078
|
-
explanation:
|
|
1079
|
-
`$01 read as ${formatByte(port01)} -- the KERNAL ROM is banked OUT, so the CPU dispatches ` +
|
|
1080
|
-
`directly through the hardware IRQ/BRK vector pair ($FFFE/$FFFF) with no ROM indirection; it ` +
|
|
1081
|
-
`resolves to ${formatAddress(hwTarget)}.`,
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Enumerate armed checkpoints, read the current PC, resolve the live IRQ
|
|
1087
|
-
* handler, and decide the checkpoint-trap verdict on two named shapes
|
|
1088
|
-
* (D-14): an enabled, stopping, exec checkpoint sitting exactly at the
|
|
1089
|
-
* current PC; or one sitting at the resolved handler entry with a hit count
|
|
1090
|
-
* of exactly zero (the corroborating tell that it has never actually
|
|
1091
|
-
* fired). Makes NO resume and NO stopwatch call -- the whole point of
|
|
1092
|
-
* checking this before any cycle bracket (D-14, T-01.3-08).
|
|
1093
|
-
*/
|
|
1094
|
-
/** A single vice_checkpoint_list entry, typed loosely (matching this
|
|
1095
|
-
* codebase's own precedent for a host-written record this proxy never
|
|
1096
|
-
* asserts a closed shape on) -- every field is read defensively below,
|
|
1097
|
-
* never assumed present. */
|
|
1098
|
-
interface CheckpointInfo {
|
|
1099
|
-
checkpoint_num?: unknown;
|
|
1100
|
-
start?: unknown;
|
|
1101
|
-
stop?: unknown;
|
|
1102
|
-
exec?: unknown;
|
|
1103
|
-
enabled?: unknown;
|
|
1104
|
-
hit_count?: unknown;
|
|
1105
|
-
[key: string]: unknown;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
interface CheckpointTrapEvidence {
|
|
1109
|
-
isTrap: boolean;
|
|
1110
|
-
checkpoints: CheckpointInfo[];
|
|
1111
|
-
pc: number | null;
|
|
1112
|
-
handler: IrqHandlerResolution;
|
|
1113
|
-
trapCheckpoint: CheckpointInfo | null;
|
|
1114
|
-
trapReason: "pc" | "handler" | null;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
async function gatherCheckpointTrapEvidence(): Promise<CheckpointTrapEvidence> {
|
|
1118
|
-
const checkpointsResult = await call("vice_checkpoint_list", {});
|
|
1119
|
-
const checkpoints: CheckpointInfo[] =
|
|
1120
|
-
isPlainObject(checkpointsResult) && Array.isArray(checkpointsResult.checkpoints)
|
|
1121
|
-
? (checkpointsResult.checkpoints as CheckpointInfo[])
|
|
1122
|
-
: [];
|
|
1123
|
-
|
|
1124
|
-
const regs = await call("vice_registers_get", {});
|
|
1125
|
-
const pc = isPlainObject(regs) && typeof regs.PC === "number" ? regs.PC : null;
|
|
1126
|
-
|
|
1127
|
-
const handler = await resolveLiveIrqHandler();
|
|
1128
|
-
|
|
1129
|
-
const armedStopping = checkpoints.filter((c) => c && c.enabled !== false && c.stop === true && c.exec === true);
|
|
1130
|
-
|
|
1131
|
-
const atPc = pc !== null ? armedStopping.find((c) => toAddressNumber(c.start) === pc) : undefined;
|
|
1132
|
-
const atHandler =
|
|
1133
|
-
!atPc && handler.target !== null && handler.target !== undefined
|
|
1134
|
-
? armedStopping.find((c) => toAddressNumber(c.start) === handler.target && c.hit_count === 0)
|
|
1135
|
-
: undefined;
|
|
1136
|
-
|
|
1137
|
-
const trapCheckpoint = atPc || atHandler || null;
|
|
1138
|
-
return {
|
|
1139
|
-
isTrap: Boolean(trapCheckpoint),
|
|
1140
|
-
checkpoints,
|
|
1141
|
-
pc,
|
|
1142
|
-
handler,
|
|
1143
|
-
trapCheckpoint,
|
|
1144
|
-
trapReason: atPc ? "pc" : atHandler ? "handler" : null,
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// The recorded incident this report's own "not guaranteed" paragraph cites --
|
|
1149
|
-
// D-15's own caveat, load-bearing per this plan's planning notes: delete,
|
|
1150
|
-
// soft reset, hard reset and an explicit single step ALL left the machine
|
|
1151
|
-
// frozen in this recorded case.
|
|
1152
|
-
const CHECKPOINT_TRAP_INCIDENT_REF =
|
|
1153
|
-
".planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md";
|
|
1154
|
-
|
|
1155
|
-
/** Renders the checkpoint_trap verdict's report -- an explanation, never a
|
|
1156
|
-
* remedy (D-15): it names the armed checkpoints, the resolved handler, the
|
|
1157
|
-
* PC's relation to the trap, states plainly this is self-inflicted and not a
|
|
1158
|
-
* wedge, names the agent's own next moves without performing any of them,
|
|
1159
|
-
* and closes with the not-guaranteed paragraph. */
|
|
1160
|
-
function renderCheckpointTrapReport(evidence: CheckpointTrapEvidence): string {
|
|
1161
|
-
const { checkpoints, pc, handler, trapCheckpoint, trapReason } = evidence;
|
|
1162
|
-
const checkpointList =
|
|
1163
|
-
checkpoints.length === 0
|
|
1164
|
-
? "none armed"
|
|
1165
|
-
: checkpoints
|
|
1166
|
-
.map((c) => {
|
|
1167
|
-
const addr = formatAddress(toAddressNumber(c && c.start));
|
|
1168
|
-
const flag = c && c.stop ? "stop" : "continue";
|
|
1169
|
-
const enabled = c && c.enabled === false ? "disabled" : "enabled";
|
|
1170
|
-
const hitCount = c && typeof c.hit_count === "number" ? c.hit_count : "unknown";
|
|
1171
|
-
return `#${c && c.checkpoint_num} ${addr} (${flag}, ${enabled}, hit_count ${hitCount})`;
|
|
1172
|
-
})
|
|
1173
|
-
.join("; ");
|
|
1174
|
-
|
|
1175
|
-
const pcRelation =
|
|
1176
|
-
trapReason === "pc"
|
|
1177
|
-
? `exactly at armed checkpoint #${trapCheckpoint!.checkpoint_num} -- that is why the machine is stopped here`
|
|
1178
|
-
: trapReason === "handler"
|
|
1179
|
-
? `not at the armed checkpoint's own address, but checkpoint #${trapCheckpoint!.checkpoint_num} sits at ` +
|
|
1180
|
-
"the resolved live IRQ handler entry with hit_count 0 -- the corroborating tell that this checkpoint " +
|
|
1181
|
-
"has never actually fired, not merely that it fired between reads"
|
|
1182
|
-
: "no relation established";
|
|
1183
|
-
|
|
1184
|
-
return [
|
|
1185
|
-
"vice_diagnose verdict: checkpoint_trap",
|
|
1186
|
-
"",
|
|
1187
|
-
`Armed checkpoints: ${checkpointList}.`,
|
|
1188
|
-
`Resolved live IRQ handler: ${handler.explanation}`,
|
|
1189
|
-
`Current PC: ${formatAddress(pc)} -- ${pcRelation}.`,
|
|
1190
|
-
"",
|
|
1191
|
-
"This is a self-inflicted stop, not a wedge: the machine paused because an armed checkpoint " +
|
|
1192
|
-
"fired or sits exactly here, not because it stopped retiring cycles on its own. Recycling now " +
|
|
1193
|
-
"would destroy a healthy instance -- no cycle bracket was run to reach this verdict.",
|
|
1194
|
-
"",
|
|
1195
|
-
"Next moves available to you (this report does not perform any of them): vice_checkpoint_delete " +
|
|
1196
|
-
"the offending checkpoint, or vice_checkpoint_toggle it disabled; vice_execution_step past it; " +
|
|
1197
|
-
"then re-run vice_diagnose.",
|
|
1198
|
-
"",
|
|
1199
|
-
"Not guaranteed: deleting the checkpoint is not guaranteed to unfreeze the machine. The recorded " +
|
|
1200
|
-
`incident (${CHECKPOINT_TRAP_INCIDENT_REF}) shows checkpoint delete, then a soft reset, then a hard ` +
|
|
1201
|
-
"reset, then an explicit single step ALL leaving the machine frozen in sequence -- a checkpoint " +
|
|
1202
|
-
"trap may be the onset without being the whole story. If a cycle bracket still measures zero " +
|
|
1203
|
-
"after the checkpoint is gone, the verdict becomes wedged and recycle is the fallback after all.",
|
|
1204
|
-
].join("\n");
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
/** Renders the restarted verdict's report -- reached from a plain epoch-file
|
|
1208
|
-
* comparison alone, at zero emulator calls (D-14's ordering: this check
|
|
1209
|
-
* costs nothing and runs first). */
|
|
1210
|
-
function renderRestartedReport(beforeEpoch: number | null | undefined, afterEpoch: number | null | undefined): string {
|
|
1211
|
-
return (
|
|
1212
|
-
"vice_diagnose verdict: restarted\n\n" +
|
|
1213
|
-
`The host VICE MCP server's epoch changed from ${beforeEpoch} to ${afterEpoch} -- the emulator ` +
|
|
1214
|
-
"behind this session restarted. This is answered from a plain epoch comparison alone, at zero " +
|
|
1215
|
-
"emulator calls; no checkpoint enumeration was attempted, because a restart is this project's own " +
|
|
1216
|
-
"already-handled case (criterion 1) and re-deriving it here would be a second mechanism. Any run " +
|
|
1217
|
-
"in flight before this point is void."
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// Plan 01.3-02 task 2: the cycle bracket, the definitive liveness test, and
|
|
1222
|
-
// the three verdicts that depend on it (wedged, stale_read_path, live).
|
|
1223
|
-
|
|
1224
|
-
// Three polls: the bracket needs the machine to be given real forwarded
|
|
1225
|
-
// round trips to retire cycles across, and three is enough for the counter
|
|
1226
|
-
// to move at any rate worth calling alive.
|
|
1227
|
-
const CYCLE_BRACKET_PINGS = 3;
|
|
1228
|
-
// Two brackets: criterion 2's minimum for a wedged verdict is two
|
|
1229
|
-
// consecutive zeros, and D-04 makes every additional bracket another call to
|
|
1230
|
-
// the tool most correlated with host death. Two is the minimum and the
|
|
1231
|
-
// maximum.
|
|
1232
|
-
const CYCLE_BRACKET_MAX = 2;
|
|
1233
|
-
|
|
1234
|
-
// ~991,000 cycles/s is the measured PAL C64 full-speed rate (RE-FINDINGS.md,
|
|
1235
|
-
// "the only trustworthy VICE liveness test is a cycle bracket"). Printed
|
|
1236
|
-
// only, as an observation beside a measured rate -- D-08 refuses a
|
|
1237
|
-
// degradation threshold, and a constant that is only ever printed cannot
|
|
1238
|
-
// become one by accident.
|
|
1239
|
-
const BASELINE_CYCLES_PER_SECOND = 991000;
|
|
1240
|
-
|
|
1241
|
-
function cyclesFromStopwatchResult(result: unknown): number {
|
|
1242
|
-
if (isPlainObject(result) && typeof result.cycles === "number") return result.cycles;
|
|
1243
|
-
if (isPlainObject(result) && typeof result.previous_cycles === "number") return result.previous_cycles;
|
|
1244
|
-
return 0;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
interface CycleBracketResult {
|
|
1248
|
-
cycles: number;
|
|
1249
|
-
elapsedMs: number;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
/**
|
|
1253
|
-
* The single definition of the cycle bracket criterion 2 requires: reset the
|
|
1254
|
-
* stopwatch, resume execution exactly once, poll with ping
|
|
1255
|
-
* CYCLE_BRACKET_PINGS times, pause, read the stopwatch back. Pacing comes
|
|
1256
|
-
* from the forwarded round trips alone -- there is no timer, no delay and no
|
|
1257
|
-
* wall-clock quantity anywhere in it (the standing project rule). Every
|
|
1258
|
-
* stopwatch call in this file lives inside this function's body; the
|
|
1259
|
-
* structural test enforces it. `elapsedMs` is measured only to print an
|
|
1260
|
-
* observational rate afterward -- it decides nothing and paces nothing.
|
|
1261
|
-
*/
|
|
1262
|
-
async function runCycleBracket() {
|
|
1263
|
-
await call("vice_cycles_stopwatch", { action: "reset" });
|
|
1264
|
-
const startedAt = Date.now();
|
|
1265
|
-
await call("vice_execution_run", {});
|
|
1266
|
-
for (let i = 0; i < CYCLE_BRACKET_PINGS; i += 1) {
|
|
1267
|
-
await call("vice_ping", {}); // the ping EXECUTION field is never inspected here -- it decides nothing (C1, D-07)
|
|
1268
|
-
}
|
|
1269
|
-
await call("vice_execution_pause", {});
|
|
1270
|
-
const elapsedMs = Date.now() - startedAt;
|
|
1271
|
-
const readResult = await call("vice_cycles_stopwatch", { action: "read" });
|
|
1272
|
-
const cycles = cyclesFromStopwatchResult(readResult);
|
|
1273
|
-
return { cycles, elapsedMs };
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
function registersByteIdentical(a: unknown, b: unknown): boolean {
|
|
1277
|
-
try {
|
|
1278
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
1279
|
-
} catch {
|
|
1280
|
-
return false;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
interface BracketEvidence {
|
|
1285
|
-
regsBefore: unknown;
|
|
1286
|
-
regsAfter: unknown;
|
|
1287
|
-
bracket1: CycleBracketResult;
|
|
1288
|
-
bracket2: CycleBracketResult | null;
|
|
1289
|
-
finalBracket: CycleBracketResult;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
/**
|
|
1293
|
-
* Gathers the bracket evidence: a register snapshot at each end, bracket
|
|
1294
|
-
* one, and -- only when bracket one retired exactly zero cycles -- bracket
|
|
1295
|
-
* two. A non-zero first bracket short-circuits (D-04): the answer is already
|
|
1296
|
-
* not wedged, and a second resume buys nothing.
|
|
1297
|
-
*/
|
|
1298
|
-
async function gatherBracketEvidence(): Promise<BracketEvidence> {
|
|
1299
|
-
const regsBefore = await call("vice_registers_get", {});
|
|
1300
|
-
const bracket1 = await runCycleBracket();
|
|
1301
|
-
let bracket2: CycleBracketResult | null = null;
|
|
1302
|
-
let finalBracket = bracket1;
|
|
1303
|
-
if (bracket1.cycles === 0) {
|
|
1304
|
-
bracket2 = await runCycleBracket();
|
|
1305
|
-
finalBracket = bracket2;
|
|
1306
|
-
}
|
|
1307
|
-
const regsAfter = await call("vice_registers_get", {});
|
|
1308
|
-
return { regsBefore, regsAfter, bracket1, bracket2, finalBracket };
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
type LivenessVerdict = "wedged" | "stale_read_path" | "live";
|
|
1312
|
-
|
|
1313
|
-
/**
|
|
1314
|
-
* Produces the post-bracket verdict (criterion 2/3). Two consecutive zeros
|
|
1315
|
-
* is wedged and nothing else is. On any non-zero result (whichever bracket
|
|
1316
|
-
* produced it), a byte-identical register snapshot across an advancing
|
|
1317
|
-
* bracket is stale_read_path -- one read path is stale while the machine is
|
|
1318
|
-
* demonstrably not frozen; anything else is live.
|
|
1319
|
-
*/
|
|
1320
|
-
function classifyLiveness(evidence: BracketEvidence): LivenessVerdict {
|
|
1321
|
-
const { bracket1, bracket2, regsBefore, regsAfter } = evidence;
|
|
1322
|
-
if (bracket1.cycles === 0 && (!bracket2 || bracket2.cycles === 0)) {
|
|
1323
|
-
return "wedged";
|
|
1324
|
-
}
|
|
1325
|
-
return registersByteIdentical(regsBefore, regsAfter) ? "stale_read_path" : "live";
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
/**
|
|
1329
|
-
* Renders the post-bracket report (wedged/stale_read_path/live). Separates
|
|
1330
|
-
* load-bearing evidence (the restart epoch, already checked; the stopwatch
|
|
1331
|
-
* delta across the bracket) from corroborating evidence (the program
|
|
1332
|
-
* counter, VIC-II state, checkpoint hit counts, a screenshot) explicitly --
|
|
1333
|
-
* criterion 3's own requirement. A status of ok with an execution state of
|
|
1334
|
-
* running is compatible with every one of these verdicts and is therefore
|
|
1335
|
-
* evidence for none of them.
|
|
1336
|
-
*/
|
|
1337
|
-
function renderDiagnoseReport(evidence: BracketEvidence, verdict: LivenessVerdict): string {
|
|
1338
|
-
const { bracket1, bracket2, finalBracket } = evidence;
|
|
1339
|
-
const bracketsRun = bracket2 ? 2 : 1;
|
|
1340
|
-
const ratePerSecond =
|
|
1341
|
-
finalBracket.cycles > 0 ? Math.round((finalBracket.cycles / Math.max(finalBracket.elapsedMs, 1)) * 1000) : 0;
|
|
1342
|
-
|
|
1343
|
-
const lines = [
|
|
1344
|
-
`vice_diagnose verdict: ${verdict}`,
|
|
1345
|
-
"",
|
|
1346
|
-
"Load-bearing evidence: the restart epoch (already checked, at zero emulator cost) and the " +
|
|
1347
|
-
`stopwatch cycle delta across the bracket -- bracket 1 retired ${bracket1.cycles} cycles` +
|
|
1348
|
-
(bracket2 ? `, bracket 2 retired ${bracket2.cycles} cycles` : "") +
|
|
1349
|
-
` (${bracketsRun} bracket${bracketsRun > 1 ? "s" : ""} run, ${bracketsRun} resume call${bracketsRun > 1 ? "s" : ""}).`,
|
|
1350
|
-
"Corroborating evidence only, never load-bearing on its own: the program counter, VIC-II state, " +
|
|
1351
|
-
"checkpoint hit counts, and a screenshot. A status of ok with an execution state of running is " +
|
|
1352
|
-
"compatible with every one of these verdicts and is therefore evidence for none of them.",
|
|
1353
|
-
];
|
|
1354
|
-
|
|
1355
|
-
if (verdict !== "wedged") {
|
|
1356
|
-
lines.push(
|
|
1357
|
-
`Measured rate this call: ~${finalBracket.cycles} cycles in ~${finalBracket.elapsedMs}ms ` +
|
|
1358
|
-
`(~${ratePerSecond} cycles/s), beside the baseline ~${BASELINE_CYCLES_PER_SECOND} cycles/s ` +
|
|
1359
|
-
"(PAL C64 full speed) -- an observation, never a threshold, and never a verdict of its own."
|
|
1360
|
-
);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
if (verdict === "stale_read_path") {
|
|
1364
|
-
lines.push(
|
|
1365
|
-
"The register-read path returned a byte-identical snapshot across both ends of an advancing " +
|
|
1366
|
-
"bracket -- that read path is stale, but the machine is demonstrably not frozen."
|
|
1367
|
-
);
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
lines.push(
|
|
1371
|
-
verdict === "wedged"
|
|
1372
|
-
? "Machine state left: paused, after two zero-cycle brackets. Resuming is your own deliberate next call."
|
|
1373
|
-
: "Machine state left: paused, after the bracket that reached this verdict. Resuming is your own deliberate next call."
|
|
1374
|
-
);
|
|
1375
|
-
|
|
1376
|
-
return lines.join("\n");
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* Handles vice_diagnose. Fixed check order, and the order is the point
|
|
1381
|
-
* (D-14): first the epoch comparison (zero emulator calls), then the
|
|
1382
|
-
* checkpoint-trap check (no resume at all). Never throws past this point --
|
|
1383
|
-
* every branch is a well-formed isError:false or isError:true result.
|
|
1384
|
-
*/
|
|
1385
|
-
async function handleDiagnose(_args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
1386
|
-
try {
|
|
1387
|
-
const leaseResult = await ensureBrokerLease();
|
|
1388
|
-
if (!leaseResult.ok) {
|
|
1389
|
-
return isErrorText(leaseResult.message);
|
|
1390
|
-
}
|
|
1391
|
-
ensureViceSession();
|
|
1392
|
-
|
|
1393
|
-
const epochNow = currentEpoch();
|
|
1394
|
-
if (epochChanged(epochBaseline, epochNow)) {
|
|
1395
|
-
const before = (epochBaseline as EpochResult).epoch;
|
|
1396
|
-
epochBaseline = epochNow; // never cache a negative result (criterion 6)
|
|
1397
|
-
return { content: [{ type: "text", text: renderRestartedReport(before, epochNow.epoch) }], isError: false };
|
|
1398
|
-
}
|
|
1399
|
-
if (!(epochBaseline as EpochResult).present && epochNow.present) {
|
|
1400
|
-
epochBaseline = epochNow;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
const trapEvidence = await gatherCheckpointTrapEvidence();
|
|
1404
|
-
if (trapEvidence.isTrap) {
|
|
1405
|
-
return { content: [{ type: "text", text: renderCheckpointTrapReport(trapEvidence) }], isError: false };
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// Third and last: the cycle bracket, the definitive liveness test, drives
|
|
1409
|
-
// the three remaining verdicts (D-14's full order: epoch, trap, bracket).
|
|
1410
|
-
const bracketEvidence = await gatherBracketEvidence();
|
|
1411
|
-
const verdict = classifyLiveness(bracketEvidence);
|
|
1412
|
-
return { content: [{ type: "text", text: renderDiagnoseReport(bracketEvidence, verdict) }], isError: false };
|
|
1413
|
-
} catch (e) {
|
|
1414
|
-
if (e instanceof MachineRestartedError) {
|
|
1415
|
-
const current = currentEpoch();
|
|
1416
|
-
epochBaseline = current;
|
|
1417
|
-
return { content: [{ type: "text", text: renderRestartedReport(e.baselineEpoch, e.currentEpoch) }], isError: false };
|
|
1418
|
-
}
|
|
1419
|
-
return isErrorText(
|
|
1420
|
-
`vice_diagnose: an unexpected error occurred while gathering evidence: ${e && (e as Error).message ? (e as Error).message : e}`
|
|
1421
|
-
);
|
|
1422
|
-
}
|
|
660
|
+
// vice_recycle and vice_diagnose (below) are registered as this file's own
|
|
661
|
+
// proxy-local synthetic tools (RECYCLE_TOOL/DIAGNOSE_TOOL above). Before
|
|
662
|
+
// plan 52-04, each ALSO carried its own fork-only implementation here --
|
|
663
|
+
// evidence gathered over call()'s HTTP transport, its own incident-record
|
|
664
|
+
// writes -- reachable only on the (now-deleted) fork backend. The
|
|
665
|
+
// (already-active) stock arm never ran that body at all: it dispatched
|
|
666
|
+
// straight through stockDispatch.dispatchStock() to handleRecycleStock()/
|
|
667
|
+
// handleDiagnoseStock() (stock-recycle.ts/stock-diagnose.ts), which own a
|
|
668
|
+
// complete stock-native evidence gatherer and incident-record write of
|
|
669
|
+
// their own (built for exactly this reason -- see stock-recycle.ts's own
|
|
670
|
+
// header). That fork-only body is deleted, not merely emptied:
|
|
671
|
+
// handleRecycle()/handleDiagnose() SURVIVE as named functions --
|
|
672
|
+
// RECYCLE_TOOL/DIAGNOSE_TOOL's own registration still wires them in by name
|
|
673
|
+
// (a structural oracle in vice-proxy.test.ts asserts handleRecycle's own
|
|
674
|
+
// declaration form) -- but their bodies now do exactly what the stock arm
|
|
675
|
+
// already did, unconditionally, rather than re-deriving a second copy of
|
|
676
|
+
// stock-recycle.ts/stock-diagnose.ts's own logic here.
|
|
677
|
+
const handleRecycle: (args: Record<string, unknown>) => Promise<ToolCallResult> = async function handleRecycle(args) {
|
|
678
|
+
return dispatchStockFor(RECYCLE_TOOL.name, args);
|
|
1423
679
|
}
|
|
1424
680
|
|
|
1425
|
-
//
|
|
681
|
+
// ----------------------------------------------------------- vice_diagnose
|
|
1426
682
|
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
*
|
|
1440
|
-
* This is deliberately DIFFERENT from the project's standing prohibition on
|
|
1441
|
-
* WALL-CLOCK PACING (never sleep to wait for the emulated machine to reach
|
|
1442
|
-
* some state -- synchronise on checkpoint hits and cycle counts instead):
|
|
1443
|
-
* that rule governs synchronising INPUT/WAITS against the emulated game's
|
|
1444
|
-
* own state. This deadline governs a capture step's patience with the
|
|
1445
|
-
* TRANSPORT alone -- exactly the kind of deadline call()'s own
|
|
1446
|
-
* AbortSignal.timeout already applies per forwarded call, just bounding the
|
|
1447
|
-
* WHOLE step (which may issue several forwarded calls, e.g. the bracket) so
|
|
1448
|
-
* one non-answering read can never stall the whole gather, and the
|
|
1449
|
-
* snapshot attempt can never stall the recycle itself (D-19). Overridable
|
|
1450
|
-
* purely so this file's own test suite can exercise a "never answers"
|
|
1451
|
-
* fixture in milliseconds rather than minutes -- production always uses the
|
|
1452
|
-
* generous default.
|
|
1453
|
-
*/
|
|
1454
|
-
const CAPTURE_STEP_TIMEOUT_MS = Number(process.env.VICE_RECYCLE_CAPTURE_TIMEOUT_MS || 8000);
|
|
1455
|
-
|
|
1456
|
-
/** captureStep()'s own result shape -- structurally an EvidenceItem
|
|
1457
|
-
* (incident-record.ts), just narrowed to a discriminated union here so a
|
|
1458
|
-
* caller can branch on `available` without an optional-field guess. */
|
|
1459
|
-
type CaptureStepResult<T> = { available: true; value: T } | { available: false; reason: string };
|
|
1460
|
-
|
|
1461
|
-
/**
|
|
1462
|
-
* Runs one evidence-gathering step, turning any rejection, transport
|
|
1463
|
-
* failure or capture-step deadline into an explicit `{ available: false,
|
|
1464
|
-
* reason }` entry rather than letting it abort the whole gather -- the
|
|
1465
|
-
* whole point (D-17, D-19) is that a wedged machine will fail SOME of these
|
|
1466
|
-
* and the record must still exist. Never throws.
|
|
1467
|
-
*/
|
|
1468
|
-
async function captureStep<T>(fn: () => Promise<T>): Promise<CaptureStepResult<T>> {
|
|
1469
|
-
let timer: NodeJS.Timeout | undefined;
|
|
1470
|
-
try {
|
|
1471
|
-
const value = await Promise.race([
|
|
1472
|
-
fn(),
|
|
1473
|
-
new Promise<never>((_, reject) => {
|
|
1474
|
-
timer = setTimeout(
|
|
1475
|
-
() => reject(new Error(`capture step deadline of ${CAPTURE_STEP_TIMEOUT_MS}ms exceeded`)),
|
|
1476
|
-
CAPTURE_STEP_TIMEOUT_MS
|
|
1477
|
-
);
|
|
1478
|
-
}),
|
|
1479
|
-
]);
|
|
1480
|
-
return { available: true, value };
|
|
1481
|
-
} catch (e) {
|
|
1482
|
-
return { available: false, reason: e && (e as Error).message ? (e as Error).message : String(e) };
|
|
1483
|
-
} finally {
|
|
1484
|
-
clearTimeout(timer);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/**
|
|
1489
|
-
* Assembles criterion-4's evidence set for an incident record: one cycle
|
|
1490
|
-
* bracket (runCycleBracket(), plan 01.3-02 -- NEVER a second bracket
|
|
1491
|
-
* definition), the program counter and full register snapshot, the full
|
|
1492
|
-
* checkpoint enumeration (address, enabled flag, stop-or-continue), the
|
|
1493
|
-
* resolved live IRQ handler (resolveLiveIrqHandler(), plan 01.3-02), and a
|
|
1494
|
-
* screenshot written to a path in the incidents directory sharing the
|
|
1495
|
-
* record's own stem. Every step goes through captureStep() above, so no
|
|
1496
|
-
* step can abort the gather.
|
|
1497
|
-
*
|
|
1498
|
-
* `at`/`port`/`epoch` name the SAME triple the caller passes to
|
|
1499
|
-
* writeIncidentRecord(), so the screenshot's path shares that record's stem
|
|
1500
|
-
* (best-effort: the very rare case of a same-millisecond/port/epoch
|
|
1501
|
-
* collision forcing writeIncidentRecord() to append a numeric suffix onto
|
|
1502
|
-
* the actual .md file is not reflected here, since this path is computed
|
|
1503
|
-
* BEFORE that write happens).
|
|
1504
|
-
*/
|
|
1505
|
-
async function gatherWedgeEvidence({ at, port, epoch }: IncidentAssetStemOptions): Promise<IncidentEvidence> {
|
|
1506
|
-
const bracket = await captureStep(() => runCycleBracket());
|
|
1507
|
-
const registers = await captureStep(() => call("vice_registers_get", {}));
|
|
1508
|
-
const checkpoints = await captureStep(async () => {
|
|
1509
|
-
const result = await call("vice_checkpoint_list", {});
|
|
1510
|
-
const list: CheckpointInfo[] =
|
|
1511
|
-
isPlainObject(result) && Array.isArray(result.checkpoints) ? (result.checkpoints as CheckpointInfo[]) : [];
|
|
1512
|
-
return list.map((c) => ({
|
|
1513
|
-
checkpoint_num: c && c.checkpoint_num,
|
|
1514
|
-
address: formatAddress(toAddressNumber(c && c.start)),
|
|
1515
|
-
enabled: Boolean(c && c.enabled !== false),
|
|
1516
|
-
flag: c && c.stop ? "stop" : "continue",
|
|
1517
|
-
}));
|
|
1518
|
-
});
|
|
1519
|
-
const irqHandler = await captureStep(() => resolveLiveIrqHandler());
|
|
1520
|
-
|
|
1521
|
-
// The screenshot's path argument must be translated (T-01.3-11's sibling
|
|
1522
|
-
// concern): handleToolsCall() applies rewriteArguments() before
|
|
1523
|
-
// forwarding, and this proxy-local caller does NOT pass through that seam
|
|
1524
|
-
// -- so it is called explicitly here. Skipping this would write the file
|
|
1525
|
-
// to a host path that does not exist and return a success the record
|
|
1526
|
-
// would then be lying about.
|
|
1527
|
-
const screenshotContainerPath = incidentAssetPath({ at, port, epoch, ext: "png" });
|
|
1528
|
-
const screenshot = await captureStep(async () => {
|
|
1529
|
-
const { args: translated } = rewriteArguments({ path: screenshotContainerPath }, "vice_display_screenshot");
|
|
1530
|
-
await call("vice_display_screenshot", translated);
|
|
1531
|
-
return relative(repoRoot(), screenshotContainerPath);
|
|
1532
|
-
});
|
|
1533
|
-
|
|
1534
|
-
return { bracket, registers, checkpoints, irqHandler, screenshot };
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
/**
|
|
1538
|
-
* The best-effort pre-kill snapshot (plan 01.3-03 task 2, D-19): the LAST
|
|
1539
|
-
* capture step, run immediately before the incident record is written. It
|
|
1540
|
-
* takes a NAME, not a path -- vice_snapshot_save's own contract -- so the
|
|
1541
|
-
* file lands in the host emulator's own snapshot directory and nothing
|
|
1542
|
-
* container-side can confirm it landed there. The record therefore says
|
|
1543
|
-
* the ATTEMPT was accepted, never that a file was verified (T-01.3-11): the
|
|
1544
|
-
* wording must not overstate what was established. A rejection, a
|
|
1545
|
-
* transport failure or a capture-step deadline records unavailable with
|
|
1546
|
-
* the reason verbatim and moves on -- it cannot fail or stall the recycle.
|
|
1547
|
-
* The name is built from the SAME timestamp/port/epoch triple the incident
|
|
1548
|
-
* record's own stem uses, so the two artifacts are trivially correlated
|
|
1549
|
-
* later.
|
|
1550
|
-
*/
|
|
1551
|
-
async function captureSnapshotAttempt({
|
|
1552
|
-
at,
|
|
1553
|
-
port,
|
|
1554
|
-
epoch,
|
|
1555
|
-
}: IncidentAssetStemOptions): Promise<CaptureStepResult<{ name: string }>> {
|
|
1556
|
-
const name = incidentAssetStem({ at, port, epoch });
|
|
1557
|
-
return captureStep(async () => {
|
|
1558
|
-
await call("vice_snapshot_save", { name, description: "vice_recycle pre-kill evidence capture" });
|
|
1559
|
-
return { name };
|
|
1560
|
-
});
|
|
683
|
+
// See vice_recycle's own header comment immediately above: handleDiagnose()
|
|
684
|
+
// SURVIVES as a named function (DIAGNOSE_TOOL's own registration still wires
|
|
685
|
+
// it in by name) but its fork-only evidence-gathering body (the epoch/
|
|
686
|
+
// checkpoint-trap/cycle-bracket walk, all reached over call()'s HTTP
|
|
687
|
+
// transport) is deleted. The stock arm never ran that body -- it already
|
|
688
|
+
// dispatched straight through stockDispatch.dispatchStock() to
|
|
689
|
+
// handleDiagnoseStock() (stock-diagnose.ts), which owns a complete
|
|
690
|
+
// stock-native five-verdict diagnosis of its own. This is that delegation
|
|
691
|
+
// made unconditional, rather than a second copy of stock-diagnose.ts's own
|
|
692
|
+
// logic living here.
|
|
693
|
+
async function handleDiagnose(args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
694
|
+
return dispatchStockFor(DIAGNOSE_TOOL.name, args);
|
|
1561
695
|
}
|
|
1562
696
|
|
|
1563
697
|
// --------------------------------------------------- unreachable diagnostics
|
|
1564
698
|
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
699
|
+
// ONLY_ROUTE_NOTE and brokerHostPath() below are the shared vocabulary the
|
|
700
|
+
// broker-absent diagnostics family (immediately below) uses to name the one
|
|
701
|
+
// route back to a working emulator. The host-unreachable triple that used
|
|
702
|
+
// to live in this section (never-started/dead-or-hung/alive-but-failed,
|
|
703
|
+
// classifying a failed pre-flight liveness check over the fork's own HTTP
|
|
704
|
+
// transport) is deleted along with the fork-only generic forwarding
|
|
705
|
+
// function and its liveness-probe module: stock has no equivalent
|
|
706
|
+
// probe-then-classify step of its own, and stockDispatch's own
|
|
707
|
+
// session/lease handling reports unreachability through its own vocabulary
|
|
708
|
+
// instead.
|
|
1574
709
|
//
|
|
1575
710
|
// This MCP tool surface is the only route to the emulator -- never named
|
|
1576
711
|
// together with a CLI verb here, since plan 01.1-04 installs a durable gate
|
|
@@ -1580,22 +715,13 @@ const ONLY_ROUTE_NOTE =
|
|
|
1580
715
|
"the human to start it on the host -- falling back to a direct shell invocation of the underlying " +
|
|
1581
716
|
"transport is not an available workaround.";
|
|
1582
717
|
|
|
1583
|
-
// supervisorHostPath() (the per-instance-supervisor host-path helper) is
|
|
1584
|
-
// GONE, not merely unused (01.6.2-09, T-01.6.2-54/T-01.6.2-59): its three
|
|
1585
|
-
// former consumers below -- neverStartedMessage(), deadOrHungMessage() and
|
|
1586
|
-
// aliveButFailedMessage() -- now resolve brokerHostPath() instead, the SAME
|
|
1587
|
-
// single helper the broker-absent triple already used. There is exactly one
|
|
1588
|
-
// host-path helper left in this file (a structural test in
|
|
1589
|
-
// vice-proxy.test.ts asserts that directly: the resolved path's basename
|
|
1590
|
-
// equals the surviving launcher's filename).
|
|
1591
|
-
|
|
1592
718
|
/** The absolute path of the command a human should run on the HOST to
|
|
1593
719
|
* start/restart access to the emulator -- computed via hostPath() over the
|
|
1594
720
|
* deployed launcher's container path, degrading to the container path plus
|
|
1595
721
|
* SET_ENV_HINT exactly as install-resources.ts's hostLaunchInstructions()
|
|
1596
722
|
* does, so a translation failure still yields something to act on rather
|
|
1597
723
|
* than an empty message. Recomputed fresh every call -- never cached (see
|
|
1598
|
-
* the never-cache-a-negative-result invariant above
|
|
724
|
+
* the never-cache-a-negative-result invariant above, near tools/call).
|
|
1599
725
|
* Points at resources/vice-launcher.sh's deployed copy -- the one surviving
|
|
1600
726
|
* host script (01.6.2-09). Every message in this file that used to name
|
|
1601
727
|
* either the retiring per-instance supervisor (vice-supervisor.sh) or the
|
|
@@ -1604,7 +730,13 @@ const ONLY_ROUTE_NOTE =
|
|
|
1604
730
|
* the launch/supervise/respawn-with-backoff job the bash supervisor did. */
|
|
1605
731
|
function brokerHostPath(): string {
|
|
1606
732
|
const root = repoRoot();
|
|
1607
|
-
|
|
733
|
+
// Moved 2026-09-08 (D-33): was join(root, "tools", "vice-launcher.sh"),
|
|
734
|
+
// matching installTargetDir()'s pre-consolidation value. Now derived from
|
|
735
|
+
// repo-root.ts's own toolsDir() -- this module is container-side, unlike
|
|
736
|
+
// install-resources.ts, so it CAN import that resolver directly rather
|
|
737
|
+
// than joining the literal a second time -- matching installTargetDir()'s
|
|
738
|
+
// new `<root>/.c64-re-tools/bin` value exactly.
|
|
739
|
+
const target = join(toolsDir(), "bin", "vice-launcher.sh");
|
|
1608
740
|
try {
|
|
1609
741
|
return hostPath(target, { workspaceRoot: root });
|
|
1610
742
|
} catch {
|
|
@@ -1615,18 +747,11 @@ function brokerHostPath(): string {
|
|
|
1615
747
|
// ------------------------------------------------- broker-absent diagnostics
|
|
1616
748
|
//
|
|
1617
749
|
// Plan 01.2-03 task 1 / must_have C10. A missing broker answers exactly one
|
|
1618
|
-
// generic message two times out of three sends the reader to the wrong fix
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
// replacing the other. Every message here quotes brokerHostPath() (an
|
|
1624
|
-
// absolute HOST path, recomputed fresh -- see that function's own comment)
|
|
1625
|
-
// and the single shared ONLY_ROUTE_NOTE definition; no message below writes
|
|
1626
|
-
// its own second only-route sentence. As of 01.6.2-09, the host-unreachable
|
|
1627
|
-
// triple below quotes the exact same brokerHostPath() helper -- there is
|
|
1628
|
-
// only one surviving launcher left to name, so both triples now resolve
|
|
1629
|
-
// identically rather than two different paths.
|
|
750
|
+
// generic message two times out of three sends the reader to the wrong fix.
|
|
751
|
+
// Every message here quotes brokerHostPath() (an absolute HOST path,
|
|
752
|
+
// recomputed fresh -- see that function's own comment) and the single
|
|
753
|
+
// shared ONLY_ROUTE_NOTE definition; no message below writes its own second
|
|
754
|
+
// only-route sentence.
|
|
1630
755
|
|
|
1631
756
|
/** State: readBrokerLiveness() found no broker.json at all -- the broker has
|
|
1632
757
|
* never been started on this host. Nothing on the other side would ever
|
|
@@ -1643,7 +768,7 @@ function brokerNeverStartedMessage(): string {
|
|
|
1643
768
|
/** State: broker.json exists but its heartbeat is older than the stale
|
|
1644
769
|
* threshold -- the broker process is dead or hung. Quotes the recorded pid
|
|
1645
770
|
* (readBrokerLiveness()'s own field), since checking that pid is the first
|
|
1646
|
-
* thing a human does on the host
|
|
771
|
+
* thing a human does on the host. */
|
|
1647
772
|
function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
|
|
1648
773
|
const pidNote = liveness && liveness.pid != null ? ` (pid ${liveness.pid})` : "";
|
|
1649
774
|
return (
|
|
@@ -1657,12 +782,10 @@ function brokerDeadOrHungMessage(liveness: BrokerLivenessResult): string {
|
|
|
1657
782
|
/** State: the broker is alive and a request was polled, but it wrote a
|
|
1658
783
|
* denial rather than a grant. Relays the denial's own `reason` field
|
|
1659
784
|
* VERBATIM -- never paraphrased -- and deliberately carries no RESTART
|
|
1660
|
-
* instruction
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
* the only-route sentence, both required of every broker-absent-adjacent
|
|
1665
|
-
* message this proxy emits. */
|
|
785
|
+
* instruction: restarting something that is answering correctly is the
|
|
786
|
+
* wrong fix. Still names an absolute path (the running broker's own
|
|
787
|
+
* launcher, purely as a reference) and the only-route sentence, both
|
|
788
|
+
* required of every broker-absent-adjacent message this proxy emits. */
|
|
1666
789
|
function brokerLaunchFailedMessage(reason: string): string {
|
|
1667
790
|
const hostRef = brokerHostPath().split("\n")[0];
|
|
1668
791
|
return (
|
|
@@ -1734,314 +857,44 @@ function brokerControlUnreachableMessage(opened: { kind: ControlFailureKind; mes
|
|
|
1734
857
|
// the "orphan request the sweeper must reap" problem this helper solved
|
|
1735
858
|
// does not exist in this design.
|
|
1736
859
|
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1739
|
-
// see its own fallback `causeCode || e.message`. A timeout, an HTTP error
|
|
1740
|
-
// status, or "didn't decode to a recognisable ping" all produce prose
|
|
1741
|
-
// instead, never a bare all-caps E-code, which is what keeps this predicate
|
|
1742
|
-
// precise rather than a loose substring guess.
|
|
1743
|
-
function isConnectionRefusedReason(reason: unknown): boolean {
|
|
1744
|
-
return typeof reason === "string" && /^E[A-Z]+$/.test(reason);
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
function neverStartedMessage(probe: ProbeResult): string {
|
|
1748
|
-
return (
|
|
1749
|
-
`vice: the host VICE MCP server has never been started at this configured path -- no ` +
|
|
1750
|
-
`restart-epoch record exists, and the connection was refused (${probe.reason}). Start it on the host with:\n` +
|
|
1751
|
-
` ${brokerHostPath()}\n` +
|
|
1752
|
-
ONLY_ROUTE_NOTE
|
|
1753
|
-
);
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
function deadOrHungMessage(probe: ProbeResult, epoch: EpochResult): string {
|
|
1757
|
-
const pidNote =
|
|
1758
|
-
epoch && epoch.present && epoch.pid != null
|
|
1759
|
-
? ` (pid ${epoch.pid}${epoch.spawned_at ? `, spawned_at ${epoch.spawned_at}` : ""})`
|
|
1760
|
-
: "";
|
|
1761
|
-
return (
|
|
1762
|
-
`vice: the host VICE MCP server appears to be dead or hung${pidNote} -- ${probe.reason}. ` +
|
|
1763
|
-
`Restart it on the host with:\n` +
|
|
1764
|
-
` ${brokerHostPath()}\n` +
|
|
1765
|
-
ONLY_ROUTE_NOTE
|
|
1766
|
-
);
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
/** Reached only when the pre-flight probe found the host alive but the
|
|
1770
|
-
* forwarded call itself failed (a transport error the retry ladder gave up
|
|
1771
|
-
* on, or a genuine RPC error). Relays the host's own message VERBATIM --
|
|
1772
|
-
* never paraphrased -- and deliberately carries no restart instruction,
|
|
1773
|
-
* since restarting a live, correctly-answering host is the wrong fix for a
|
|
1774
|
-
* rejected tool call. Still names an absolute path and the only-route note
|
|
1775
|
-
* (both required of every unreachable-adjacent message this proxy emits),
|
|
1776
|
-
* worded so as never to suggest the action a restart message would. */
|
|
1777
|
-
function aliveButFailedMessage(errMessage: string): string {
|
|
1778
|
-
const hostRef = brokerHostPath().split("\n")[0];
|
|
1779
|
-
return (
|
|
1780
|
-
`vice: the host VICE MCP server (reachable via the host-side launcher at ${hostRef}) rejected ` +
|
|
1781
|
-
`this call: ${errMessage} ${ONLY_ROUTE_NOTE}`
|
|
1782
|
-
);
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
// RESOLVED RESIDUAL (originally quick-260801-ccn task 3; re-examined by
|
|
1786
|
-
// 01.6.2-09, T-01.6.2-54/T-01.6.2-59): this comment used to record that
|
|
1787
|
-
// aliveButFailedMessage() above still named a SEPARATE per-instance
|
|
1788
|
-
// supervisor path even under a broker-granted session -- a genuine
|
|
1789
|
-
// mismatch, because two different launchers existed. That mismatch is
|
|
1790
|
-
// dissolved, not merely reworded: supervisorHostPath() is deleted, and
|
|
1791
|
-
// aliveButFailedMessage() now resolves the exact same brokerHostPath()
|
|
1792
|
-
// helper every other message in this file uses, so there is only ever one
|
|
1793
|
-
// launcher path to name, regardless of session type. What still holds,
|
|
1794
|
-
// unchanged, is the REASON this message carries no restart instruction: it
|
|
1795
|
-
// answers a different question from both the host-unreachable triple and
|
|
1796
|
-
// the broker-granted message below -- an instance that IS reachable and
|
|
1797
|
-
// answering rejected ONE call -- where no launcher is the fix and a
|
|
1798
|
-
// restart would be the wrong advice on either route (broker-granted or
|
|
1799
|
-
// fixed-port).
|
|
1800
|
-
|
|
1801
|
-
// ------------------------------------------- broker-granted unreachable diagnostics
|
|
1802
|
-
//
|
|
1803
|
-
// Quick task 260801-ccn task 3 (D-5) introduced ONE message here, distinct
|
|
1804
|
-
// from both the host-unreachable triple above and the broker-ABSENT triple
|
|
1805
|
-
// below, for a granted instance that stopped answering: report the fact and
|
|
1806
|
-
// tell a human to go investigate on the host.
|
|
1807
|
-
//
|
|
1808
|
-
// Plan 01.6.2-08 (D-13) turns that report-and-instruct message into a
|
|
1809
|
-
// replace-and-report: a granted instance not answering no longer waits for
|
|
1810
|
-
// a human -- it costs this session exactly one replacement acquisition,
|
|
1811
|
-
// made automatically, and the triggering call still fails LOUDLY (never a
|
|
1812
|
-
// silently substituted result) naming the replacement. See
|
|
1813
|
-
// handleGrantedInstanceUnreachable() and its own three message builders
|
|
860
|
+
// broker-granted unreachable diagnostics: GONE, not merely unused.
|
|
861
|
+
// handleGrantedInstanceUnreachable() and its three message builders
|
|
1814
862
|
// (machineReplacedMessage()/replacementFailedMessage()/
|
|
1815
|
-
// sessionMustRestartMessage())
|
|
1816
|
-
//
|
|
1817
|
-
//
|
|
1818
|
-
//
|
|
1819
|
-
// own
|
|
863
|
+
// sessionMustRestartMessage()) existed to replace-and-report when a granted
|
|
864
|
+
// instance stopped answering the fork-only generic forwarding function's own
|
|
865
|
+
// pre-flight liveness check -- their only caller. Deleted along with that
|
|
866
|
+
// forwarding function; stock has no equivalent probe-then-replace step at
|
|
867
|
+
// this proxy layer, and a dead lease surfaces through stockDispatch's own
|
|
868
|
+
// error handling instead.
|
|
1820
869
|
|
|
1821
870
|
// ------------------------------------------------------------ path rewriting
|
|
1822
871
|
//
|
|
1823
|
-
//
|
|
1824
|
-
// translation
|
|
1825
|
-
//
|
|
1826
|
-
//
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
872
|
+
// isInsideWorkspace() below is the ONE survivor of what used to be a larger
|
|
873
|
+
// container->host path-translation seam here (decision D-G, plan 01.1-03
|
|
874
|
+
// task 3): the per-call argument path-rewriter and its recursive value
|
|
875
|
+
// walker, along with their own refusal classes
|
|
876
|
+
// (PathOutOfWorkspaceError/PathTranslationError), were the fork-only
|
|
877
|
+
// per-forwarded-call rewriter the fork-only generic forwarding function ran
|
|
878
|
+
// before delegating to the fork transport's own dispatch call -- deleted
|
|
879
|
+
// along with it. isInsideWorkspace() itself SURVIVES
|
|
880
|
+
// because it has a second, backend-agnostic consumer: containerizeGrant()
|
|
881
|
+
// (further down this file) re-checks a broker grant's translated
|
|
882
|
+
// epoch_file/supervisor_dir fields against the workspace boundary before
|
|
883
|
+
// trusting them. Stock's OWN emulator-side path translation
|
|
884
|
+
// (stock-paths.ts's withEmulatorSidePath()/STOCK_EMULATOR_SIDE_PATH_TOOLS)
|
|
885
|
+
// is a separate, still-untouched mechanism -- see that file's own header.
|
|
1837
886
|
//
|
|
1838
|
-
//
|
|
1839
|
-
//
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
// ("pass container paths and let the tools handle the boundary") promises
|
|
1846
|
-
// the opposite, so callers reasonably passed "disks/foo.d64" and got a bare
|
|
1847
|
-
// "Failed to attach disk image" from the host, with nothing anywhere
|
|
1848
|
-
// indicating the path was the problem. That cost real session time.
|
|
1849
|
-
//
|
|
1850
|
-
// The premise is also no longer true. tools-manifest.json -- the same file
|
|
1851
|
-
// tools/list is served from -- types every argument, and exactly four
|
|
1852
|
-
// declare a path: vice_disk_attach.path, vice_autostart.path,
|
|
1853
|
-
// vice_display_screenshot.path and vice_symbols_load.path. Consulting it
|
|
1854
|
-
// removes the guessing the residual was protecting against: a relative
|
|
1855
|
-
// string in a DECLARED path argument is a path, full stop, and everything
|
|
1856
|
-
// else keeps the byte-identical pass-through unchanged.
|
|
1857
|
-
//
|
|
1858
|
-
// Resolution is against the workspace root, never process.cwd() -- the
|
|
1859
|
-
// proxy is one long-lived process serving the whole session, so its cwd is
|
|
1860
|
-
// meaningless to the caller. (hostpath.mjs:106 resolves against cwd for its
|
|
1861
|
-
// CLI's benefit; that branch is unreachable from here, and deliberately so.)
|
|
1862
|
-
//
|
|
1863
|
-
// STATED RESIDUAL, narrower than before: a relative string in an argument
|
|
1864
|
-
// the manifest does NOT declare as a path is still left byte-identical, and
|
|
1865
|
-
// so is a relative string nested inside an object or array. Both remain
|
|
1866
|
-
// indistinguishable from non-path data. A worktree caller also resolves
|
|
1867
|
-
// against the MAIN workspace root, not its worktree -- correct for the
|
|
1868
|
-
// read-only disk images this serves, and an absolute path still overrides.
|
|
1869
|
-
const PATH_REWRITE_MAX_DEPTH = 10; // bounded so pathological nesting is left alone rather than looping forever
|
|
1870
|
-
|
|
1871
|
-
class PathOutOfWorkspaceError extends Error {}
|
|
1872
|
-
class PathTranslationError extends Error {}
|
|
1873
|
-
|
|
1874
|
-
// The boundary check MUST run against a normalized path, never the raw
|
|
1875
|
-
// string. `startsWith(root)` on an unnormalized value is satisfied by any
|
|
1876
|
-
// string that merely begins with the root's characters, so a lexical `..`
|
|
1877
|
-
// sequence -- "/workspaces/c64-project/../../../etc/passwd" -- passes a raw
|
|
1878
|
-
// prefix test and is then handed to hostPath(), which does NOT refuse it:
|
|
1879
|
-
// when relative() normalizes to a leading "..", hostpath.mjs deliberately
|
|
1880
|
-
// falls through to generic mount-based translation instead of throwing (its
|
|
1881
|
-
// own comment says so, for the CLI's benefit). That makes THIS check the only
|
|
1882
|
-
// workspace boundary on the forwarding path, so it has to be the strict one.
|
|
1883
|
-
//
|
|
1884
|
-
// resolve() collapses "." and ".." segments; callers only reach here after
|
|
1885
|
-
// value.startsWith("/") is confirmed, so it is pure normalization and never
|
|
1886
|
-
// pulls in process.cwd().
|
|
1887
|
-
//
|
|
1888
|
-
// STATED RESIDUAL: this is lexical, not physical -- a symlink inside the
|
|
1889
|
-
// workspace whose target lives outside it still translates. realpathSync()
|
|
1890
|
-
// would catch that but requires the file to already exist, which is wrong for
|
|
1891
|
-
// the write-side tools (snapshot_save and friends name a path that does not
|
|
1892
|
-
// exist yet). Lexical normalization is the part that can be enforced for both
|
|
1893
|
-
// directions without breaking writes.
|
|
887
|
+
// STATED RESIDUAL, unchanged from before this deletion: this check is
|
|
888
|
+
// lexical, not physical -- a symlink inside the workspace whose target lives
|
|
889
|
+
// outside it still translates. realpathSync() would catch that but requires
|
|
890
|
+
// the file to already exist, which is wrong for the write-side tools
|
|
891
|
+
// (snapshot_save and friends name a path that does not exist yet). Lexical
|
|
892
|
+
// normalization is the part that can be enforced for both directions
|
|
893
|
+
// without breaking writes.
|
|
1894
894
|
function isInsideWorkspace(absPath: string, root: string): boolean {
|
|
1895
895
|
return absPath === root || absPath.startsWith(root.endsWith("/") ? root : root + "/");
|
|
1896
896
|
}
|
|
1897
897
|
|
|
1898
|
-
/**
|
|
1899
|
-
* Recursively walk `value`, applying decision D-G's structural rule to
|
|
1900
|
-
* every string found. Objects and arrays are walked (bounded by
|
|
1901
|
-
* PATH_REWRITE_MAX_DEPTH); numbers, booleans, null, and non-absolute
|
|
1902
|
-
* strings are returned byte-identical. `argPath` accumulates a
|
|
1903
|
-
* human-readable position (e.g. "arguments.path" or "arguments.files[2]")
|
|
1904
|
-
* used in a refusal message so the caller can find exactly which argument
|
|
1905
|
-
* was the problem.
|
|
1906
|
-
*/
|
|
1907
|
-
function rewritePathsIn(value: unknown, argPath: string, root: string, depth: number, asWritten?: string): unknown {
|
|
1908
|
-
if (depth > PATH_REWRITE_MAX_DEPTH) return value;
|
|
1909
|
-
if (typeof value === "string") {
|
|
1910
|
-
if (!value.startsWith("/")) return value; // the stated residual: undeclared relative strings untouched
|
|
1911
|
-
// Normalize FIRST, then check, then translate the normalized form -- so a
|
|
1912
|
-
// path that only looks like it is inside the workspace cannot slip through,
|
|
1913
|
-
// and the host is never handed a path still carrying ".." segments.
|
|
1914
|
-
const normalized = resolve(value);
|
|
1915
|
-
// `asWritten` is set only when rewriteArguments() already resolved a
|
|
1916
|
-
// declared-path argument from a relative string. Quoting the resolved
|
|
1917
|
-
// form alone would show the caller a path they never typed, so BOTH
|
|
1918
|
-
// failure branches below name what they wrote and what it became.
|
|
1919
|
-
const escapedRelative = asWritten !== undefined && asWritten !== value;
|
|
1920
|
-
if (!isInsideWorkspace(normalized, root)) {
|
|
1921
|
-
throw new PathOutOfWorkspaceError(
|
|
1922
|
-
(escapedRelative
|
|
1923
|
-
? `vice: ${argPath} is the relative path "${asWritten}", which resolves to ${normalized} -- ` +
|
|
1924
|
-
`outside the mounted workspace (${root})`
|
|
1925
|
-
: `vice: ${argPath} is an absolute path (${value}) outside the mounted workspace (${root})` +
|
|
1926
|
-
(normalized === value ? "" : `; it resolves to ${normalized}`)) +
|
|
1927
|
-
`. The host emulator can only be handed paths that live inside the mounted workspace -- move the ` +
|
|
1928
|
-
`artifact inside the workspace and call again.`
|
|
1929
|
-
);
|
|
1930
|
-
}
|
|
1931
|
-
try {
|
|
1932
|
-
return hostPath(normalized, { workspaceRoot: root });
|
|
1933
|
-
} catch (e) {
|
|
1934
|
-
// Name what the CALLER wrote first, and the container path it became --
|
|
1935
|
-
// never lead with the host path. The caller reasons in container terms
|
|
1936
|
-
// and cannot act on a host-side location, so quoting only the resolved
|
|
1937
|
-
// form makes a fixable mistake look like an emulator fault.
|
|
1938
|
-
throw new PathTranslationError(
|
|
1939
|
-
`vice: ${argPath} ` +
|
|
1940
|
-
(escapedRelative ? `("${asWritten}", which resolves to ${normalized})` : `(${value})`) +
|
|
1941
|
-
` could not be translated to a host path: ${(e as Error).message}\n ${SET_ENV_HINT}`
|
|
1942
|
-
);
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
if (Array.isArray(value)) {
|
|
1946
|
-
return value.map((v, i) => rewritePathsIn(v, `${argPath}[${i}]`, root, depth + 1));
|
|
1947
|
-
}
|
|
1948
|
-
if (value && typeof value === "object") {
|
|
1949
|
-
const out: Record<string, unknown> = {};
|
|
1950
|
-
for (const [k, v] of Object.entries(value)) {
|
|
1951
|
-
out[k] = rewritePathsIn(v, `${argPath}.${k}`, root, depth + 1);
|
|
1952
|
-
}
|
|
1953
|
-
return out;
|
|
1954
|
-
}
|
|
1955
|
-
return value; // numbers, booleans, null -- byte-identical, never touched
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
|
-
const NO_PATH_ARGS: Set<string> = new Set();
|
|
1959
|
-
let PATH_ARGS_BY_TOOL: Map<string, Set<string>> | null = null; // built once per process, from the manifest
|
|
1960
|
-
|
|
1961
|
-
/**
|
|
1962
|
-
* The set of argument names `toolName` declares to be filesystem paths,
|
|
1963
|
-
* read off tools-manifest.json -- the SAME file tools/list is served from,
|
|
1964
|
-
* so this can never become a second, drifting copy of "which arguments are
|
|
1965
|
-
* paths". An argument qualifies when it is declared `type: "string"` and
|
|
1966
|
-
* either is named exactly `path` or opens its description with "Path to" /
|
|
1967
|
-
* "File path" (both tests agree on all four current cases; either alone
|
|
1968
|
-
* would also suffice, and keeping both means a future manifest entry that
|
|
1969
|
-
* satisfies only one is still caught).
|
|
1970
|
-
*
|
|
1971
|
-
* Deliberately name/description-driven rather than a hardcoded tool list:
|
|
1972
|
-
* a manifest refresh that adds a path-taking tool gets the behaviour for
|
|
1973
|
-
* free, which a literal list here would silently miss.
|
|
1974
|
-
*/
|
|
1975
|
-
function pathArgsFor(toolName: string): Set<string> {
|
|
1976
|
-
if (!PATH_ARGS_BY_TOOL) {
|
|
1977
|
-
PATH_ARGS_BY_TOOL = new Map();
|
|
1978
|
-
for (const t of readManifestTools()) {
|
|
1979
|
-
const props = isPlainObject(t.inputSchema) ? (t.inputSchema.properties as unknown) : undefined;
|
|
1980
|
-
if (!props || typeof props !== "object") continue;
|
|
1981
|
-
const names = new Set<string>();
|
|
1982
|
-
for (const [k, v] of Object.entries(props as Record<string, unknown>)) {
|
|
1983
|
-
if (!isPlainObject(v) || v.type !== "string") continue;
|
|
1984
|
-
if (k === "path" || /^(path|file path)\b/i.test((v.description as string) || "")) names.add(k);
|
|
1985
|
-
}
|
|
1986
|
-
if (names.size) PATH_ARGS_BY_TOOL.set(t.name, names);
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
return PATH_ARGS_BY_TOOL.get(toolName) || NO_PATH_ARGS;
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
/** One `arguments.<key>` resolved from a relative, manifest-declared path
|
|
1993
|
-
* argument to its absolute container form, before hostPath() translation --
|
|
1994
|
-
* the record resolutionNote() below renders for the agent. */
|
|
1995
|
-
interface PathResolution {
|
|
1996
|
-
arg: string;
|
|
1997
|
-
asWritten: string;
|
|
1998
|
-
container: string;
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
/** Rewrite every in-workspace path inside `args` to its host form. A relative
|
|
2002
|
-
* string in a manifest-declared path argument is resolved against the
|
|
2003
|
-
* workspace root first; everything else keeps the byte-identical
|
|
2004
|
-
* pass-through. Throws PathOutOfWorkspaceError / PathTranslationError on the
|
|
2005
|
-
* two refusal cases above; the caller (handleToolsCall) converts either into
|
|
2006
|
-
* an isError:true result rather than letting it escape. */
|
|
2007
|
-
function rewriteArguments(
|
|
2008
|
-
args: Record<string, unknown> | undefined,
|
|
2009
|
-
toolName: string
|
|
2010
|
-
): { args: Record<string, unknown>; resolutions: PathResolution[] } {
|
|
2011
|
-
const root = repoRoot();
|
|
2012
|
-
const pathArgs = pathArgsFor(toolName);
|
|
2013
|
-
const out: Record<string, unknown> = {};
|
|
2014
|
-
const resolutions: PathResolution[] = [];
|
|
2015
|
-
for (const [k, v] of Object.entries(args || {})) {
|
|
2016
|
-
// Only a top-level, declared-path, non-empty relative string is resolved.
|
|
2017
|
-
// Empty stays empty (resolve() would silently turn "" into the workspace
|
|
2018
|
-
// root, i.e. a directory, which is never what a caller meant).
|
|
2019
|
-
if (pathArgs.has(k) && typeof v === "string" && v !== "" && !v.startsWith("/")) {
|
|
2020
|
-
const container = resolve(root, v);
|
|
2021
|
-
out[k] = rewritePathsIn(container, `arguments.${k}`, root, 1, v);
|
|
2022
|
-
resolutions.push({ arg: k, asWritten: v, container });
|
|
2023
|
-
} else {
|
|
2024
|
-
out[k] = rewritePathsIn(v, `arguments.${k}`, root, 1);
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
return { args: out, resolutions };
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
/**
|
|
2031
|
-
* One line naming, in full, every relative path this call resolved -- so the
|
|
2032
|
-
* absolute path actually handed to the emulator is never something the caller
|
|
2033
|
-
* has to infer. Returned to the AGENT, not just stderr: the failure this
|
|
2034
|
-
* prevents ("Failed to attach disk image", with no indication which file was
|
|
2035
|
-
* even attempted) is one the agent has to diagnose, and it cost a real session
|
|
2036
|
-
* before the resolution existed at all. Empty string when nothing was resolved,
|
|
2037
|
-
* so a call that passed absolute paths reads exactly as it always did.
|
|
2038
|
-
*/
|
|
2039
|
-
function resolutionNote(resolutions: PathResolution[] | undefined): string {
|
|
2040
|
-
if (!resolutions || !resolutions.length) return "";
|
|
2041
|
-
const parts = resolutions.map((r) => `${r.arg}: "${r.asWritten}" -> ${r.container}`);
|
|
2042
|
-
return `vice: resolved relative path${resolutions.length > 1 ? "s" : ""} against the workspace root -- ${parts.join("; ")}`;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
898
|
// ------------------------------------------------------- oversized results
|
|
2046
899
|
//
|
|
2047
900
|
// Decision D-E: the `_meta["anthropic/maxResultSizeChars"]` declaration
|
|
@@ -2183,6 +1036,14 @@ function handleResultContinue(args: Record<string, unknown>): ToolCallResult {
|
|
|
2183
1036
|
// instance down.
|
|
2184
1037
|
let controlSession: BrokerControlSession | null = null;
|
|
2185
1038
|
let grantId: string | null = null;
|
|
1039
|
+
// Plan 41-01 (D-15): THIS session's own text-monitor port, stashed by
|
|
1040
|
+
// adoptGrant() beside grantId -- never memoised anywhere else. `null` means
|
|
1041
|
+
// either no grant has been adopted yet, this is a fork instance (which never
|
|
1042
|
+
// carries one), or the observed wire value failed validation (see
|
|
1043
|
+
// adoptGrant()'s own stderr warning for that last case). Read fresh by
|
|
1044
|
+
// buildHeldLease() on every call, exactly like activeInstance() and grantId
|
|
1045
|
+
// above it -- never cached past a replacement acquisition.
|
|
1046
|
+
let grantRemoteMonitorPort: number | null = null;
|
|
2186
1047
|
|
|
2187
1048
|
// ----------------------------------------------------- grant containerization
|
|
2188
1049
|
//
|
|
@@ -2352,11 +1213,23 @@ function buildHeldLease(session: BrokerControlSession): HeldLease {
|
|
|
2352
1213
|
// fresh from activeInstance() like every other field here (adoptGrant()
|
|
2353
1214
|
// put the CONTAINERIZED path there, so it is already in this process's
|
|
2354
1215
|
// view of the filesystem -- no second translation here).
|
|
2355
|
-
// - supervisorDir is the TOP-LEVEL `.
|
|
1216
|
+
// - supervisorDir is the TOP-LEVEL `.c64-re-tools/supervisor`, where backend.json
|
|
2356
1217
|
// lives, resolved through brokerRootDir() -- the SAME resolver
|
|
2357
1218
|
// broker.json is read from, never a locally re-derived path (the
|
|
2358
1219
|
// "re-deriving a cross-cutting seam locally" anti-pattern).
|
|
2359
|
-
return {
|
|
1220
|
+
return {
|
|
1221
|
+
host,
|
|
1222
|
+
port,
|
|
1223
|
+
targetId: grantId ?? "",
|
|
1224
|
+
brokerControl: session,
|
|
1225
|
+
epochFile,
|
|
1226
|
+
supervisorDir: brokerRootDir(),
|
|
1227
|
+
// Plan 41-01 (D-15): read fresh off the module-level variable
|
|
1228
|
+
// adoptGrant() stashed, exactly like every other field here -- `null`
|
|
1229
|
+
// becomes `undefined` on the lease (HeldLease.remoteMonitorPort is
|
|
1230
|
+
// optional; `null` is not a value that type carries).
|
|
1231
|
+
...(grantRemoteMonitorPort === null ? {} : { remoteMonitorPort: grantRemoteMonitorPort }),
|
|
1232
|
+
};
|
|
2360
1233
|
}
|
|
2361
1234
|
|
|
2362
1235
|
async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
@@ -2370,9 +1243,9 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
|
2370
1243
|
// re-reads broker.json fresh on every call (see its own implementation in
|
|
2371
1244
|
// vice-broker-client.ts); nothing here memoises the verdict, so this is the
|
|
2372
1245
|
// broker-path instance of the same never-cache-a-negative-result invariant
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
//
|
|
1246
|
+
// stated near tools/call above -- the call after a human starts the
|
|
1247
|
+
// broker just works, with no session restart required. openBrokerControl()
|
|
1248
|
+
// performs this SAME classification
|
|
2376
1249
|
// again internally (over its own read of broker.json) before it ever
|
|
2377
1250
|
// connects -- a second, independent read, not a second answer to trust
|
|
2378
1251
|
// instead of this one; fetching liveness here first is what gives the
|
|
@@ -2410,57 +1283,20 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
|
2410
1283
|
}
|
|
2411
1284
|
const session = opened.session;
|
|
2412
1285
|
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
//
|
|
2425
|
-
//
|
|
2426
|
-
//
|
|
2427
|
-
// failure on the first real tool call. D-01's "one reader" property holds per
|
|
2428
|
-
// PROCESS but not across this pair, and this is the seam where the pair first
|
|
2429
|
-
// meets.
|
|
2430
|
-
//
|
|
2431
|
-
// Refusing (rather than adapting) is deliberate: the advertised tool list was
|
|
2432
|
-
// already built at startup from ACTIVE_BACKEND and answered to the client, so
|
|
2433
|
-
// this process cannot re-decide its own surface here. VICE_BACKEND remains the
|
|
2434
|
-
// explicit fix, and it must be set for BOTH processes.
|
|
2435
|
-
//
|
|
2436
|
-
// Absent evidence is NOT disagreement: a broker that does not report a backend
|
|
2437
|
-
// (older build, or an unrecognised value) leaves `backend: null`, and a
|
|
2438
|
-
// hostState() call that fails at all is not allowed to block an acquire. Only
|
|
2439
|
-
// a definite, named mismatch refuses.
|
|
2440
|
-
const brokerState = await session.hostState();
|
|
2441
|
-
if (brokerState.ok && brokerState.hostState.backend !== null && brokerState.hostState.backend !== ACTIVE_BACKEND.backend) {
|
|
2442
|
-
const brokerBackend = brokerState.hostState.backend;
|
|
2443
|
-
await session.release();
|
|
2444
|
-
return {
|
|
2445
|
-
ok: false,
|
|
2446
|
-
message:
|
|
2447
|
-
`vice: backend mismatch between this MCP server and the broker that owns the emulator. This process ` +
|
|
2448
|
-
`resolved "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}, binary: ${ACTIVE_BACKEND.binPath}) while the ` +
|
|
2449
|
-
`broker resolved "${brokerBackend}" (binary: ${brokerState.hostState.vice_bin}) -- and the broker's verdict is the ` +
|
|
2450
|
-
`authoritative one, because it is what the emulator was actually launched with. The two backends speak ` +
|
|
2451
|
-
`different protocols on that port, so proceeding would send ${ACTIVE_BACKEND.backend === "fork" ? "HTTP at a binary-monitor port" : "binary-monitor frames at an HTTP endpoint"}. ` +
|
|
2452
|
-
`This normally means the MCP server runs where the emulator binary is not (a container), so its own detection ` +
|
|
2453
|
-
`could not see it. Set VICE_BACKEND=${brokerBackend} for THIS process as well -- it must be set for both -- ` +
|
|
2454
|
-
`and restart the MCP server so its advertised tool list matches.`,
|
|
2455
|
-
};
|
|
2456
|
-
}
|
|
2457
|
-
if (!brokerState.ok) {
|
|
2458
|
-
console.error(
|
|
2459
|
-
`vice-proxy: could not read the broker's own backend verdict (${brokerState.kind}: ${brokerState.message}) -- ` +
|
|
2460
|
-
`proceeding with this process's own verdict "${ACTIVE_BACKEND.backend}" (source: ${ACTIVE_BACKEND.source}); a mismatch, if any, will not be detected`,
|
|
2461
|
-
);
|
|
2462
|
-
}
|
|
2463
|
-
|
|
1286
|
+
// FORKRM-01 (plan 52-06): the broker/proxy backend cross-check that used to
|
|
1287
|
+
// sit here is deleted outright, by recorded decision, with no lighter
|
|
1288
|
+
// replacement. It existed because two processes could resolve `ViceBackend`
|
|
1289
|
+
// differently -- this process's own resolution (against the CONTAINER's
|
|
1290
|
+
// filesystem, which usually has no x64sc at all) and the broker's own
|
|
1291
|
+
// independent resolution (against the HOST's) -- and a disagreement would
|
|
1292
|
+
// otherwise surface only as an inexplicable transport failure on the first
|
|
1293
|
+
// real tool call. With one backend the comparison is a tautology: both
|
|
1294
|
+
// sides can only ever resolve "stock". The residual signal the check also
|
|
1295
|
+
// caught -- one side finding no x64sc binary at all -- still reaches the
|
|
1296
|
+
// operator independently, unaffected by this deletion: `vice_ping`'s
|
|
1297
|
+
// `resolvedBinaryPath` field reports this process's own resolution (via
|
|
1298
|
+
// `RESOLVED_BINARY.binPath`/`binPathResolved` above), and the broker
|
|
1299
|
+
// reports a launch failure by name when it cannot find its own binary.
|
|
2464
1300
|
const result = await session.acquire();
|
|
2465
1301
|
if (!result.ok) {
|
|
2466
1302
|
// No grant is coming for this session -- nothing to hold the connection
|
|
@@ -2483,7 +1319,6 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
|
2483
1319
|
// handleGrantedInstanceUnreachable() below) -- one code path for adopting
|
|
2484
1320
|
// an instance, never a second one for a replacement.
|
|
2485
1321
|
adoptGrant({ ...result.grant });
|
|
2486
|
-
viceSession = null; // re-baseline: the next ensureViceSession() reads the GRANTED instance's own epoch file
|
|
2487
1322
|
controlSession = session;
|
|
2488
1323
|
return { ok: true, lease: buildHeldLease(session) };
|
|
2489
1324
|
}
|
|
@@ -2500,6 +1335,29 @@ async function ensureBrokerLease(): Promise<BrokerLeaseResult> {
|
|
|
2500
1335
|
function adoptGrant(grant: Record<string, unknown>): void {
|
|
2501
1336
|
grantId = typeof grant.id === "string" ? grant.id : null;
|
|
2502
1337
|
const containerized = containerizeGrant({ ...grant });
|
|
1338
|
+
|
|
1339
|
+
// Plan 41-01 (D-15): validate before stashing. `containerizeGrant()` never
|
|
1340
|
+
// translates this field (a port number needs no host<->container path or
|
|
1341
|
+
// URL rewrite), so `containerized.remote_monitor_port` is exactly the raw
|
|
1342
|
+
// wire value. A value that is not an integer in 1..65535 is rejected,
|
|
1343
|
+
// grantRemoteMonitorPort is left null, and a one-line stderr warning names
|
|
1344
|
+
// the observed value -- never a silent coercion, matching
|
|
1345
|
+
// containerizeGrant()'s own posture for an invalid grant.port.
|
|
1346
|
+
const rawRemoteMonitorPort = containerized.remote_monitor_port;
|
|
1347
|
+
if (rawRemoteMonitorPort === undefined) {
|
|
1348
|
+
grantRemoteMonitorPort = null;
|
|
1349
|
+
} else {
|
|
1350
|
+
const n = Number(rawRemoteMonitorPort);
|
|
1351
|
+
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
|
|
1352
|
+
grantRemoteMonitorPort = n;
|
|
1353
|
+
} else {
|
|
1354
|
+
grantRemoteMonitorPort = null;
|
|
1355
|
+
console.error(
|
|
1356
|
+
`vice-proxy: adoptGrant ${grantId ?? "(no id)"}: remote_monitor_port (${JSON.stringify(rawRemoteMonitorPort)}) is not a valid integer port in 1..65535 -- the text channel will not be dialed for this instance`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
2503
1361
|
useInstance({
|
|
2504
1362
|
port: containerized.port as number,
|
|
2505
1363
|
url: containerized.url as string,
|
|
@@ -2510,611 +1368,25 @@ function adoptGrant(grant: Record<string, unknown>): void {
|
|
|
2510
1368
|
|
|
2511
1369
|
// --------------------------------------- D-13/D-14: replace-and-report
|
|
2512
1370
|
//
|
|
2513
|
-
//
|
|
2514
|
-
//
|
|
2515
|
-
//
|
|
2516
|
-
//
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
//
|
|
2520
|
-
//
|
|
2521
|
-
//
|
|
2522
|
-
//
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
//
|
|
2528
|
-
//
|
|
2529
|
-
//
|
|
2530
|
-
//
|
|
2531
|
-
//
|
|
2532
|
-
// surviving past a grant was a file write (the retiring lease heartbeat)
|
|
2533
|
-
// whose failure was a silent no-op -- broker death was survivable by
|
|
2534
|
-
// construction, because nothing after the grant still needed the broker at
|
|
2535
|
-
// all. Under one held TCP connection, that is no longer true: recycle and
|
|
2536
|
-
// (as of this plan) replacement both need a live connection. That safety
|
|
2537
|
-
// margin is given up DELIBERATELY, per the tolerance decision recorded in
|
|
2538
|
-
// broker-control-plane-over-tcp.md -- the compensation is that a session is
|
|
2539
|
-
// told LOUDLY rather than left to quietly keep working against whatever a
|
|
2540
|
-
// still-reachable granted instance happens to answer, for as long as it
|
|
2541
|
-
// happens to stay reachable.
|
|
2542
|
-
//
|
|
2543
|
-
// Both D-13 and D-14 reuse the SAME machineReplacedMessage() builder (which
|
|
2544
|
-
// itself reuses epochDriftMessage(), the existing voided-run vocabulary the
|
|
2545
|
-
// fixed-port epoch-drift guard already carries) -- one vocabulary for a
|
|
2546
|
-
// voided run, never a second one paralleling it. Neither outcome is ever
|
|
2547
|
-
// cached: controlSession is deliberately left pointing at a known-dead
|
|
2548
|
-
// session on every failure branch below, so the NEXT call's own probe
|
|
2549
|
-
// failure repeats this exact same from-scratch attempt (a fresh
|
|
2550
|
-
// openBrokerControl() reads broker.json fresh every time, never memoised),
|
|
2551
|
-
// rather than short-circuiting on a remembered verdict -- see the
|
|
2552
|
-
// NEVER-CACHE-A-NEGATIVE-RESULT invariant above ensureViceSession().
|
|
2553
|
-
|
|
2554
|
-
/**
|
|
2555
|
-
* D-13/D-14's shared report text: a call was refused because the machine
|
|
2556
|
-
* behind it was REPLACED out from under it. Reuses epochDriftMessage() --
|
|
2557
|
-
* the SAME builder the fixed-port epoch-drift guard already uses -- for the
|
|
2558
|
-
* epoch-comparison sentence, rather than inventing a second wording for
|
|
2559
|
-
* "this is not the machine you had a moment ago" (D-13's own instruction:
|
|
2560
|
-
* no second notion of a voided run). States the three facts an agent needs,
|
|
2561
|
-
* literally: the machine was REPLACED, the replacement is FRESH, and prior
|
|
2562
|
-
* state on the old instance is GONE.
|
|
2563
|
-
*
|
|
2564
|
-
* 2026-08-05 defect fix, two parts, both kept INLINE here (not extracted to
|
|
2565
|
-
* a helper) so this function's own body still literally contains the
|
|
2566
|
-
* `epochDriftMessage(` call the structural test
|
|
2567
|
-
* ("the replaced-machine report is built from the existing voided-run
|
|
2568
|
-
* vocabulary") pins:
|
|
2569
|
-
*
|
|
2570
|
-
* 1. Epoch sentence -- three cases, not two. The old code called
|
|
2571
|
-
* epochDriftMessage() whenever BOTH epochs were merely present,
|
|
2572
|
-
* with no inequality check -- so an unmoved-but-both-present pair
|
|
2573
|
-
* (oldEpoch.epoch === newEpoch.epoch) rendered the literally false
|
|
2574
|
-
* "epoch changed from 1 to 1" (the exact sighting on file). Now:
|
|
2575
|
-
* both present AND different -> epochDriftMessage(), unchanged; both
|
|
2576
|
-
* present but EQUAL -> an honest "did not change" sentence (each
|
|
2577
|
-
* port's epoch file is an independent counter, so a coincidental
|
|
2578
|
-
* match is expected, not evidence of anything -- and a genuinely
|
|
2579
|
-
* reused port can still read stale-equal if the host had not yet
|
|
2580
|
-
* written its post-respawn bump at the moment this was sampled); not
|
|
2581
|
-
* both present -> unchanged from before, "could not both be
|
|
2582
|
-
* compared".
|
|
2583
|
-
* 2. Port sentence -- when oldPort === newPort (a real, legitimate
|
|
2584
|
-
* outcome in this broker's fixed-slot design: a "replacement" can
|
|
2585
|
-
* land back on the exact port it replaced), the OLD wording named
|
|
2586
|
-
* that single port number as both "the old instance (port X)" and
|
|
2587
|
-
* "the replacement instance (port X)" -- two different entities
|
|
2588
|
-
* sharing one label, which a reader cannot reconcile ("one port
|
|
2589
|
-
* cannot be both"). Now branches on whether the port actually
|
|
2590
|
-
* changed: same port says so plainly ("replaced in place"), rather
|
|
2591
|
-
* than implying two distinct ports that happen to print the same
|
|
2592
|
-
* digits.
|
|
2593
|
-
*/
|
|
2594
|
-
function machineReplacedMessage(opts: {
|
|
2595
|
-
where: string;
|
|
2596
|
-
reason: string;
|
|
2597
|
-
oldPort: number;
|
|
2598
|
-
oldEpoch: EpochResult;
|
|
2599
|
-
newPort: number;
|
|
2600
|
-
newEpoch: EpochResult;
|
|
2601
|
-
}): string {
|
|
2602
|
-
const { where, reason, oldPort, oldEpoch, newPort, newEpoch } = opts;
|
|
2603
|
-
let driftSentence: string;
|
|
2604
|
-
if (oldEpoch.present && newEpoch.present) {
|
|
2605
|
-
driftSentence =
|
|
2606
|
-
oldEpoch.epoch !== newEpoch.epoch
|
|
2607
|
-
? epochDriftMessage(where, oldEpoch, newEpoch)
|
|
2608
|
-
: `vice: the epoch recorded ${where} did not change (still ${oldEpoch.epoch}) -- this is NOT evidence ` +
|
|
2609
|
-
`the machine stayed the same: each port's epoch counter is independent, so a coincidental match is ` +
|
|
2610
|
-
`expected between two unrelated files, and a genuinely reused port can still read stale-equal if the ` +
|
|
2611
|
-
`host had not yet recorded its post-respawn bump at the moment this was sampled. Treat every result ` +
|
|
2612
|
-
`since the previous call as void and redo that work regardless -- the replacement itself (below) is ` +
|
|
2613
|
-
`the operative fact here, not this epoch read.`;
|
|
2614
|
-
} else {
|
|
2615
|
-
driftSentence =
|
|
2616
|
-
`vice: treat every result since the previous call as void and redo that work -- the old instance's ` +
|
|
2617
|
-
`epoch and the new instance's epoch could not both be compared ` +
|
|
2618
|
-
`(old epoch present: ${oldEpoch.present}, new epoch present: ${newEpoch.present}).`;
|
|
2619
|
-
}
|
|
2620
|
-
const portSentence =
|
|
2621
|
-
oldPort === newPort
|
|
2622
|
-
? `The instance behind port ${oldPort} was REPLACED IN PLACE -- the process is a FRESH emulator (this ` +
|
|
2623
|
-
`broker's fixed-slot design can hand the replacement the SAME port back), and all prior state from ` +
|
|
2624
|
-
`before the replacement is GONE (${reason}).`
|
|
2625
|
-
: `The machine was REPLACED, the replacement is a FRESH emulator, and all prior state on the old ` +
|
|
2626
|
-
`instance (port ${oldPort}) is GONE (${reason}).`;
|
|
2627
|
-
return (
|
|
2628
|
-
`${driftSentence} ${portSentence} Make this call again -- it will run on the replacement instance ` +
|
|
2629
|
-
`(port ${newPort}), already acquired and adopted for this session.`
|
|
2630
|
-
);
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
/** D-13: the replacement acquisition itself failed for a reason OTHER than
|
|
2634
|
-
* the broker connection being gone (denied, no_free_port, at_capacity, its
|
|
2635
|
-
* own deadline, ...). Names both failures -- the original unreachability
|
|
2636
|
-
* and the failed replacement -- and does not retry: a retry loop against a
|
|
2637
|
-
* broker that cannot currently grant is how one failure becomes a hang. */
|
|
2638
|
-
function replacementFailedMessage(probe: ProbeResult, failure: { kind: string; message: string }): string {
|
|
2639
|
-
return (
|
|
2640
|
-
`vice: retry this call yourself once the underlying problem is fixed -- no further replacement will ` +
|
|
2641
|
-
`be attempted automatically. The granted instance stopped answering (${probe.reason}), and a ` +
|
|
2642
|
-
`same-session replacement attempt also failed (${failure.kind}: ${failure.message}).`
|
|
2643
|
-
);
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
/** D-14: the broker connection is gone and a fresh one could not be opened
|
|
2647
|
-
* either (or could be opened but could not itself acquire) -- there is
|
|
2648
|
-
* nothing left this proxy can do on its own. Names the broker, not this
|
|
2649
|
-
* proxy, as the cause, and states plainly that no further call in THIS
|
|
2650
|
-
* session can succeed until it is running again. */
|
|
2651
|
-
function sessionMustRestartMessage(failure: { kind: string; message: string }): string {
|
|
2652
|
-
return (
|
|
2653
|
-
`vice: this session must be restarted -- no further call in this session can succeed until the ` +
|
|
2654
|
-
`broker is running again. The on-demand VICE broker connection is gone and a fresh session could ` +
|
|
2655
|
-
`not be opened (${failure.kind}: ${failure.message}). The broker itself is the cause.`
|
|
2656
|
-
);
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
/**
|
|
2660
|
-
* D-13/D-14's entry point, reached only when the pre-flight probe found the
|
|
2661
|
-
* session's granted instance unreachable AND a control session is held.
|
|
2662
|
-
* Exactly one same-session replacement attempt, then (only if THAT attempt
|
|
2663
|
-
* discovers the connection itself is gone) exactly one fresh-session
|
|
2664
|
-
* attempt -- never a loop, never more than these two acquisitions for one
|
|
2665
|
-
* triggering call. Always returns a report string; never a result, even on
|
|
2666
|
-
* the success paths -- see this section's own header comment for why.
|
|
2667
|
-
*/
|
|
2668
|
-
async function handleGrantedInstanceUnreachable(probe: ProbeResult, oldEpoch: EpochResult): Promise<string> {
|
|
2669
|
-
const { port: oldPort } = activeInstance();
|
|
2670
|
-
const session = controlSession as BrokerControlSession;
|
|
2671
|
-
|
|
2672
|
-
// Attempt 1: a replacement over the SAME session (D-13). Only the granted
|
|
2673
|
-
// EMULATOR is suspected dead here -- the connection to the broker may
|
|
2674
|
-
// still be perfectly good, and reusing it is the whole point of "costs
|
|
2675
|
-
// one acquisition, not the session."
|
|
2676
|
-
//
|
|
2677
|
-
// Gap closure (plan 14, WR-03 / T-01.6.2-90): release the grant this
|
|
2678
|
-
// session currently holds BEFORE acquiring its replacement -- two
|
|
2679
|
-
// independent reasons, both load-bearing, and order matters for both.
|
|
2680
|
-
//
|
|
2681
|
-
// Reason one: releasing frees the OLD instance's port and capacity slot
|
|
2682
|
-
// FIRST, before the acquire below ever asks for one -- a broker already
|
|
2683
|
-
// sitting at its instance ceiling can still serve this replacement, where
|
|
2684
|
-
// acquiring first could not.
|
|
2685
|
-
//
|
|
2686
|
-
// Reason two, the actual leak this closes: grantId (this proxy's own
|
|
2687
|
-
// module-level grant slot, declared above) is a SINGLE value --
|
|
2688
|
-
// adoptGrant() below simply overwrites it. Acquiring a replacement
|
|
2689
|
-
// without first releasing what is about to be overwritten is what
|
|
2690
|
-
// abandons the prior grant: the broker goes on holding an instance this
|
|
2691
|
-
// proxy no longer remembers asking to release, and every further
|
|
2692
|
-
// lost-machine event on this same session leaks one more, compounding
|
|
2693
|
-
// toward the instance ceiling. No new control-plane message is needed to
|
|
2694
|
-
// close this -- the existing release request already releases exactly
|
|
2695
|
-
// the grant THIS connection currently holds (no target id on the wire at
|
|
2696
|
-
// all), which is precisely the right one, provided it lands before the
|
|
2697
|
-
// slot below is overwritten.
|
|
2698
|
-
//
|
|
2699
|
-
// session.release() performs that release by closing the underlying
|
|
2700
|
-
// connection (a synchronous socket.destroy() under the hood -- D-12: the
|
|
2701
|
-
// connection IS the lease). The acquire immediately below therefore
|
|
2702
|
-
// finds THIS session already gone and answers "broker_gone" -- which is
|
|
2703
|
-
// NOT a new failure mode invented for this fix: it is handled by the
|
|
2704
|
-
// SAME broker-gone branch a few lines down this function already had,
|
|
2705
|
-
// exactly as it already handles any other dead-connection discovery. A
|
|
2706
|
-
// failed release introduces no new branch of its own: release() never
|
|
2707
|
-
// rejects (closing an already-closed socket is an idempotent no-op), and
|
|
2708
|
-
// even if it somehow did, the acquire that follows would classify and
|
|
2709
|
-
// report it exactly the same way.
|
|
2710
|
-
await session.release();
|
|
2711
|
-
grantId = null;
|
|
2712
|
-
const result = await session.acquire();
|
|
2713
|
-
if (result.ok) {
|
|
2714
|
-
adoptGrant({ ...result.grant });
|
|
2715
|
-
viceSession = null;
|
|
2716
|
-
ensureViceSession(); // re-baseline BEFORE returning -- see the never-cache invariant
|
|
2717
|
-
const newInstance = activeInstance();
|
|
2718
|
-
return machineReplacedMessage({
|
|
2719
|
-
where: "at the pre-flight liveness probe",
|
|
2720
|
-
reason: `the granted instance (port ${oldPort}) stopped answering -- ${probe.reason}`,
|
|
2721
|
-
oldPort,
|
|
2722
|
-
oldEpoch,
|
|
2723
|
-
newPort: newInstance.port,
|
|
2724
|
-
newEpoch: currentEpoch(),
|
|
2725
|
-
});
|
|
2726
|
-
}
|
|
2727
|
-
|
|
2728
|
-
if (result.kind !== "broker_gone") {
|
|
2729
|
-
// Bounded: exactly one replacement attempt, and it failed for a reason
|
|
2730
|
-
// that has nothing to do with the connection itself -- report both
|
|
2731
|
-
// failures and stop.
|
|
2732
|
-
return replacementFailedMessage(probe, result);
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
|
-
// D-14: attempt 1 itself discovered the control connection is gone --
|
|
2736
|
-
// now the ORDINARY way this branch is reached, since the release just
|
|
2737
|
-
// above always closes it (T-01.6.2-90's own fix, not a regression: the
|
|
2738
|
-
// grant that release protects against leaking is already gone by
|
|
2739
|
-
// construction before this line ever runs). No release is sent over
|
|
2740
|
-
// `session` here, and none is needed: a release cannot be sent over a
|
|
2741
|
-
// connection that is already gone, and connection close IS the release
|
|
2742
|
-
// in this design, kernel-enforced -- the broker's own close handler has
|
|
2743
|
-
// already released the prior grant and killed its instance the moment
|
|
2744
|
-
// that close event fired, whichever branch triggered it. If the broker
|
|
2745
|
-
// itself died instead, there is nothing left to leak into either.
|
|
2746
|
-
// Attempt 2: open a GENUINELY FRESH session -- a brand-new broker.json
|
|
2747
|
-
// read (never the stale record the dead session above was opened
|
|
2748
|
-
// against), never reusing `session`. controlSession is deliberately left
|
|
2749
|
-
// pointing at the dead session on every failure branch below, so a LATER
|
|
2750
|
-
// call's own probe failure repeats this exact same from-scratch sequence.
|
|
2751
|
-
const opened = await openBrokerControl();
|
|
2752
|
-
if (!opened.ok) {
|
|
2753
|
-
return sessionMustRestartMessage(opened);
|
|
2754
|
-
}
|
|
2755
|
-
const freshResult = await opened.session.acquire();
|
|
2756
|
-
if (!freshResult.ok) {
|
|
2757
|
-
await opened.session.release(); // nothing to hold this connection open for
|
|
2758
|
-
return sessionMustRestartMessage(freshResult);
|
|
2759
|
-
}
|
|
2760
|
-
adoptGrant({ ...freshResult.grant });
|
|
2761
|
-
controlSession = opened.session; // the fresh session replaces the dead one, held for the rest of this proxy's life
|
|
2762
|
-
viceSession = null;
|
|
2763
|
-
ensureViceSession();
|
|
2764
|
-
const newInstance = activeInstance();
|
|
2765
|
-
return machineReplacedMessage({
|
|
2766
|
-
where: "after the broker connection itself was found gone and a fresh session was opened",
|
|
2767
|
-
reason: `the broker connection was gone (${result.message})`,
|
|
2768
|
-
oldPort,
|
|
2769
|
-
oldEpoch,
|
|
2770
|
-
newPort: newInstance.port,
|
|
2771
|
-
newEpoch: currentEpoch(),
|
|
2772
|
-
});
|
|
2773
|
-
}
|
|
2774
|
-
|
|
2775
|
-
// ------------------------------------------------ D-16 seam hazard annotation
|
|
2776
|
-
//
|
|
2777
|
-
// Plan 01.3-04. Structurally the OPPOSITE of the deny-list refusal below (the
|
|
2778
|
-
// DENY_LIST.includes(name) branch a little further into this same function):
|
|
2779
|
-
// the refusal fires BEFORE forwarding and the call never reaches the host;
|
|
2780
|
-
// this fires AFTER call() returns a real payload and appends to a
|
|
2781
|
-
// SUCCESSFUL result. The call is never refused and the error flag is never
|
|
2782
|
-
// set (D-16) -- a stopping checkpoint on an IRQ handler is core reverse-
|
|
2783
|
-
// engineering technique that Phase 2's exhaustive trace depends on, so this
|
|
2784
|
-
// warns instead of blocking it, the way the deny list blocks vice_disk_list
|
|
2785
|
-
// (which has no legitimate use at all).
|
|
2786
|
-
|
|
2787
|
-
// The set of capability names whose OWN arguments can express an armed,
|
|
2788
|
-
// stopping, exec checkpoint. Today that is vice_checkpoint_add alone.
|
|
2789
|
-
// Re-enabling an already-armed stopping checkpoint via vice_checkpoint_toggle
|
|
2790
|
-
// or a checkpoint group (vice_checkpoint_group_toggle/_add) can ALSO re-arm
|
|
2791
|
-
// one, but neither call's own arguments carry the stop flag -- only the
|
|
2792
|
-
// id/group being toggled -- so that re-enable path is NOT detectable from
|
|
2793
|
-
// the call alone and is deliberately excluded from this set. That gap is
|
|
2794
|
-
// covered by both tools' own descriptions and by vice_diagnose's checkpoint-
|
|
2795
|
-
// trap check, and it is stated in the annotation text below rather than left
|
|
2796
|
-
// for a reader to discover.
|
|
2797
|
-
const CHECKPOINT_ARMING_TOOLS = new Set(["vice_checkpoint_add"]);
|
|
2798
|
-
|
|
2799
|
-
// Per-session suppression: an address (as rendered by formatAddress(), or an
|
|
2800
|
-
// "unparseable:<raw>" key for an address that could not be parsed) already
|
|
2801
|
-
// warned about this session maps to true. Cleared whenever the observed
|
|
2802
|
-
// epoch changes -- a new machine has seen none of these. currentEpoch() is a
|
|
2803
|
-
// synchronous LOCAL file read (see its own definition above), never a
|
|
2804
|
-
// forwarded call, so consulting it here does not violate the "makes no
|
|
2805
|
-
// forwarded call of its own" requirement below.
|
|
2806
|
-
let seamHazardSeen: Set<string> = new Set();
|
|
2807
|
-
let seamHazardEpochKey: number | null = null;
|
|
2808
|
-
|
|
2809
|
-
function seamHazardObserveEpoch(): void {
|
|
2810
|
-
const epoch = currentEpoch();
|
|
2811
|
-
const key = epoch && epoch.present ? epoch.epoch : null;
|
|
2812
|
-
if (seamHazardEpochKey !== null && key !== seamHazardEpochKey) {
|
|
2813
|
-
seamHazardSeen = new Set(); // a new machine has seen none of these
|
|
2814
|
-
}
|
|
2815
|
-
seamHazardEpochKey = key;
|
|
2816
|
-
}
|
|
2817
|
-
|
|
2818
|
-
/** detectCheckpointArmingHazard()'s own return shape -- consumed only by
|
|
2819
|
-
* renderCheckpointArmingHazard() below. */
|
|
2820
|
-
interface CheckpointArmingHazardDetection {
|
|
2821
|
-
addrLabel: string;
|
|
2822
|
-
repeat: boolean;
|
|
2823
|
-
}
|
|
2824
|
-
|
|
2825
|
-
/**
|
|
2826
|
-
* D-16's hazard annotation. Returns the annotation text for a successful
|
|
2827
|
-
* checkpoint-arming call, or nothing. Returns nothing unless the capability
|
|
2828
|
-
* is in CHECKPOINT_ARMING_TOOLS and the arguments express an exec operation
|
|
2829
|
-
* with the stop flag set -- callers only reach this after a successful
|
|
2830
|
-
* call(), so a rejected arm never reaches here at all (a failed arm has no
|
|
2831
|
-
* hazard to warn about). Makes NO forwarded call of its own (T-01.3-13) --
|
|
2832
|
-
* the detection is entirely over the arguments the agent already supplied.
|
|
2833
|
-
* An unparseable address is still annotated, naming the address as unread
|
|
2834
|
-
* rather than silently skipping: an unparseable address is not evidence of
|
|
2835
|
-
* safety.
|
|
2836
|
-
*/
|
|
2837
|
-
function detectCheckpointArmingHazard(
|
|
2838
|
-
name: string,
|
|
2839
|
-
args: Record<string, unknown>
|
|
2840
|
-
): CheckpointArmingHazardDetection | undefined {
|
|
2841
|
-
if (!CHECKPOINT_ARMING_TOOLS.has(name)) return undefined;
|
|
2842
|
-
// vice_checkpoint_add's own schema: `stop` defaults true, `exec` defaults
|
|
2843
|
-
// true -- an ABSENT field is armed, not merely "true when written out".
|
|
2844
|
-
const stopArmed = !(args && args.stop === false);
|
|
2845
|
-
const execArmed = !(args && args.exec === false);
|
|
2846
|
-
if (!stopArmed || !execArmed) return undefined;
|
|
2847
|
-
|
|
2848
|
-
seamHazardObserveEpoch();
|
|
2849
|
-
|
|
2850
|
-
const addrNum = toAddressNumber(args && args.start);
|
|
2851
|
-
const addrLabel =
|
|
2852
|
-
addrNum === null ? `an unparseable address (raw value: ${JSON.stringify(args && args.start)})` : formatAddress(addrNum);
|
|
2853
|
-
const suppressionKey = addrNum === null ? `unparseable:${JSON.stringify(args && args.start)}` : addrLabel;
|
|
2854
|
-
|
|
2855
|
-
const repeat = seamHazardSeen.has(suppressionKey);
|
|
2856
|
-
if (!repeat) seamHazardSeen.add(suppressionKey);
|
|
2857
|
-
return { addrLabel, repeat };
|
|
2858
|
-
}
|
|
2859
|
-
|
|
2860
|
-
function renderCheckpointArmingHazard(detection: CheckpointArmingHazardDetection): string {
|
|
2861
|
-
const { addrLabel, repeat } = detection;
|
|
2862
|
-
if (repeat) {
|
|
2863
|
-
return (
|
|
2864
|
-
`vice hazard (repeat): a stopping exec checkpoint was armed again at ${addrLabel} -- the full ` +
|
|
2865
|
-
"hazard note for this address was already issued earlier this session; see that note."
|
|
2866
|
-
);
|
|
2867
|
-
}
|
|
2868
|
-
return [
|
|
2869
|
-
`vice hazard: a stopping exec checkpoint was just armed at ${addrLabel}, and the call was NOT ` +
|
|
2870
|
-
"blocked -- it will not be, because this is core reverse-engineering technique.",
|
|
2871
|
-
"",
|
|
2872
|
-
"This shape -- a stopping exec checkpoint armed, then execution resumed -- is common to every recorded " +
|
|
2873
|
-
"freeze on this project. Two variants are on record: a mid-routine stop that froze two independent " +
|
|
2874
|
-
"sessions at an identical program counter, and an IRQ-handler-entry stop whose tell was a hit count " +
|
|
2875
|
-
"of zero on a screen the machine must have been executing.",
|
|
2876
|
-
"",
|
|
2877
|
-
"Whether THIS address is the live IRQ handler is a question vice_diagnose answers, by resolving the " +
|
|
2878
|
-
"vector pair live -- this warning deliberately does not resolve it here, because doing so on every " +
|
|
2879
|
-
"arm would disturb the machine it is protecting.",
|
|
2880
|
-
"",
|
|
2881
|
-
"Recovery, in order: run vice_diagnose first; reach for vice_recycle only when the bracket says wedge " +
|
|
2882
|
-
"with no checkpoint explanation.",
|
|
2883
|
-
"",
|
|
2884
|
-
"Stated residual: re-enabling this checkpoint later via vice_checkpoint_toggle or a checkpoint group " +
|
|
2885
|
-
"carries no stop flag in its own arguments and is therefore NOT annotated by this mechanism -- covered " +
|
|
2886
|
-
"by both tools' own descriptions and by vice_diagnose's checkpoint-trap check instead.",
|
|
2887
|
-
].join("\n");
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
/**
|
|
2891
|
-
* Plan 01.3-04 task 2: turns task 1's single hazard into the general
|
|
2892
|
-
* mechanism D-06 needs -- a table, so the next confirmed trigger (plan
|
|
2893
|
-
* 01.3-05's bounded hunt) is a single entry rather than new plumbing at this
|
|
2894
|
-
* seam. Each entry:
|
|
2895
|
-
* - id: a short identifier that MUST be named by at least one test in
|
|
2896
|
-
* vice-proxy.test.mjs (this file's own structural completeness test
|
|
2897
|
-
* enforces it) -- an entry that ships without a matching test fails the
|
|
2898
|
-
* suite rather than shipping unproven.
|
|
2899
|
-
* - capabilities: the Set of tool names this entry's own detect() can ever
|
|
2900
|
-
* match against. Used ONLY by the disjointness structural test below
|
|
2901
|
-
* (never for dispatch -- the walk tries every entry against every
|
|
2902
|
-
* call). Every capability named here must be ABSENT from DENY_LIST: a
|
|
2903
|
-
* capability with no legitimate use is refused before forwarding, and
|
|
2904
|
-
* one with a legitimate use is annotated after it, and none is both
|
|
2905
|
-
* (D-16).
|
|
2906
|
-
* - detect(name, args, payload): returns a truthy detection payload, or
|
|
2907
|
-
* nothing. MUST make no forwarded call of its own (T-01.3-13).
|
|
2908
|
-
* - render(detection): returns the annotation text for a truthy
|
|
2909
|
-
* detection.
|
|
2910
|
-
*
|
|
2911
|
-
* Plan 01.3-05 is this table's expected next writer, adding the bounded
|
|
2912
|
-
* hunt's own confirmed trigger as one more entry here -- not new plumbing.
|
|
2913
|
-
*/
|
|
2914
|
-
// Method-shorthand syntax deliberately (not `detect: (...) => ...`): TS's
|
|
2915
|
-
// bivariant method-parameter check is what lets each entry's own narrower
|
|
2916
|
-
// detect()/render() pair (e.g. CheckpointArmingHazardDetection, not
|
|
2917
|
-
// `unknown`) slot into this shared, heterogeneous table -- exactly the
|
|
2918
|
-
// polymorphism the table's own doc comment above describes ("the next
|
|
2919
|
-
// confirmed trigger is a single entry"). The production entry below is cast
|
|
2920
|
-
// `as SeamHazardEntry` (not the whole SEAM_HAZARDS declaration -- that
|
|
2921
|
-
// exact line is vice-proxy.test.mjs's own oracle anchor, `indexOf("const
|
|
2922
|
-
// SEAM_HAZARDS = [")`, and must stay byte-identical) so the array's own
|
|
2923
|
-
// inferred element type is this interface, which is what lets the
|
|
2924
|
-
// TEST-ONLY .push() below (a structurally different detect/render pair)
|
|
2925
|
-
// type-check without a second cast at that call site.
|
|
2926
|
-
interface SeamHazardEntry {
|
|
2927
|
-
id: string;
|
|
2928
|
-
capabilities: Set<string>;
|
|
2929
|
-
detect(name: string, args: Record<string, unknown>, payload?: unknown): unknown;
|
|
2930
|
-
render(detection: unknown): string;
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
const SEAM_HAZARDS = [
|
|
2934
|
-
{
|
|
2935
|
-
id: "checkpoint-arming",
|
|
2936
|
-
capabilities: CHECKPOINT_ARMING_TOOLS,
|
|
2937
|
-
detect: detectCheckpointArmingHazard,
|
|
2938
|
-
render: renderCheckpointArmingHazard,
|
|
2939
|
-
} as SeamHazardEntry,
|
|
2940
|
-
];
|
|
2941
|
-
|
|
2942
|
-
// TEST-ONLY escape hatch (plan 01.3-04 task 2's data-driven proof): proves
|
|
2943
|
-
// the walk below is genuinely data-driven, not hand-wired to the one
|
|
2944
|
-
// production entry above, by injecting a SECOND entry the same way a real
|
|
2945
|
-
// plan 01.3-05 entry would arrive. Matches against vice_ping -- an existing,
|
|
2946
|
-
// universally-forwardable tool -- rather than inventing a synthetic
|
|
2947
|
-
// capability name that would need its own manifest/deny-list bookkeeping.
|
|
2948
|
-
// Never set outside this file's own test suite.
|
|
2949
|
-
if (process.env.VICE_SEAM_HAZARDS_TEST_FIXTURE === "1") {
|
|
2950
|
-
SEAM_HAZARDS.push({
|
|
2951
|
-
id: "test-fixture-synthetic-entry",
|
|
2952
|
-
capabilities: new Set(["vice_ping"]),
|
|
2953
|
-
detect: (name: string) => (name === "vice_ping" ? { fixture: true } : undefined),
|
|
2954
|
-
render: () => "vice-proxy hazard (TEST FIXTURE): synthetic second SEAM_HAZARDS entry, detected and annotated through the same walk.",
|
|
2955
|
-
});
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
/**
|
|
2959
|
-
* Walks SEAM_HAZARDS, concatenating every annotation a successful call
|
|
2960
|
-
* attracts. Short-circuits per entry on a falsy detection -- a call matching
|
|
2961
|
-
* no entry costs one array pass and returns undefined, leaving the payload
|
|
2962
|
-
* untouched.
|
|
2963
|
-
*/
|
|
2964
|
-
function renderSeamHazardAnnotations(name: string, args: Record<string, unknown>, payload: unknown): string | undefined {
|
|
2965
|
-
const notes: string[] = [];
|
|
2966
|
-
for (const entry of SEAM_HAZARDS) {
|
|
2967
|
-
const detection = entry.detect(name, args, payload);
|
|
2968
|
-
if (detection) {
|
|
2969
|
-
notes.push(entry.render(detection));
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
return notes.length ? notes.join("\n\n") : undefined;
|
|
2973
|
-
}
|
|
2974
|
-
|
|
2975
|
-
// forwardToVice() is the retained BODY of what used to be handleToolsCall()
|
|
2976
|
-
// -- renamed and trimmed of the name/args extraction, the three synthetic-
|
|
2977
|
-
// tool short-circuits, and the deny-list check, all now handled one layer
|
|
2978
|
-
// out by the CallToolRequestSchema override and the tool registry
|
|
2979
|
-
// construction (both near the bottom of this file, right after the
|
|
2980
|
-
// teardown region): each real manifest tool's own buildViceTool() entry
|
|
2981
|
-
// wraps this function as its `execute`, so this is reached only for a name
|
|
2982
|
-
// already known to be a real, non-deny-listed manifest tool with an
|
|
2983
|
-
// already-parsed `args` object. Every function called below is reused
|
|
2984
|
-
// completely unchanged from its pre-swap form.
|
|
2985
|
-
async function forwardToVice(name: string, args: Record<string, unknown>): Promise<ToolCallResult> {
|
|
2986
|
-
const leaseResult = await ensureBrokerLease();
|
|
2987
|
-
if (!leaseResult.ok) {
|
|
2988
|
-
return isErrorText(leaseResult.message);
|
|
2989
|
-
}
|
|
2990
|
-
// No touch-on-every-forwarded-call any more (C6's old mechanism, alongside
|
|
2991
|
-
// the heartbeat timer, both retired under D-12): the connection itself is
|
|
2992
|
-
// the claim, kernel-enforced, with nothing to refresh. Either the socket is
|
|
2993
|
-
// still open, or the broker's own "close" handler has already reclaimed
|
|
2994
|
-
// the instance -- there is no third, ambiguous state a touch could rescue.
|
|
2995
|
-
|
|
2996
|
-
ensureViceSession();
|
|
2997
|
-
|
|
2998
|
-
const beforeDrift = checkEpochAndRebaseline("before forwarding");
|
|
2999
|
-
if (beforeDrift) {
|
|
3000
|
-
// Refused BEFORE any request is serialised -- the whole point of the
|
|
3001
|
-
// pre-forward check.
|
|
3002
|
-
return isErrorText(beforeDrift);
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
|
-
// Pre-flight liveness probe (task 2 / criterion 7), ordered AFTER the
|
|
3006
|
-
// deny-list refusal and the epoch comparison above (a refused tool and a
|
|
3007
|
-
// restarted machine both need answering without any network activity at
|
|
3008
|
-
// all) and BEFORE delegating to call() -- see vice-probe.ts's header for
|
|
3009
|
-
// why this is a single 1500ms-budget round trip with no retry, never
|
|
3010
|
-
// wrapped in withReconnect()'s ladder. One call site, not inside a loop.
|
|
3011
|
-
const { url, port } = activeInstance();
|
|
3012
|
-
const probe = await probeInstance({ url, port });
|
|
3013
|
-
if (!probe.alive) {
|
|
3014
|
-
const epoch = currentEpoch();
|
|
3015
|
-
// D-5 (quick-260801-ccn task 3): the lease check runs FIRST, before the
|
|
3016
|
-
// refused-and-no-epoch test below -- under the bug this fixes, BOTH of
|
|
3017
|
-
// that test's arms hold true for a fresh broker grant (a just-granted
|
|
3018
|
-
// instance's own epoch_file rarely has a baseline recorded yet), so a
|
|
3019
|
-
// broker-granted instance was being answered by the RETIRED fixed-port
|
|
3020
|
-
// triple instead of naming the broker. That ordering was the whole
|
|
3021
|
-
// defect.
|
|
3022
|
-
if (controlSession) {
|
|
3023
|
-
// D-13/D-14 (plan 08): a granted instance not answering no longer
|
|
3024
|
-
// gets a report-and-instruct message -- it gets a replace-and-report.
|
|
3025
|
-
// handleGrantedInstanceUnreachable() acquires a replacement over this
|
|
3026
|
-
// same session (or, if the session itself turns out to be gone, a
|
|
3027
|
-
// genuinely fresh one) and returns an ERROR naming the replacement --
|
|
3028
|
-
// never a silently substituted result, even though a working
|
|
3029
|
-
// instance is now held for the NEXT call.
|
|
3030
|
-
return isErrorText(await handleGrantedInstanceUnreachable(probe, epoch));
|
|
3031
|
-
}
|
|
3032
|
-
if (isConnectionRefusedReason(probe.reason) && !epoch.present) {
|
|
3033
|
-
return isErrorText(neverStartedMessage(probe));
|
|
3034
|
-
}
|
|
3035
|
-
// Every other unreachable shape -- refused-with-an-epoch-on-record,
|
|
3036
|
-
// timed out, or something answered but didn't look like VICE -- is
|
|
3037
|
-
// "dead or hung"; probe.reason itself says which, verbatim.
|
|
3038
|
-
return isErrorText(deadOrHungMessage(probe, epoch));
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
|
-
// Path translation at the seam (task 3 / decision D-G / criterion 9),
|
|
3042
|
-
// ordered after the deny-list refusal, the epoch comparison and the
|
|
3043
|
-
// liveness probe above, and before delegating to call(). A refusal here
|
|
3044
|
-
// (out-of-workspace absolute path, or a translation failure) is returned
|
|
3045
|
-
// exactly like every other tools/call outcome: a well-formed isError:true
|
|
3046
|
-
// result, never a throw.
|
|
3047
|
-
let translatedArgs: Record<string, unknown>;
|
|
3048
|
-
let pathNote = "";
|
|
3049
|
-
try {
|
|
3050
|
-
const rewritten = rewriteArguments(args, name);
|
|
3051
|
-
translatedArgs = rewritten.args;
|
|
3052
|
-
pathNote = resolutionNote(rewritten.resolutions);
|
|
3053
|
-
} catch (e) {
|
|
3054
|
-
if (e instanceof PathOutOfWorkspaceError || e instanceof PathTranslationError) {
|
|
3055
|
-
return isErrorText(e.message);
|
|
3056
|
-
}
|
|
3057
|
-
throw e; // unexpected -- let the never-throw dispatch one layer up handle it
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
|
-
let payload: unknown;
|
|
3061
|
-
try {
|
|
3062
|
-
payload = await call(name, translatedArgs);
|
|
3063
|
-
} catch (e) {
|
|
3064
|
-
if (e instanceof MachineRestartedError) {
|
|
3065
|
-
// call()'s own post-reconnect fast path detected this first -- convert
|
|
3066
|
-
// to the same isError frame shape and re-baseline identically. Two
|
|
3067
|
-
// layers, one observable behaviour.
|
|
3068
|
-
const current = currentEpoch();
|
|
3069
|
-
epochBaseline = current;
|
|
3070
|
-
return isErrorText(
|
|
3071
|
-
`vice: treat every result since the previous call as void and redo that work -- the emulator was ` +
|
|
3072
|
-
`replaced mid-call (epoch changed from ${e.baselineEpoch} to ${e.currentEpoch}). (${e.message})`
|
|
3073
|
-
);
|
|
3074
|
-
}
|
|
3075
|
-
// NEVER rethrow past this point -- a tool-execution failure (transport
|
|
3076
|
-
// error, a rejected RPC) is a normal, expected outcome for this method
|
|
3077
|
-
// and must come back as a well-formed result, not crash the read loop.
|
|
3078
|
-
// The probe above already proved the host alive, so this is the "alive
|
|
3079
|
-
// but the operation failed" state -- relay verbatim, no restart advice.
|
|
3080
|
-
// The path note rides along on the FAILURE too, and this is the case it
|
|
3081
|
-
// was written for: a host-side "Failed to attach disk image" says nothing
|
|
3082
|
-
// about which file was attempted, so naming the resolved absolute path
|
|
3083
|
-
// here is the difference between a one-line fix and an hour spent
|
|
3084
|
-
// suspecting the emulator.
|
|
3085
|
-
const failure = aliveButFailedMessage(e && (e as Error).message ? (e as Error).message : String(e));
|
|
3086
|
-
return isErrorText(pathNote ? `${failure}\n${pathNote}` : failure);
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
|
-
const afterDrift = checkEpochAndRebaseline("after the call returned");
|
|
3090
|
-
if (afterDrift) {
|
|
3091
|
-
// A payload read from a machine whose identity changed mid-call is not
|
|
3092
|
-
// trustworthy -- return the restart frame INSTEAD OF the call's result.
|
|
3093
|
-
return isErrorText(afterDrift);
|
|
3094
|
-
}
|
|
3095
|
-
|
|
3096
|
-
const rawText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
3097
|
-
// D-16 seam hazard annotation (plan 01.3-04): computed by walking
|
|
3098
|
-
// SEAM_HAZARDS and merged into the TEXT itself, BEFORE wrapPossiblyChunked()
|
|
3099
|
-
// runs, so an oversized annotated result still carries the note inside its
|
|
3100
|
-
// own chunking (T-01.3-15) -- a warning appended AFTER chunking would be
|
|
3101
|
-
// lost off the end. Never routes through isErrorText and never touches the
|
|
3102
|
-
// error flag (D-16, T-01.3-12).
|
|
3103
|
-
const hazardNote = renderSeamHazardAnnotations(name, args, payload);
|
|
3104
|
-
const text = hazardNote ? `${rawText}\n\n${hazardNote}` : rawText;
|
|
3105
|
-
const wrapped = wrapPossiblyChunked(text);
|
|
3106
|
-
// Append the path note as a trailing content item, never mixed into the
|
|
3107
|
-
// payload: wrapPossiblyChunked()'s contract is that the FIRST item is the
|
|
3108
|
-
// payload byte-for-byte, so reassembly stays a plain concatenation. Only
|
|
3109
|
-
// the unchunked shape is annotated -- a chunked result is already carrying
|
|
3110
|
-
// a continuation marker as its second item, and the four tools that can
|
|
3111
|
-
// resolve a path (disk_attach, autostart, display_screenshot, symbols_load)
|
|
3112
|
-
// never produce output anywhere near the cap.
|
|
3113
|
-
if (pathNote && wrapped.content.length === 1) {
|
|
3114
|
-
wrapped.content.push({ type: "text", text: pathNote });
|
|
3115
|
-
}
|
|
3116
|
-
return wrapped;
|
|
3117
|
-
}
|
|
1371
|
+
// machineReplacedMessage()/replacementFailedMessage()/sessionMustRestartMessage()
|
|
1372
|
+
// and their entry point handleGrantedInstanceUnreachable() are deleted along
|
|
1373
|
+
// with the fork-only generic forwarding function that used to sit here (a
|
|
1374
|
+
// broker lease, an epoch drift check, a pre-flight liveness probe, a
|
|
1375
|
+
// per-call argument path translation, the fork transport's own HTTP
|
|
1376
|
+
// dispatch call, a D-16 seam-hazard annotation walk over the
|
|
1377
|
+
// checkpoint-arming detector, and result chunking): this whole
|
|
1378
|
+
// replace-and-report mechanism existed to handle a granted instance failing
|
|
1379
|
+
// that forwarding function's own pre-flight liveness check (a fork-only
|
|
1380
|
+
// HTTP round trip) -- its only caller. The D-16 mechanism has no other
|
|
1381
|
+
// caller either, and stock-dispatch.ts's own per-tool handlers have no
|
|
1382
|
+
// equivalent hook today. Every advertised tool now registers straight
|
|
1383
|
+
// through buildViceTool() to stockDispatch.dispatchStock() (see the
|
|
1384
|
+
// registration loop below) -- there is no surviving generic-dispatch
|
|
1385
|
+
// surface for a derived tool to slip behind, matching this plan's own
|
|
1386
|
+
// prohibition against re-opening the nested-argument hazard an outer-name
|
|
1387
|
+
// refusal array used to close. Stock has no equivalent probe-then-replace
|
|
1388
|
+
// step at this proxy layer; a dead lease surfaces through stockDispatch's
|
|
1389
|
+
// own error handling instead.
|
|
3118
1390
|
|
|
3119
1391
|
// -------------------------------------------------------------- teardown
|
|
3120
1392
|
//
|
|
@@ -3189,12 +1461,13 @@ warnOnceAboutOutputLimit(); // D-1.2-H -- one stderr line, at most once per proc
|
|
|
3189
1461
|
|
|
3190
1462
|
// ------------------------------------------------------- @mastra/mcp seam
|
|
3191
1463
|
//
|
|
3192
|
-
// D-01
|
|
3193
|
-
//
|
|
3194
|
-
//
|
|
3195
|
-
// top-level caller changed
|
|
3196
|
-
//
|
|
3197
|
-
//
|
|
1464
|
+
// D-01: the wire layer is MCPServer + startStdio(), with each registered
|
|
1465
|
+
// tool's own runner (stockDispatch.dispatchStock(), or a proxy-local
|
|
1466
|
+
// handler for the synthetic/anno_* tools, above) doing the actual dispatch
|
|
1467
|
+
// work -- only the top-level caller changed from the original hand-rolled
|
|
1468
|
+
// framing. See this plan's PLAN.md "Ground truth" section (read directly
|
|
1469
|
+
// from @mastra/mcp's compiled source, not its docs) for why tools/call is
|
|
1470
|
+
// answered by the CallToolRequestSchema override below rather
|
|
3198
1471
|
// than by MCPServer's own dispatch.
|
|
3199
1472
|
|
|
3200
1473
|
/**
|
|
@@ -3262,129 +1535,74 @@ function buildViceTool(def: ToolDefinition, run: (args: Record<string, unknown>)
|
|
|
3262
1535
|
});
|
|
3263
1536
|
}
|
|
3264
1537
|
|
|
3265
|
-
//
|
|
3266
|
-
//
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
//
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
//
|
|
3276
|
-
//
|
|
3277
|
-
//
|
|
1538
|
+
// This loop registers every tool the active manifest advertises. It used
|
|
1539
|
+
// to skip a fixed outer-name refusal array covering the fork HTTP server's
|
|
1540
|
+
// own generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
|
|
1541
|
+
// `notifications_initialized`, all of which the fork's manifest advertised
|
|
1542
|
+
// as ordinary forwardable tools) plus `vice_disk_list` (a tool known to
|
|
1543
|
+
// crash that same server). Both the fork manifest and the refusal array are
|
|
1544
|
+
// gone: the manifest this loop reads never advertised any of those names,
|
|
1545
|
+
// so there is nothing left to skip -- every entry registers unconditionally.
|
|
1546
|
+
// tools/list is served entirely by MCPServer's own ListToolsRequestSchema
|
|
1547
|
+
// handler (unmodified, not overridden), reading from this SAME `tools`
|
|
1548
|
+
// object. A manifest hot-reload mid-session is not picked up until the
|
|
1549
|
+
// proxy restarts; the manifest is regenerated by a manual, rare build step,
|
|
1550
|
+
// never mid-session in practice.
|
|
3278
1551
|
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3284
|
-
//
|
|
3285
|
-
// therefore no longer picked up until the proxy restarts; the manifest is
|
|
3286
|
-
// regenerated by a manual, rare build step, never mid-session in practice.
|
|
3287
|
-
// Edit 3 (plan 02-10, D-09): the runner each manifest tool's own execute()
|
|
3288
|
-
// closes over is chosen by ACTIVE_BACKEND, decided ONCE above, never
|
|
3289
|
-
// per-tool or per-call. The first ternary arm below is byte-identical to
|
|
3290
|
-
// every prior plan's own forwarding call, unchanged.
|
|
3291
|
-
// The second arm (the OTHER backend) passes ensureBrokerLease itself as the
|
|
3292
|
-
// injected LeaseProvider (no locally-built acquisition wrapping it -- there
|
|
3293
|
-
// is exactly one acquisition function in this file, and this arm calls the
|
|
3294
|
-
// SAME one the first arm's own lease check already calls), plus this file's
|
|
3295
|
-
// own already-settled binary path so the health-check tool on that path can
|
|
3296
|
-
// answer BACK-03 without ever re-detecting anything itself.
|
|
3297
|
-
/**
|
|
3298
|
-
* The ONE backend-aware registration seam (D-09). CR-07 (code review
|
|
3299
|
-
* 2026-08-13) is why it is a function rather than a ternary inlined in the
|
|
3300
|
-
* manifest loop: the loop was backend-aware, but the three synthetic tools
|
|
3301
|
-
* registered straight after it were NOT, and `tools/list` is served from this
|
|
3302
|
-
* same object -- so on the stock backend the advertised surface was `vice_ping`
|
|
3303
|
-
* PLUS `vice_result_continue`, `vice_recycle` and `vice_diagnose`, and two of
|
|
3304
|
-
* those three ran the fork's HTTP transport against a port speaking the binary
|
|
3305
|
-
* monitor. `handleDiagnose()` reaches ensureViceSession() /
|
|
3306
|
-
* gatherCheckpointTrapEvidence() / gatherBracketEvidence(); `handleRecycle()`
|
|
3307
|
-
* reaches gatherWedgeEvidence(). Both go through call()/forwardToVice(). That
|
|
3308
|
-
* is a direct D-09 violation ("the stock path must never fall through to the
|
|
3309
|
-
* fork's HTTP forward"), and `vice_diagnose` is the wedge-triage skill's
|
|
3310
|
-
* documented opening move -- so its output on stock was HTTP failure text
|
|
3311
|
-
* dressed as emulator diagnosis. The pre-existing structural test could not
|
|
3312
|
-
* catch it: it only checked that no code LINE pairs the string "stock" with
|
|
3313
|
-
* `forwardToVice`, which this arrangement satisfied while still reaching that
|
|
3314
|
-
* transport.
|
|
3315
|
-
*
|
|
3316
|
-
* On the stock backend every tool registered through here is answered by
|
|
3317
|
-
* dispatchStock -- which either has a table entry for the name or REFUSES BY
|
|
3318
|
-
* NAME. There is no third path and no fall-through, which is D-09's whole
|
|
3319
|
-
* point.
|
|
3320
|
-
*
|
|
3321
|
-
* WHAT NOT TO DO: never register a tool whose runner can reach `call()` /
|
|
3322
|
-
* `forwardToVice()` / `ensureViceSession()` without going through this
|
|
3323
|
-
* function. The one legitimate exception is a runner that touches no transport
|
|
3324
|
-
* at all (`vice_result_continue`, which only reads this proxy's own
|
|
3325
|
-
* CONTINUATION_STORE) -- and that exception is asserted, by name, in
|
|
3326
|
-
* stock-dispatch.test.ts's structural section rather than left to judgement.
|
|
3327
|
-
*/
|
|
3328
|
-
function buildBackendAwareTool(def: ToolDefinition, forkRun: (args: Record<string, unknown>) => Promise<ToolCallResult>) {
|
|
3329
|
-
return ACTIVE_BACKEND.backend === "fork"
|
|
3330
|
-
? buildViceTool(def, forkRun)
|
|
3331
|
-
: buildViceTool(def, (args) =>
|
|
3332
|
-
stockDispatch.dispatchStock(def.name, args, {
|
|
3333
|
-
ensureLease: ensureBrokerLease,
|
|
3334
|
-
resolvedBinaryPath: ACTIVE_BACKEND.binPath,
|
|
3335
|
-
resolvedBinaryPathIsResolved: ACTIVE_BACKEND.binPathResolved,
|
|
3336
|
-
}),
|
|
3337
|
-
);
|
|
3338
|
-
}
|
|
3339
|
-
|
|
1552
|
+
// The per-backend registration seam this section used to describe
|
|
1553
|
+
// (D-09, CR-07) is deleted: every tool this file
|
|
1554
|
+
// registers now dispatches through stockDispatch.dispatchStock()
|
|
1555
|
+
// unconditionally, which either has a table entry for the name or REFUSES
|
|
1556
|
+
// BY NAME. There is no third path and no fall-through -- D-09's whole
|
|
1557
|
+
// point, true by construction now rather than by a runtime backend check.
|
|
3340
1558
|
const tools: Record<string, ReturnType<typeof buildViceTool>> = {};
|
|
3341
1559
|
// Read ONCE and reused below for both the manifest loop and the two
|
|
3342
1560
|
// synthetic registrations' own resolveAdvertisedToolDefinition() calls --
|
|
3343
1561
|
// never re-read per registration (WR-07, plan 07-16).
|
|
3344
1562
|
const manifestTools = readManifestTools();
|
|
3345
1563
|
for (const def of manifestTools) {
|
|
3346
|
-
|
|
3347
|
-
tools[def.name] = buildBackendAwareTool(def, (args) => forwardToVice(def.name, args));
|
|
1564
|
+
tools[def.name] = buildViceTool(def, (args) => dispatchStockFor(def.name, args));
|
|
3348
1565
|
}
|
|
3349
1566
|
// Backend-INDEPENDENT by construction: handleResultContinue() is served
|
|
3350
1567
|
// entirely from this proxy's own CONTINUATION_STORE and opens no socket of any
|
|
3351
1568
|
// kind, so it is correct on either backend and is deliberately NOT routed
|
|
3352
1569
|
// through dispatchStock (which would refuse the continuation mechanism itself).
|
|
3353
1570
|
tools[RESULT_CONTINUE_TOOL.name] = buildViceTool(RESULT_CONTINUE_TOOL, (args) => Promise.resolve(handleResultContinue(args)));
|
|
3354
|
-
//
|
|
3355
|
-
//
|
|
3356
|
-
//
|
|
3357
|
-
//
|
|
3358
|
-
//
|
|
3359
|
-
//
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
//
|
|
3363
|
-
tools
|
|
3364
|
-
|
|
1571
|
+
// vice_recycle/vice_diagnose keep their own dedicated handlers
|
|
1572
|
+
// (handleRecycle()/handleDiagnose(), declared above) rather than going
|
|
1573
|
+
// through the manifest loop's own inline dispatchStock() call -- both
|
|
1574
|
+
// handlers delegate to dispatchStock() themselves now, so the observable
|
|
1575
|
+
// behaviour is identical either way, but the named handlers stay the
|
|
1576
|
+
// registration point so they remain independently locatable and testable.
|
|
1577
|
+
// WR-07 (plan 07-16): resolveAdvertisedToolDefinition() picks the corrected
|
|
1578
|
+
// stock manifest entry when one exists, falling back to the synthetic
|
|
1579
|
+
// RECYCLE_TOOL/DIAGNOSE_TOOL definition otherwise, so the advertised
|
|
1580
|
+
// tools/list entry stays correct even if the manifest is ever missing or
|
|
1581
|
+
// malformed.
|
|
1582
|
+
tools[RECYCLE_TOOL.name] = buildViceTool(stockDispatch.resolveAdvertisedToolDefinition(RECYCLE_TOOL, manifestTools), (args) => handleRecycle(args));
|
|
1583
|
+
tools[DIAGNOSE_TOOL.name] = buildViceTool(stockDispatch.resolveAdvertisedToolDefinition(DIAGNOSE_TOOL, manifestTools), (args) => handleDiagnose(args));
|
|
3365
1584
|
// Backend-INDEPENDENT by construction (plan 29-01): the anno_* family never
|
|
3366
1585
|
// touches VICE at all -- it reaches a PROXY-LOCAL SQLite annotation store
|
|
3367
1586
|
// this repo owns, opened and closed inside the runner itself, so there is no
|
|
3368
|
-
// fork/stock distinction to make
|
|
3369
|
-
//
|
|
3370
|
-
//
|
|
3371
|
-
//
|
|
3372
|
-
//
|
|
3373
|
-
//
|
|
3374
|
-
//
|
|
3375
|
-
//
|
|
3376
|
-
//
|
|
3377
|
-
//
|
|
3378
|
-
//
|
|
3379
|
-
// runner is never wired to forwardToVice() in the first place.
|
|
1587
|
+
// fork/stock distinction to make. The family is in NEITHER
|
|
1588
|
+
// tools-manifest.json NOR tools-manifest.stock.json: both are regenerated by
|
|
1589
|
+
// refresh-manifest.ts from a live HOST VICE server's own tools/list, and a
|
|
1590
|
+
// local store file is never that host -- a hand-added entry in either
|
|
1591
|
+
// manifest would be silently wiped on the next refresh.
|
|
1592
|
+
// Registered here via buildViceTool() directly (the SAME pattern
|
|
1593
|
+
// RESULT_CONTINUE_TOOL above uses), so no anno_* runner ever reaches
|
|
1594
|
+
// stockDispatch: there is no generic-dispatch surface left anywhere in this
|
|
1595
|
+
// file for a derived tool's runner to slip behind, so this exemption cannot
|
|
1596
|
+
// be violated by omission the way it could when a fork-only forwarding path
|
|
1597
|
+
// still existed.
|
|
3380
1598
|
// Deliberately NOT named `def` (the manifest loop's own loop variable,
|
|
3381
1599
|
// above): `stock-dispatch.test.ts`'s `proxyToolRegistrations()` regex-scans
|
|
3382
1600
|
// this file's own `tools[...] = ...;` lines and keys each one by its raw
|
|
3383
1601
|
// captured text, so an identically-named loop variable here would make this
|
|
3384
1602
|
// registration textually indistinguishable from the manifest loop's -- a
|
|
3385
|
-
// distinct name (`annoDef`) keeps the anno_* family's own
|
|
3386
|
-
//
|
|
3387
|
-
//
|
|
1603
|
+
// distinct name (`annoDef`) keeps the anno_* family's own registration from
|
|
1604
|
+
// ever being confused with, or accidentally merged into, the manifest
|
|
1605
|
+
// loop's.
|
|
3388
1606
|
for (const annoDef of ANNO_TOOL_DEFINITIONS) {
|
|
3389
1607
|
tools[annoDef.name] = buildViceTool(annoDef, (args) => runAnnoTool(annoDef.name, args));
|
|
3390
1608
|
}
|
|
@@ -3399,61 +1617,20 @@ await server.startStdio();
|
|
|
3399
1617
|
// dispatch always forces isError:false on success and prepends "Error: " on
|
|
3400
1618
|
// a thrown failure (read directly from @mastra/mcp's compiled source this
|
|
3401
1619
|
// session, not its docs), which matches neither this file's own
|
|
3402
|
-
// {content, isError} contract nor
|
|
1620
|
+
// {content, isError} contract nor a capability refusal's exact wording a
|
|
3403
1621
|
// pre-existing test pins verbatim -- so tools/call is answered entirely by
|
|
3404
1622
|
// this override, never by MCPServer's own handler. tools/list is NOT
|
|
3405
1623
|
// overridden -- MCPServer's own ListToolsRequestSchema handler answers it,
|
|
3406
1624
|
// the one piece of genuine library value this swap adopts.
|
|
3407
1625
|
server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3408
1626
|
const name = request.params.name;
|
|
3409
|
-
// Layer 1 (unchanged mechanism, now here instead of the retired
|
|
3410
|
-
// handleToolsCall()): call-time deny-list refusal, before any tool lookup
|
|
3411
|
-
// and before any network attempt -- independent from `tools`'s own
|
|
3412
|
-
// construction-time absence of vice_disk_list (layer 2, the
|
|
3413
|
-
// discovery-time enforcement tools/list reads from). Removing either
|
|
3414
|
-
// layer leaves the other standing.
|
|
3415
|
-
if (DENY_LIST.includes(name)) {
|
|
3416
|
-
return {
|
|
3417
|
-
content: [{ type: "text", text: denyListRefusalMessage(name) }],
|
|
3418
|
-
isError: true,
|
|
3419
|
-
};
|
|
3420
|
-
}
|
|
3421
|
-
// CLOSED BY 01.4-01 (tasks 1+2), closing Phase 01.4 criterion 3's
|
|
3422
|
-
// already-recorded open breach concern. This check inspects only the
|
|
3423
|
-
// OUTER `name` -- the literal MCP tool being called -- and always has;
|
|
3424
|
-
// that outer-name-only shape is unchanged by this fix and is NOT itself
|
|
3425
|
-
// the hazard. The hazard was that the manifest also lists the host's own
|
|
3426
|
-
// generic-surface meta-tools (`tools_call`/`tools_list`/`initialize`/
|
|
3427
|
-
// `notifications_initialized`) as ordinary forwardable tools, and
|
|
3428
|
-
// `tools_call` specifically could carry a forbidden name (e.g.
|
|
3429
|
-
// `vice_disk_list`) as a NESTED `arguments.name`, bypassing this exact
|
|
3430
|
-
// guard by never presenting the forbidden name as the OUTER one. All four
|
|
3431
|
-
// meta-tool names are now themselves on DENY_LIST (task 1 added
|
|
3432
|
-
// `tools_list`; task 2 added `tools_call`, `initialize` and
|
|
3433
|
-
// `notifications_initialized` after confirming, via a repo-wide grep, that
|
|
3434
|
-
// none has a sanctioned caller): `tools_call` itself is refused before its
|
|
3435
|
-
// own nested argument is ever read, closing the bypass without teaching
|
|
3436
|
-
// this guard to parse nested argument shapes -- one array, no new
|
|
3437
|
-
// mechanism, exactly 01.4-RESEARCH.md's own Pattern 1 and primary
|
|
3438
|
-
// recommendation. The historical bypass-proving test in
|
|
3439
|
-
// vice-proxy.test.ts is repointed (not deleted) to assert this closure.
|
|
3440
|
-
// Full history in 01.6.3-03-SUMMARY.md and
|
|
3441
|
-
// .planning/todos/pending/2026-08-05-generic-surface-deny-list-gap-tools-call-nested-vice-disk-list.md.
|
|
3442
1627
|
const tool = tools[name];
|
|
3443
1628
|
if (!tool || !tool.execute) {
|
|
3444
|
-
//
|
|
3445
|
-
//
|
|
3446
|
-
//
|
|
3447
|
-
//
|
|
3448
|
-
//
|
|
3449
|
-
// This lookup renders undefined for a genuinely unknown name (or a
|
|
3450
|
-
// same-backend miss), so a real typo still falls through to the generic
|
|
3451
|
-
// message below unchanged. capability-registry.ts is the ONE place to
|
|
3452
|
-
// edit this data -- never hand-add a per-tool special case here.
|
|
3453
|
-
const capabilityRefusal = capabilityRefusalMessage(name, ACTIVE_BACKEND.backend);
|
|
3454
|
-
if (capabilityRefusal !== undefined) {
|
|
3455
|
-
return { content: [{ type: "text", text: capabilityRefusal }], isError: true };
|
|
3456
|
-
}
|
|
1629
|
+
// Fires ONLY when the stock manifest (D-07) never registered this name
|
|
1630
|
+
// -- i.e. `tools` has no key for it. There is one backend now, so an
|
|
1631
|
+
// unrecognised name is simply unknown: no per-capability refusal lookup
|
|
1632
|
+
// remains to distinguish "this tool exists on some other backend" from
|
|
1633
|
+
// "this is a typo" -- there is nothing else it could be.
|
|
3457
1634
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
3458
1635
|
}
|
|
3459
1636
|
try {
|
|
@@ -3466,24 +1643,42 @@ server.getServer().setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3466
1643
|
isError: true,
|
|
3467
1644
|
};
|
|
3468
1645
|
}
|
|
1646
|
+
// Restores wrapPossiblyChunked()'s only call site. buildViceTool() stamps
|
|
1647
|
+
// OUTPUT_CHAR_CAP onto EVERY tool's `_meta` unconditionally, so the
|
|
1648
|
+
// ceiling has to be honoured for every tool -- and this override is the
|
|
1649
|
+
// one place all four registration families (the manifest loop,
|
|
1650
|
+
// vice_recycle/vice_diagnose, the anno_* loop, and vice_result_continue
|
|
1651
|
+
// itself) converge on a single result before it reaches the wire. A
|
|
1652
|
+
// previous edit deleted this function's only caller and left the
|
|
1653
|
+
// function itself in place: for the whole life of one release a
|
|
1654
|
+
// registered continuation tool could only ever refuse an unknown token,
|
|
1655
|
+
// and an oversized result -- MEASURED at 23,290 characters under a
|
|
1656
|
+
// 200-character advertised cap -- crossed the wire whole, unchunked.
|
|
1657
|
+
// Only a single-item text success is a candidate for the split: an
|
|
1658
|
+
// `isError: true` result carries a refusal or a diagnostic, never a
|
|
1659
|
+
// payload, and a result already carrying a marker item (this tool's own
|
|
1660
|
+
// continuation replies, or any future multi-item producer) must never be
|
|
1661
|
+
// wrapped a second time -- this one condition keeps both out without
|
|
1662
|
+
// naming either by name.
|
|
1663
|
+
if (raw.isError === false && raw.content.length === 1 && raw.content[0].type === "text" && typeof raw.content[0].text === "string") {
|
|
1664
|
+
return toolCallResultToWire(wrapPossiblyChunked(raw.content[0].text));
|
|
1665
|
+
}
|
|
3469
1666
|
return toolCallResultToWire(raw);
|
|
3470
1667
|
} catch (e) {
|
|
3471
1668
|
// The never-throw discipline this file already lives by (matching the
|
|
3472
1669
|
// retired handleToolsCall()'s own "NEVER rethrow past this point"
|
|
3473
|
-
// comment) --
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
1670
|
+
// comment) -- every registered tool's own runner should never actually
|
|
1671
|
+
// throw in normal operation, but this override must not depend on that
|
|
1672
|
+
// being true.
|
|
3476
1673
|
return { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], isError: true };
|
|
3477
1674
|
}
|
|
3478
1675
|
});
|
|
3479
1676
|
|
|
3480
|
-
// Log-line
|
|
3481
|
-
//
|
|
1677
|
+
// Log-line (plan 02-10, collapsed to the stock-only message once the fork
|
|
1678
|
+
// arm was deleted): the stock arm cannot yet name a real instance/port -- no
|
|
3482
1679
|
// acquisition has happened at process startup, only lazily on the first
|
|
3483
1680
|
// tools/call -- so it names the backend and the binary-monitor target
|
|
3484
1681
|
// instead of a coordinate pair that does not exist yet.
|
|
3485
1682
|
console.error(
|
|
3486
|
-
|
|
3487
|
-
? `vice-proxy: ready, forwarding to ${activeInstance().url} (port ${activeInstance().port})`
|
|
3488
|
-
: `vice-proxy: ready, stock backend active -- dispatching to a broker-claimed binary-monitor instance (resolved binary: ${ACTIVE_BACKEND.binPath})`,
|
|
1683
|
+
`vice-proxy: ready, stock backend active -- dispatching to a broker-claimed binary-monitor instance (resolved binary: ${RESOLVED_BINARY.binPath})`,
|
|
3489
1684
|
);
|