@henols/vice-mcp 0.2.2 → 0.2.4

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.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -2,29 +2,32 @@
2
2
  // Compiled by `tsc` from broker-launch.mts. Edit the TypeScript source and rebuild;
3
3
  // changes made directly to this file are silently overwritten by the next build, and are never
4
4
  // deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
5
- // verbatim to tools/, so an edit made only here reaches the host but is lost on the very next
5
+ // verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
6
6
  // rebuild.
7
7
  // broker-launch.mts
8
8
  //
9
- // C (complete, plan 02 of Phase 01.6.2): the single `in_flight` launch-guard
10
- // owner (plan 01, unchanged -- every launch call site in the whole broker
11
- // goes through tryLaunchOne(), which is what makes the single-owner
12
- // guarantee mechanical rather than a convention plan 02's own concurrency
13
- // race test can silently violate), PLUS the readiness probe's single
14
- // in-process mechanism (collapsed from a three-way branch by Phase
15
- // 01.6.2.1's own plan 02 -- D-05 as amended by P-05/P-06/P-07; see
16
- // probeReady()'s own header comment below for the amendment's record),
17
- // serialised warm-floor maintenance (one launch per pass, never more), and
18
- // the fixed-order evaluation pass both surviving concerns run through.
9
+ // This module owns three concerns that started life separately and were
10
+ // folded together here: the single `in_flight` launch-guard owner -- every
11
+ // launch call site in the whole broker goes through tryLaunchOne(), which
12
+ // is what makes the single-owner guarantee mechanical rather than a
13
+ // convention a concurrency race test could silently violate -- PLUS the
14
+ // readiness probe's single in-process mechanism (collapsed from a
15
+ // three-way branch down to one; see probeReady()'s own header comment
16
+ // below for the full record of that collapse and its later amendment),
17
+ // the launching -> ready promotion sweep (promoteLaunchingInstances() --
18
+ // this used to be step one inside a warm-floor maintenance function that
19
+ // speculatively pre-launched spare instances; that floor is RETIRED and
20
+ // VICE now launches strictly on demand, but the promotion sweep outlived
21
+ // it), and the fixed-order evaluation pass both surviving concerns run
22
+ // through.
19
23
  //
20
- // Plan 03, Task 2 grows this module into a real per-child supervisor
21
- // (C2/D-23), absorbing resources/vice-supervisor.sh wholesale: superviseChild()
24
+ // This file also grew a real per-child supervisor: superviseChild()
22
25
  // launches an instance through tryLaunchOne() (the SAME single guarded
23
26
  // primitive above) and installs an exit handler on the spawned child that
24
27
  // respawns on crash (doubling backoff, clamped at a ceiling), gives up
25
28
  // cleanly after too many crashes inside a window, never respawns a
26
29
  // deliberately-killed instance, and writes the per-instance boot/crash log
27
- // D-23 preserves at the exact path shape the retiring bash supervisor used.
30
+ // at the exact path shape the retiring bash supervisor used.
28
31
  import { spawn as nodeSpawn } from "node:child_process";
29
32
  import { mkdirSync, mkdtempSync, openSync, closeSync, existsSync } from "node:fs";
30
33
  import { join, basename } from "node:path";
@@ -33,13 +36,13 @@ import { tmpdir } from "node:os";
33
36
  // synchronous check, synchronous set, released in a finally, with no
34
37
  // `await` between the check and the set.
35
38
  //
36
- // D-07 (01.6.2.1-03-PLAN.md): launch PRIORITY is layered on this owner, and
37
- // never replaces or weakens it. An in-flight boot always completes and is
38
- // NEVER killed or abandoned to serve a later arrival -- preemption was
39
- // considered and rejected (01.6.2-CONTEXT.md D-07) because a kill/relaunch
40
- // overlap re-creates the exact concurrent-spawn window the 2026-08-01
41
- // outage came from (one SEGV, one exit 1, one exit 0 at the identical spawn
42
- // second). Once a boot reaches `ready`, a waiting request takes it
39
+ // Launch PRIORITY is layered on this owner, and never replaces or weakens
40
+ // it. An in-flight boot always completes and is NEVER killed or abandoned
41
+ // to serve a later arrival -- preemption was considered and rejected
42
+ // because a kill/relaunch overlap re-creates the exact concurrent-spawn
43
+ // window the 2026-08-01 outage came from (one SEGV, one exit 1, one exit 0
44
+ // at the identical spawn second). Once a boot reaches `ready`, a waiting
45
+ // request takes it
43
46
  // regardless of which reason booted it (vice-broker.mts's
44
47
  // selectWarmInstance() performs no `reason` check at all -- proven by
45
48
  // vice-broker-acquire.test.ts). Priority governs only which REASON wins
@@ -50,10 +53,9 @@ let inFlight = false;
50
53
  // only, never a second guard: nothing branches on this value's presence to
51
54
  // decide whether a launch may proceed (that is `inFlight` alone, checked
52
55
  // and set synchronously exactly as before). Its only consumer is the
53
- // launch-slot decision log line (D-07's standing constraint that a
54
- // lifecycle decision must be reconstructable from the log after an
55
- // incident -- both 2026-08-01 and 2026-08-02 were diagnosed from broker log
56
- // lines).
56
+ // launch-slot decision log line: a lifecycle decision must be
57
+ // reconstructable from the log after an incident -- both the 2026-08-01
58
+ // and 2026-08-02 outages were diagnosed from broker log lines.
57
59
  let inFlightReason = null;
58
60
  /** True while a launch is in progress -- exported for the race test plan 02
59
61
  * writes against two concurrent tryLaunchOne() calls. */
@@ -65,12 +67,43 @@ export function isLaunchInFlight() {
65
67
  // most once per process -- the repo-root.ts `warnedEnvOutsideFrom` gate
66
68
  // pattern, reused here.
67
69
  let warnedBinmonBindWidened = false;
68
- // Plan 03-04 (DIRECT-06, D-13): the SAME one-time-note idiom as
69
- // warnedBinmonBindWidened above, for the SECOND (`-remotemonitor`) port's
70
- // bind -- a separate boolean because the two flags widen independently (a
71
- // caller could widen one host override and not the other, though in
72
- // practice both resolve from the same `binmonHost` value below).
70
+ // The SAME one-time-note idiom as warnedBinmonBindWidened above, for the
71
+ // SECOND (`-remotemonitor`) port's bind -- a separate boolean because the
72
+ // two flags widen independently (a caller could widen one host override
73
+ // and not the other, though in practice both resolve from the same
74
+ // `binmonHost` value below).
73
75
  let warnedRemoteMonitorBindWidened = false;
76
+ /** The random seed the stock determinism block pins, and the exact value
77
+ * the reproduction was measured with on this host -- exported so a capture
78
+ * record's reproducibility key can cite ONE definition rather than
79
+ * re-deriving a literal that could silently drift away from the launches it
80
+ * claims to describe. MEASURED 2026-09-02 against genuine stock 3.9 over
81
+ * `-binarymonitor`: two cold boots WITHOUT the block differ at 59 of the
82
+ * 4080 addresses in the untouched `$C000-$CFEF` window; WITH it, at 0 of
83
+ * 4080. */
84
+ export const STOCK_DETERMINISM_SEED = 4242;
85
+ /** The determinism block, in ONE fixed order, emitted UNCONDITIONALLY by
86
+ * the stock branch below (never gated on `profile` -- see buildViceArgs()'s
87
+ * own comment above `args`). Exported and frozen so tests and evidence
88
+ * scripts assert against this single definition instead of a second
89
+ * hand-copied array, and so no caller can mutate the shared value into a
90
+ * launch that no longer matches the recorded seed.
91
+ *
92
+ * The order is fixed and load-bearing beyond readability: capture records
93
+ * key on an argv digest, so a block whose element order varied between two
94
+ * launches on the same port would produce two digests for one launch
95
+ * intent. */
96
+ export const STOCK_DETERMINISM_FLAGS = Object.freeze([
97
+ "-seed",
98
+ String(STOCK_DETERMINISM_SEED),
99
+ "-raminitstartrandom",
100
+ "0",
101
+ "-raminitrepeatrandom",
102
+ "0",
103
+ "-raminitrandomchance",
104
+ "0",
105
+ "+autostart-delay-random",
106
+ ]);
74
107
  /** Resolves the emulator's own argument vector for the given `backend`. The
75
108
  * `VICE_ARGS` full-override short-circuit (matching
76
109
  * resources/vice-supervisor.sh's own VICE_ARGS convention exactly) is
@@ -80,42 +113,50 @@ let warnedRemoteMonitorBindWidened = false;
80
113
  * understands neither `-mcpserver` nor `-binarymonitor` flags, and that need
81
114
  * does not depend on which backend is configured.
82
115
  *
83
- * `backend: "fork"` returns exactly the pre-Phase-2 shape, byte-identical:
84
- * the MCP server flag, the MCP server host from `mcpHost` or
85
- * VICE_BROKER_MCP_HOST (default `0.0.0.0`), and the MCP server port.
116
+ * `backend: "stock"` (the only value `ViceBackend` has, now that the fork
117
+ * backend is gone) returns `-binarymonitor -binarymonitoraddress
118
+ * ip4://<host>:<port>`, the confirmed real-world command line. The host
119
+ * resolves from `binmonHost` or VICE_BROKER_BINMON_HOST, defaulting to
120
+ * `127.0.0.1` -- deliberately narrow, because VICE's binary monitor is
121
+ * unauthenticated by design and grants full read/write over the emulated
122
+ * machine plus process control to anything that can reach it. Widening the
123
+ * bind away from loopback emits exactly one stderr note per process,
124
+ * naming the resolved bind address and what the exposure grants.
86
125
  *
87
- * `backend: "stock"` returns `-binarymonitor -binarymonitoraddress
88
- * ip4://<host>:<port>` (docs/phase1-probe-results.md's confirmed real-world
89
- * command line). The host resolves from `binmonHost` or
90
- * VICE_BROKER_BINMON_HOST, defaulting to `127.0.0.1` -- deliberately
91
- * narrower than the fork path's `0.0.0.0` default, because VICE's binary
92
- * monitor is unauthenticated by design and grants full read/write over the
93
- * emulated machine plus process control to anything that can reach it
94
- * (planner decision, `02-03-PLAN.md`). Widening the bind away from loopback
95
- * emits exactly one stderr note per process, naming the resolved bind
96
- * address and what the exposure grants.
126
+ * When `remoteMonitorPort` is a number, the stock branch APPENDS
127
+ * `-remotemonitor -remotemonitoraddress ip4://<host>:<remoteMonitorPort>`,
128
+ * reusing the SAME resolved `host` value the binmon address already used --
129
+ * one resolution, not two. When `remoteMonitorPort` is omitted (undefined),
130
+ * the returned argv is byte-identical to what this function always
131
+ * returned -- no `-remotemonitor` at all. `-remotemonitoraddress`'s exact
132
+ * spelling was live-probed against a real fork-3.10 binary and CONFIRMED:
133
+ * the flag bound a real, accepting text-monitor listener, corroborated
134
+ * independently by `ss -ltnp`. Genuine stock 3.9 was not independently
135
+ * probed in that run -- the spelling itself is a symmetrical CLI flag pair
136
+ * and is not version-sensitive, so this is recorded as a low-risk
137
+ * carry-forward rather than implied stock-3.9 coverage. Widening THIS bind
138
+ * away from loopback emits its own one-time stderr note
139
+ * (`warnedRemoteMonitorBindWidened`), naming the resolved address and
140
+ * stating that VICE's TEXT monitor accepts arbitrary monitor commands and
141
+ * is unauthenticated. text-connect.ts's textConnect() dials this port, and
142
+ * it is MANDATORY on every stock launch -- a stock launch that cannot bind
143
+ * it now fails the whole acquire rather than launching without it, because
144
+ * the flag has to be set at launch time: adding it later would require
145
+ * relaunching a live instance, destroying all emulation state.
97
146
  *
98
- * Plan 03-04 (DIRECT-06, D-13): when `remoteMonitorPort` is a number, the
99
- * stock branch APPENDS `-remotemonitor -remotemonitoraddress
100
- * ip4://<host>:<remoteMonitorPort>`, reusing the SAME resolved `host` value
101
- * the binmon address already used -- one resolution, not two. When
102
- * `remoteMonitorPort` is omitted (undefined), the returned argv is
103
- * byte-identical to what this function always returned -- no
104
- * `-remotemonitor` at all. `-remotemonitoraddress`'s exact spelling was
105
- * live-probed against a real fork-3.10 binary and CONFIRMED (RESEARCH.md
106
- * Assumption A1, closed by 13-PROBE-RESULTS.md §A1: the flag bound a real,
107
- * accepting text-monitor listener, corroborated independently by
108
- * `ss -ltnp`). Genuine stock 3.9 was not independently probed in that run --
109
- * the spelling itself is a symmetrical CLI flag pair and is not
110
- * version-sensitive, so this is recorded as a low-risk carry-forward rather
111
- * than implied stock-3.9 coverage. Widening THIS bind away from loopback emits
112
- * its own one-time stderr note (`warnedRemoteMonitorBindWidened`), naming
113
- * the resolved address and stating that VICE's TEXT monitor accepts
114
- * arbitrary monitor commands and is unauthenticated -- Phase 3 dials
115
- * nothing on this port; only the launch flag lands now (see D-13's own
116
- * rationale: adding the flag later would require relaunching a live
117
- * instance, destroying all emulation state). */
118
- export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, }) {
147
+ * The stock branch also emits STOCK_DETERMINISM_FLAGS unconditionally, and
148
+ * takes an optional `profile` for the two additive launch knobs. Deliberately
149
+ * in the same register as tryLaunchOne's own widened `spawn` field below,
150
+ * and for the same reason: `profile` is optional, so every pre-existing
151
+ * caller and every pre-existing test stub keeps compiling and behaving
152
+ * identically, and an ABSENT profile produces exactly the same argv as an
153
+ * empty one or one whose knobs are both `false`. What optionality could NOT
154
+ * save: the determinism block is unconditional on stock, so all FIVE stock
155
+ * whole-argv assertions in broker-launch.test.ts move even with `profile`
156
+ * absent -- it is the block and not the profile that moves them. The
157
+ * byte-identity claim therefore survives in full only for the `profile`
158
+ * half: an absent profile adds no flag. */
159
+ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv, remoteMonitorPort, profile, }) {
119
160
  const rawViceArgs = viceArgsEnv ?? process.env.VICE_ARGS;
120
161
  if (typeof rawViceArgs === "string" && rawViceArgs.trim() !== "") {
121
162
  return rawViceArgs.trim().split(/\s+/);
@@ -138,22 +179,116 @@ export function buildViceArgs(port, { backend, mcpHost, binmonHost, viceArgsEnv,
138
179
  // first element: it is VICE's reset-to-compiled-in-defaults instruction,
139
180
  // not an inert "these are the baselines" no-op, so any flag emitted
140
181
  // before it (including -drive8type) is silently clobbered back to its
141
- // compiled-in value. `-drive8type 1541` therefore has to come
142
- // immediately after `-default`, and -- per CLAUDE.md's documented
143
- // constraint -- `-default` also has to come before `-binarymonitor` or
144
- // the monitor never binds and the subsequent connect hangs in the
145
- // backlog looking exactly like a wedge. Confirmed sufficient live in
146
- // Phase 8.1's standalone probe (08.1-WALKTHROUGH-EVIDENCE.md §4):
182
+ // compiled-in value. `-drive8type 1541` therefore has to come AFTER
183
+ // `-default` -- not necessarily IMMEDIATELY after -- and -- per
184
+ // CLAUDE.md's documented constraint -- `-default` also has to come before
185
+ // `-binarymonitor` or the monitor never binds and the subsequent connect
186
+ // hangs in the backlog looking exactly like a wedge.
187
+ //
188
+ // WORDING CORRECTED. This paragraph used to say "immediately after
189
+ // `-default`", which the `-console` block below now violates by
190
+ // construction whenever `profile.headless` is set -- leaving the next
191
+ // editor to find code contradicting the comment and having to re-derive
192
+ // which one is authoritative. What is load-bearing is the RELATIVE
193
+ // ORDER (`-default` precedes everything it resets), not adjacency.
194
+ //
195
+ // The `-console` block's own citation was `alive=yes bound=1`, which does
196
+ // NOT cover this paragraph's property: the failure mode this flag guards
197
+ // against is Drive8Type silently reverting to 0 (NONE) WHILE THE MONITOR
198
+ // STILL BINDS FINE, so liveness and boundness cannot tell the good case
199
+ // from the failure being guarded against. Re-verified against the
200
+ // resource itself
201
+ // [VERIFIED: live probe 2026-09-03, genuine unpatched stock
202
+ // /usr/bin/x64sc (VICE 3.9), DISPLAY and WAYLAND_DISPLAY both unset]:
203
+ // [-default -console -drive8type 1541 <determinism> -binarymonitor]
204
+ // alive=yes bound=1 Drive8Type=1541 Drive8TrueEmulation=1
205
+ // read over `RESOURCE_GET` (0x51) with `-console` interposed. So the
206
+ // citation now covers the RESOURCE and not only liveness. Confirmed
207
+ // sufficient live in a standalone probe:
147
208
  // `resourceget "Drive8Type"` moved 0 -> 1541 and a `load` over the text
148
209
  // monitor succeeded immediately. Deliberately NOT setting
149
210
  // -drive8truedrive / Drive8TrueEmulation here: this build's own default
150
- // already reads Drive8TrueEmulation=1 (same probe), so 08.2-RESEARCH.md's
151
- // primary recommendation is that only -drive8type needs adding.
152
- // Assumption A3 in that doc's Assumptions Log (some other stock build
153
- // might default Drive8TrueEmulation to 0) is read and deliberately not
154
- // pre-emptively defended against here; plan 03's live test is what would
155
- // surface it if that assumption is ever wrong on a different build.
156
- const args = ["-default", "-drive8type", "1541", "-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`];
211
+ // already reads Drive8TrueEmulation=1 (same probe), so only
212
+ // `-drive8type` needs adding.
213
+ // A different stock build might default Drive8TrueEmulation to 0,
214
+ // which is read and deliberately not pre-emptively defended against
215
+ // here; a live test against that build is what would surface it if
216
+ // this assumption is ever wrong there.
217
+ //
218
+ // The headless route is `-console`, and its POSITION is as
219
+ // load-bearing as `-default`'s. `-console` is handled in the SAME
220
+ // `main.c` pre-scan as `-default` -- that loop `break`s at the first
221
+ // option it does not recognise and then strips the prefix it handled
222
+ // from argv
223
+ // [CITED: vice-3.8/src/main.c:184-192, 232-238] -- and `console_mode`
224
+ // gates GTK initialisation at two call sites (`ui_init_with_args`,
225
+ // `ui_init`) that BOTH run before the late command-line parser
226
+ // `initcmdline_check_args()` [CITED: vice-3.8/src/main.c:296-345]. A
227
+ // `-console` seen only by the late parser therefore arrives after GTK has
228
+ // already tried and failed. MEASURED 2026-09-02 with `DISPLAY` and
229
+ // `WAYLAND_DISPLAY` both unset:
230
+ // [-default -console -binarymonitor] alive=yes bound=1
231
+ // [-default -drive8type 1541 -console -binarymonitor] alive=no bound=0 Gtk-WARNING: cannot open display:
232
+ // [-default -console -drive8type 1541 -binarymonitor] alive=yes bound=1
233
+ // So `-console` goes immediately after `-default` and BEFORE
234
+ // `-drive8type` -- which is compatible with the paragraph above as
235
+ // corrected: that constraint is `-drive8type` AFTER `-default`, not
236
+ // adjacent to it, and the interposition was re-verified over
237
+ // `RESOURCE_GET` to leave Drive8Type=1541 rather than only to leave
238
+ // the monitor bound. It is pinned by an ordering assertion rather than by
239
+ // this comment -- a bare flag push with no reason is exactly what let the
240
+ // `-default` ordering constraint be rediscovered by a red CI run last
241
+ // time.
242
+ //
243
+ // The determinism block is emitted UNCONDITIONALLY on stock, never
244
+ // gated on `profile`. Read over `RESOURCE_GET` (0x51) on this build
245
+ // under `-default -drive8type 1541`
246
+ // [VERIFIED: live probe 2026-09-02],
247
+ // `-raminitrandomchance 0` is the LOAD-BEARING one: the factory value is
248
+ // **10**, i.e. 0.1% of all RAM bits randomly flipped at power-up, and it is
249
+ // the dominant term in the divergence this milestone removes. The
250
+ // `-raminitstartrandom 0` / `-raminitrepeatrandom 0` pair already reads 0
251
+ // at factory and is DEFENSIVE against an operator `vicerc` -- belt and
252
+ // braces alongside the scratch `XDG_CONFIG_HOME` threaded in
253
+ // spawnAndRecordInstance() below.
254
+ //
255
+ // `+autostart-delay-random` is a FIFTH flag beyond the determinism
256
+ // block's own headline text (which names only `-seed` plus the three
257
+ // `raminit*`), recorded here as a deliberate ADDITION rather than
258
+ // smuggled in. `AutostartDelayRandom`
259
+ // ships at **1** on this build [VERIFIED: same probe], it draws an
260
+ // additional random delay of up to 10 frames
261
+ // [CITED: vice-3.8/src/autostart.c:1432-1436], and -- the effect that is
262
+ // easy to miss -- it also SELECTS WHICH keyboard-buffer feed injects `RUN`
263
+ // (`kbdbuf_feed_runcmd` when set, `kbdbuf_feed` when clear)
264
+ // [CITED: vice-3.8/src/autostart.c:882-886]. Disabling it is therefore a
265
+ // behavioural change and not only a timing one. Pin it once; never toggle
266
+ // it between the two runs of a capture pair.
267
+ //
268
+ // `-warp` is position-free (MEASURED 2026-09-02), placed here only so
269
+ // the argv reads in the order a human would describe it. Launch-time is
270
+ // the ONLY route on stock: there is no runtime `WarpMode` resource at
271
+ // all (`RESOURCE_GET` replies `err=0x01` OBJECT_MISSING on 3.9), so no
272
+ // runtime setter can exist here. Worth roughly **1.97x** on this host
273
+ // and this launch profile -- 5.23 emulated seconds against 2.65 over
274
+ // the same 5 s wall clock [MEASURED: evidence/33-wallclock-control.md,
275
+ // Control B instance 1] -- and NOT the order of magnitude a reader may
276
+ // assume; `AUTOSTART` additionally turns warp on by itself during the
277
+ // load whatever argv says, so both loads are warped either way. It is
278
+ // behaviour-neutral under a frame-anchored protocol (identical registers
279
+ // and one identical 64K sha256 across a warped and an unwarped run, same
280
+ // source), which is a precondition for shipping `profile.warp`; it is
281
+ // NOT neutral for a wall-clock bracket, which it invalidates by 1.76x.
282
+ const args = ["-default"];
283
+ if (profile?.headless) {
284
+ args.push("-console");
285
+ }
286
+ args.push("-drive8type", "1541");
287
+ args.push(...STOCK_DETERMINISM_FLAGS);
288
+ if (profile?.warp) {
289
+ args.push("-warp");
290
+ }
291
+ args.push("-binarymonitor", "-binarymonitoraddress", `ip4://${host}:${port}`);
157
292
  if (typeof remoteMonitorPort === "number") {
158
293
  if (host !== "127.0.0.1" && !warnedRemoteMonitorBindWidened) {
159
294
  warnedRemoteMonitorBindWidened = true;
@@ -186,42 +321,56 @@ function spawnAndRecordInstance(reason, port, deps) {
186
321
  const spawnFn = deps.spawn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
187
322
  const now = deps.now ?? (() => Date.now());
188
323
  const viceBin = deps.viceBin ?? process.env.VICE_BIN ?? "x64sc";
189
- const backend = deps.backend ?? "fork";
324
+ const backend = deps.backend ?? "stock";
325
+ // The ONE construction site for a fresh InstanceRecord asserts the
326
+ // invariant every downstream consumer (HeldLease, textConnect(), etc.) was
327
+ // written against -- a stock record NEVER lacks a text-monitor port.
328
+ // acquirePortAndLaunch() above already fails the whole acquire before ever
329
+ // reaching this function when the second allocation fails, so a caller
330
+ // that lands here with `backend: "stock"` and no `remoteMonitorPort` is a
331
+ // defect in THIS module (a call site that bypassed that guarantee), not a
332
+ // state a stock record may legitimately carry -- throw by name rather
333
+ // than silently writing a record that violates it.
334
+ // The fork case is real and unaffected: this check is stock-only.
335
+ if (backend === "stock" && deps.remoteMonitorPort === undefined) {
336
+ throw new Error("spawnAndRecordInstance: backend \"stock\" requires remoteMonitorPort -- a stock launch that cannot bind a text-monitor port must fail the acquire before reaching this construction site, never write a portless stock record");
337
+ }
190
338
  const viceArgs = buildViceArgs(port, {
191
339
  backend,
192
340
  mcpHost: deps.mcpHost,
193
341
  binmonHost: deps.binmonHost,
194
342
  remoteMonitorPort: deps.remoteMonitorPort,
343
+ // The ONE place a launch's profile becomes argv. The record built
344
+ // below mirrors the SAME value, so an instance's recorded profile and
345
+ // its actual argv are written in one step and cannot disagree.
346
+ profile: deps.profile,
195
347
  });
196
348
  const log = deps.log ?? defaultLog;
197
- // I-1 rider (audit §4.4, 08.2-02-PLAN.md Task 2): production stock
198
- // launches used to set no scratch XDG_CONFIG_HOME and would read whatever
199
- // vicerc the operator's own $HOME already carried -- shared with the
200
- // operator's own VICE usage and with the fork build. For backend ===
201
- // "stock" only, compute a fresh, isolated config dir with mkdtempSync
202
- // (atomic creation, random suffix, 0700 permissions -- the primitive that
203
- // makes a collision or a symlink-swap into the operator's real config
204
- // unreachable) and pass it as a third options argument carrying `env`
205
- // only. Never `shell: true`: the existing array-form spawn(viceBin,
206
- // viceArgs) call avoids shell interpretation entirely and that property
207
- // must survive this widening. For backend === "fork", spawnFn is called
208
- // with NO third argument at all, so the fork path's observable behaviour
209
- // stays bit-for-bit what it was (BACK-02 is a standing gate and the fork
210
- // backend has been the sole production backend across all of v0.1.x).
349
+ // Production stock launches used to set no scratch XDG_CONFIG_HOME and
350
+ // would read whatever vicerc the operator's own $HOME already carried --
351
+ // shared with the operator's own VICE usage and with the fork build. For
352
+ // backend === "stock" only, compute a fresh, isolated config dir with
353
+ // mkdtempSync (atomic creation, random suffix, 0700 permissions -- the
354
+ // primitive that makes a collision or a symlink-swap into the operator's
355
+ // real config unreachable) and pass it as a third options argument
356
+ // carrying `env` only. Never `shell: true`: the existing array-form
357
+ // spawn(viceBin, viceArgs) call avoids shell interpretation entirely and
358
+ // that property must survive this widening.
211
359
  //
212
360
  // Scope boundary (do not remove this note): the production broker daemon
213
361
  // always supplies its own deps.spawn / deps.spawnFactory, so the widened
214
362
  // default wrapper above is dead code on the real launch paths. This
215
363
  // function's job is only to COMPUTE the value at the one seam that should
216
- // own it; the forwarding to nodeSpawn() happens at four further hops --
217
- // makeLoggingSpawn() and maintainWarmFloorForRealBroker's inner
218
- // stashingSpawn in vice-broker.mts, and withCrashSupervision()'s wrapper
219
- // body and launchSupervised()'s defaultRealSpawn in this file. All four
220
- // now forward the options argument (plan 08.2-06 closed them in this same
221
- // phase, with a handleAcquire() composition test that omits
222
- // buildColdSpawnFactory so an injected stub cannot fake the proof). If you
223
- // add a fifth spawn hop, it must forward options too, or production stock
224
- // launches silently lose their config isolation again.
364
+ // own it; the forwarding to nodeSpawn() happens at three further hops --
365
+ // makeLoggingSpawn() in vice-broker.mts, and withCrashSupervision()'s
366
+ // wrapper body and launchSupervised()'s defaultRealSpawn in this file (a
367
+ // fourth hop, the retired warm floor's own inner stashingSpawn closure in
368
+ // vice-broker.mts, is REMOVED along with the function that held it). All
369
+ // three now forward the options argument, with a handleAcquire()
370
+ // composition test that omits buildColdSpawnFactory so an injected stub
371
+ // cannot fake the proof. If you add another spawn hop, it must forward
372
+ // options too, or production stock launches silently lose their config
373
+ // isolation again.
225
374
  //
226
375
  // Scratch-dir lifetime: this function deliberately does NOT clean the
227
376
  // directory up -- the spawned emulator process outlives this function's
@@ -254,7 +403,26 @@ function spawnAndRecordInstance(reason, port, deps) {
254
403
  viceBin,
255
404
  viceArgs,
256
405
  dryRun: false,
406
+ // Non-optional, defaulted to an empty map -- "no claim on any channel"
407
+ // is an empty map, never an absent field. The ONE place a fresh
408
+ // InstanceRecord is constructed, so this is the ONE place this default
409
+ // is set.
410
+ monitorClients: {},
411
+ // Key omitted only on the FORK path now -- the guard above already
412
+ // throws before this point for any stock call with no
413
+ // remoteMonitorPort, so a stock record reaching this line always
414
+ // supplies the key. "Absent" means fork, never "stock allocation
415
+ // failed" (that state no longer exists).
257
416
  ...(deps.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: deps.remoteMonitorPort }),
417
+ // Same key-omitted-when-undefined idiom as remoteMonitorPort directly
418
+ // above. An absent request must produce a record with NO `profile` key
419
+ // at all -- not `profile: undefined` -- because "absent means
420
+ // profile-less" is the property a broker restarted mid-phase relies on
421
+ // when it reads records written before this field existed. A copy, not
422
+ // the caller's own object: the record outlives this call and a caller
423
+ // mutating its profile afterwards must not silently change what this
424
+ // instance claims it was launched with.
425
+ ...(deps.profile === undefined ? {} : { profile: { ...deps.profile } }),
258
426
  };
259
427
  deps.state.instances.set(port, record);
260
428
  return record;
@@ -271,10 +439,10 @@ function spawnAndRecordInstance(reason, port, deps) {
271
439
  *
272
440
  * This is the RIGHT primitive when the port is already decided and fixed
273
441
  * (most tests; any future caller with its own allocation scheme). It is
274
- * deliberately NOT what handleAcquire or maintainWarmFloor call for a
275
- * FRESH port, because nextFreePort() itself is asynchronous (a real
276
- * port-in-use probe requires it) -- see acquirePortAndLaunch()'s own
277
- * header comment for the race that creates and how it is closed. */
442
+ * deliberately NOT what handleAcquire calls for a FRESH port, because
443
+ * nextFreePort() itself is asynchronous (a real port-in-use probe requires
444
+ * it) -- see acquirePortAndLaunch()'s own header comment for the race that
445
+ * creates and how it is closed. */
278
446
  export function tryLaunchOne(reason, port, deps) {
279
447
  if (inFlight)
280
448
  return null;
@@ -290,34 +458,45 @@ export function tryLaunchOne(reason, port, deps) {
290
458
  * allocate-a-port-then-launch sequence -- not merely the synchronous spawn
291
459
  * instant tryLaunchOne() alone guards. This closes a genuine race window
292
460
  * tryLaunchOne() cannot: nextFreePort()'s own port-in-use probe is
293
- * asynchronous (plan 02, C4 -- a real bind-and-release check), so two
294
- * overlapping callers (a cold acquire arriving over the TCP control
295
- * listener at any moment, and a warm-floor pass on its own poll timer)
461
+ * asynchronous (a real bind-and-release check), so two overlapping callers
296
462
  * could otherwise BOTH be told the SAME candidate port is free before
297
463
  * either commits it to state.instances -- a double-launch on one port,
298
464
  * silently overwriting the earlier record. The guard is checked and set
299
- * SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s
300
- * own discipline, so a second concurrent call is refused immediately
465
+ * SYNCHRONOUSLY before the first `await`, exactly like tryLaunchOne()'s own
466
+ * discipline, so a second concurrent call is refused immediately
301
467
  * (`launch_in_flight`) rather than racing on the allocation.
302
468
  *
303
- * This is also the function that restores vice-broker.sh's own
304
- * process_requests() throttle (its `in_flight` local, checked before a
305
- * COLD launch, not only before a warm one): a cold acquire and a
306
- * warm-floor pass can never launch simultaneously, matching the bash
307
- * original's declined-to-change behaviour (RESEARCH.md §A1/§C). D-07
308
- * (01.6.2.1-03-PLAN.md) layers non-preemptive PRIORITY on top of this same
309
- * "one at a time" guard, never replacing it: this function still only ever
310
- * refuses a second concurrent caller (`launch_in_flight`), and never kills
311
- * or preempts whichever caller already holds the slot -- which reason wins
312
- * this slot NEXT, once it frees, falls out of runBrokerPass()'s own fixed
313
- * evaluation order (that function's own invariant comment), not from
314
- * anything in this function. The refusal below logs which reason currently
315
- * holds the slot and which reason is waiting, so the decision is
316
- * reconstructable from the log after an incident. */
469
+ * This guard's own reasoning OUTLIVED the warm floor it was originally
470
+ * written alongside -- it exists because of the 2026-08-01 triple-launch
471
+ * outage (three simultaneous x64sc launches: one SEGV, one exit 1, one exit
472
+ * 0 at the identical spawn second) and is regression-tested (CLAUDE.md),
473
+ * and that history has nothing to do with whether a warm floor exists.
474
+ * Today the only caller of this function is the cold-acquire arm
475
+ * (vice-broker.mts's handleAcquire(), via `serveAcquires()` in
476
+ * runBrokerPass()); the overlap this guard closes is now TWO OR MORE
477
+ * concurrent acquires -- e.g. two requests arriving over the TCP control
478
+ * listener at nearly the same moment, or one arriving while an EARLIER
479
+ * acquire's own launch is still resolving -- never a warming pass, which no
480
+ * longer exists. This is also the function that restores vice-broker.sh's
481
+ * own process_requests() throttle (its `in_flight` local): whatever launches
482
+ * this broker ever attempts, they never overlap, matching the bash
483
+ * original's declined-to-change behaviour. Non-preemptive launch PRIORITY
484
+ * layers on top of this same "one at a time" guard, never replacing it, and
485
+ * the anti-pattern it names -- killing or relaunching preemptively to serve
486
+ * a newer request -- is likewise unaffected by the floor's removal: this
487
+ * function still only ever refuses a second concurrent caller
488
+ * (`launch_in_flight`), and never kills or preempts whichever caller
489
+ * already holds the slot. Among multiple QUEUED acquires, which one wins
490
+ * this slot NEXT, once it frees, falls out of the arrival-ordered
491
+ * pending-acquire structure (broker-control.mts's own mechanism) that
492
+ * requeues a refused acquire for the next pass -- not from anything in this
493
+ * function. The refusal below logs which reason currently holds the slot
494
+ * and which reason is waiting, so the decision is reconstructable from the
495
+ * log after an incident. */
317
496
  export async function acquirePortAndLaunch(reason, deps) {
318
497
  const log = deps.log ?? defaultLog;
319
498
  if (inFlight) {
320
- log(`vice-broker: launch-slot decision -- ${inFlightReason ?? "unknown"} holds the slot; ${reason} waits (D-07)`);
499
+ log(`vice-broker: launch-slot decision -- ${inFlightReason ?? "unknown"} holds the slot; ${reason} waits`);
321
500
  return { ok: false, reason: "launch_in_flight" };
322
501
  }
323
502
  inFlight = true;
@@ -331,14 +510,14 @@ export async function acquirePortAndLaunch(reason, deps) {
331
510
  const supervisorDir = join(deps.stateDir, String(port));
332
511
  const epochFile = join(supervisorDir, "epoch.json");
333
512
  const spawn = deps.spawnFactory ? deps.spawnFactory(port) : deps.spawn;
334
- // Plan 03-04 (DIRECT-06, D-13): the second (`-remotemonitor`) port is
335
- // resolved HERE, still inside the single in_flight owner's own
336
- // try-block, immediately after the primary allocation succeeds -- both
337
- // awaits stay inside this SAME try, after the guard's synchronous
338
- // check-and-set above; neither is moved, duplicated, or awaited around
339
- // that guard. Only ever attempted for `backend === "stock"`, and only
340
- // when the caller actually provided the allocator -- every fork launch
341
- // and every pre-Phase-3 caller never reaches this branch at all.
513
+ // The second (`-remotemonitor`) port is resolved HERE, still inside the
514
+ // single in_flight owner's own try-block, immediately after the primary
515
+ // allocation succeeds -- both awaits stay inside this SAME try, after
516
+ // the guard's synchronous check-and-set above; neither is moved,
517
+ // duplicated, or awaited around that guard. Only ever attempted for
518
+ // `backend === "stock"`, and only when the caller actually provided the
519
+ // allocator -- every fork launch and every caller before this feature
520
+ // existed never reaches this branch at all.
342
521
  let remoteMonitorPort;
343
522
  if (deps.backend === "stock" && deps.allocateRemoteMonitorPort) {
344
523
  const remoteResult = await deps.allocateRemoteMonitorPort(deps.state, new Set([port]));
@@ -351,16 +530,27 @@ export async function acquirePortAndLaunch(reason, deps) {
351
530
  // does. `state.blockedPorts` is a plain Set the type import already
352
531
  // describes, so mutating it directly needs no value import at all --
353
532
  // exactly the same discipline handleExit()'s own
354
- // `record.monitorClient = undefined` uses in place of
533
+ // `record.monitorClients = {}` uses in place of
355
534
  // clearMonitorClient().
356
535
  deps.state.blockedPorts.add(remoteResult.port);
357
536
  remoteMonitorPort = remoteResult.port;
358
537
  }
359
538
  else {
360
- // Degrade, never fail: a port nothing dials yet (Phase 3 builds no
361
- // text-monitor client) must never make the backend unavailable.
539
+ // FAIL, never degrade. Owner direction, verbatim: "it should not be
540
+ // possible, vice must be started witht the text channel." A stock
541
+ // launch that cannot bind a text-monitor port fails the whole
542
+ // acquire -- no process is spawned. The PRIMARY port allocated
543
+ // moments earlier is not yet in `state.instances` and was never
544
+ // added to `state.blockedPorts` by this function (only
545
+ // `nextFreePort()`'s own in-use probe blocks a candidate, and that
546
+ // never ran against the winning candidate) -- so it is already
547
+ // allocatable again on the very next call with no further release
548
+ // step; a discriminating-power test proves this rather than assuming
549
+ // it. This failure arm must NEVER call spawnAndRecordInstance() or
550
+ // otherwise leave a port "spoken for" on the caller's behalf.
362
551
  log(`vice-broker: second (-remotemonitor) port allocation failed (${remoteResult.reason}) -- ` +
363
- `launching WITHOUT -remotemonitor; nothing in Phase 3 dials the text-monitor port anyway`);
552
+ `abandoning the stock launch; the text-monitor port is mandatory on every stock launch and the acquire fails`);
553
+ return { ok: false, reason: "no_free_text_port" };
364
554
  }
365
555
  }
366
556
  const record = spawnAndRecordInstance(reason, port, {
@@ -374,6 +564,7 @@ export async function acquirePortAndLaunch(reason, deps) {
374
564
  backend: deps.backend,
375
565
  binmonHost: deps.binmonHost,
376
566
  remoteMonitorPort,
567
+ profile: deps.profile,
377
568
  });
378
569
  return { ok: true, record };
379
570
  }
@@ -386,16 +577,17 @@ export async function acquirePortAndLaunch(reason, deps) {
386
577
  * deleting the record AND handing its second (`-remotemonitor`) port back to
387
578
  * the allocator in the same step.
388
579
  *
389
- * CR-02 (03-REVIEW.md): `acquirePortAndLaunch()` above adds every allocated
390
- * remote-monitor port to `state.blockedPorts`, and until this function existed
391
- * NOTHING ever removed one. `nextFreePort()` never reconsiders a blocked
392
- * candidate for the lifetime of the process, so every teardown of a stock
393
- * instance permanently consumed one more port out of the fixed
394
- * PORT_SCAN_CEILING window even though the OS port was free again the instant
395
- * the owning process exited -- a long-running broker (the explicit design goal
396
- * of an on-demand pool with crash supervision and a warm floor) eventually
397
- * exhausts its band and answers `no_free_port` to ordinary launches purely
398
- * from routine churn, with no operator recourse short of a broker restart.
580
+ * `acquirePortAndLaunch()` above adds every allocated remote-monitor port to
581
+ * `state.blockedPorts`, and until this function existed NOTHING ever removed
582
+ * one. `nextFreePort()` never reconsiders a blocked candidate for the
583
+ * lifetime of the process, so every teardown of a stock instance permanently
584
+ * consumed one more port out of the fixed PORT_SCAN_CEILING window even
585
+ * though the OS port was free again the instant the owning process exited
586
+ * -- a long-running broker (the explicit design goal of an on-demand pool
587
+ * with crash supervision, launched strictly on demand rather than kept
588
+ * warm) eventually exhausts its band and answers `no_free_port` to ordinary
589
+ * launches purely from routine churn, with no operator recourse short of a
590
+ * broker restart.
399
591
  *
400
592
  * A RESPAWN is deliberately NOT a call site: the replacement instance keeps
401
593
  * BOTH the primary port and the remote-monitor port of the instance it
@@ -456,7 +648,7 @@ async function defaultHttpProbe(port, timeoutMs) {
456
648
  }
457
649
  }
458
650
  // ---------------------------------------------------------------------------
459
- // WR-01: the STOCK readiness route.
651
+ // The STOCK readiness route.
460
652
  //
461
653
  // probeReady() below used to POST http://127.0.0.1:<port>/mcp unconditionally
462
654
  // and require both "version" and "machine" in the body. On the stock backend
@@ -477,8 +669,9 @@ async function defaultHttpProbe(port, timeoutMs) {
477
669
  // probe-binmon.mjs. What is written here is the minimum a READINESS check needs:
478
670
  // one request header out, one response header in, four bytes checked.
479
671
  // ---------------------------------------------------------------------------
480
- /** Hand-copied from docs/phase0-binmon-findings.md §5 -- see the block comment
481
- * above for why these are not imported from stock-protocol.ts. */
672
+ /** Hand-copied from this project's own measured binary-monitor wire format
673
+ * (the same constants stock-protocol.ts defines) -- see the block comment
674
+ * above for why these are not imported from stock-protocol.ts directly. */
482
675
  const BINMON_STX = 0x02;
483
676
  const BINMON_API_VERSION = 0x02;
484
677
  const BINMON_REQUEST_HEADER_LEN = 11;
@@ -504,15 +697,15 @@ function binmonRequest(commandType, requestId) {
504
697
  * collides between the two, which is exactly why demux must key on it. */
505
698
  const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
506
699
  /**
507
- * WR-01: one PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
700
+ * One PING (0x81) over the binary monitor, requiring a WELL-FORMED 0x81
508
701
  * reply -- STX, the expected api_version, response type 0x81, error code 0x00,
509
702
  * and this probe's own request id. A bare TCP accept is explicitly insufficient
510
703
  * here for exactly the reason probeReady()'s own comment gives for the HTTP
511
704
  * route: a C64 can accept a connection before it has finished booting.
512
705
  *
513
- * quick task 260818-obc (live-discovered): a NEW binmon connection ALWAYS
514
- * emits an unsolicited REGISTER_INFO (0x31) frame at request-id 0xffffffff
515
- * the instant it opens (CLAUDE.md's own Protocol constraint) -- BEFORE this
706
+ * A live-discovered defect: a NEW binmon connection ALWAYS emits an
707
+ * unsolicited REGISTER_INFO (0x31) frame at request-id 0xffffffff the
708
+ * instant it opens (CLAUDE.md's own Protocol constraint) -- BEFORE this
516
709
  * probe's own PING reply ever arrives. The naive "the first 12 bytes ARE the
517
710
  * reply" read this code used to do treated that event frame's OWN response-
518
711
  * type byte (0x31) as a malformed PING reply and answered `false` forever,
@@ -528,8 +721,8 @@ const BINMON_UNSOLICITED_REQUEST_ID = 0xffffffff;
528
721
  * ViceMonitorClient chief among them).
529
722
  *
530
723
  * Then EXIT (0xaa), unconditionally, before closing -- because the PING ITSELF
531
- * HALTS THE MACHINE. Any inbound byte does (docs/phase0-binmon-findings.md §4,
532
- * and CR-02, which fixed the same omission in the connect handshake). A
724
+ * HALTS THE MACHINE. Any inbound byte does, and this project's own connect
725
+ * handshake had the same omission and was fixed for the same reason. A
533
726
  * readiness probe that left every warm instance frozen would be a worse defect
534
727
  * than the one it fixes: the emulator would be "ready" and stopped.
535
728
  *
@@ -617,36 +810,33 @@ async function defaultBinmonProbe(port, timeoutMs) {
617
810
  });
618
811
  });
619
812
  }
620
- /** D-05, AS AMENDED BY P-05 -- this comment is the amendment's record, kept
621
- * in the exact place a three-branch description used to sit, per this
622
- * plan's own instruction that a code reader must meet the amendment here,
623
- * not merely in the plan text (`01.6.2.1-02-PLAN.md`) or the validation
624
- * ledger (`01.6.2-VALIDATION.md`, consolidated by plan 06).
813
+ /** This comment records why the readiness probe below looks the way it
814
+ * does, kept in the exact place a longer, three-branch description used to
815
+ * sit.
625
816
  *
626
- * D-05 (locked, `01.6.2-CONTEXT.md`) originally specified the probe as a
627
- * bare in-process TCP connect to the instance's own monitor port, with a
628
- * short timeout. The code that landed instead argued against that wording,
629
- * in its OWN comment: "a bare TCP accept is explicitly not sufficient (a
630
- * C64 can accept a connection before it has finished booting)" -- a
631
- * booting emulator promoted to ready on nothing more than an accepted
632
- * connection is exactly Defect 3's failure shape reappearing, here on the
633
- * plan-01 acquire hot path.
817
+ * The probe was originally specified as a bare in-process TCP connect to
818
+ * the instance's own monitor port, with a short timeout. The code that
819
+ * landed instead argued against that wording, in its OWN comment: "a bare
820
+ * TCP accept is explicitly not sufficient (a C64 can accept a connection
821
+ * before it has finished booting)" -- a booting emulator promoted to ready
822
+ * on nothing more than an accepted connection is exactly the kind of false
823
+ * positive this probe exists to prevent.
634
824
  *
635
- * P-05 amends D-05: the ping-shaped request body stays -- it is what
636
- * proves the emulator ANSWERS, not merely that a port is bound, which is
637
- * the whole difference between a liveness check and a readiness check. The
638
- * two OTHER branches the landed code carried (an external-command
639
- * mechanism, and a "neither mechanism available -> report ready
640
- * unconditionally" fallback) retire outright, per P-06: with no second
641
- * mechanism to prefer and no "no mechanism" state left to report, there is
642
- * no longer a pair of indistinguishable states (a deliberately-zero warm
643
- * floor and a broken host) for an operator to confuse in the logs. D-05's
644
- * own intent -- exactly one check, no external command, no ambiguity -- is
645
- * fully honoured by this collapse, not reversed by it.
825
+ * The ping-shaped request body stays -- it is what proves the emulator
826
+ * ANSWERS, not merely that a port is bound, which is the whole difference
827
+ * between a liveness check and a readiness check. Two other mechanisms the
828
+ * landed code originally carried (an external-command mechanism, and a
829
+ * "neither mechanism available -> report ready unconditionally" fallback)
830
+ * were retired outright: with no second mechanism to prefer and no "no
831
+ * mechanism" state left to report, there is no longer a pair of
832
+ * indistinguishable states (a deliberately-zero warm floor and a broken
833
+ * host) for an operator to confuse in the logs. The original intent --
834
+ * exactly one check, no external command, no ambiguity -- is fully
835
+ * honoured by this collapse, not reversed by it.
646
836
  *
647
837
  * No retry loop, deliberately: a still-booting instance simply fails THIS
648
- * pass and is re-probed on the next one (maintainWarmFloor()'s own per-pass
649
- * cadence, or a later grant-time re-probe) -- this is what makes the
838
+ * pass and is re-probed on the next one (promoteLaunchingInstances()'s own
839
+ * per-pass cadence, or a later grant-time re-probe) -- this is what makes the
650
840
  * shortened ~1s default below safe rather than reckless: a slow host is
651
841
  * re-probed, never starved, and the seconds-valued timeout knob
652
842
  * (VICE_BROKER_PROBE_TIMEOUT_S) still lets an operator on a slow host raise
@@ -654,7 +844,7 @@ async function defaultBinmonProbe(port, timeoutMs) {
654
844
  export async function probeReady(port, deps = {}) {
655
845
  const timeoutS = Number(deps.probeTimeoutSEnv ?? process.env.VICE_BROKER_PROBE_TIMEOUT_S) || DEFAULT_PROBE_TIMEOUT_S;
656
846
  const timeoutMs = timeoutS * 1000;
657
- // WR-01: the route is chosen by the backend, exactly like buildViceArgs()'s
847
+ // The route is chosen by the backend, exactly like buildViceArgs()'s
658
848
  // own argv choice, and from the SAME threaded-down verdict. The fork arm below
659
849
  // is byte-identical to what this function always did, including the
660
850
  // omitted-backend default -- a fork deployment sees no behaviour change.
@@ -665,61 +855,22 @@ export async function probeReady(port, deps = {}) {
665
855
  const httpProbe = deps.httpProbe ?? defaultHttpProbe;
666
856
  return httpProbe(port, timeoutMs);
667
857
  }
668
- // ---------------------------------------------------------------------------
669
- // Serialised warm-floor maintenance
670
- // ---------------------------------------------------------------------------
671
- function resolveWarmFloor(override) {
672
- if (typeof override === "number")
673
- return override;
674
- const raw = process.env.VICE_BROKER_WARM_FLOOR;
675
- if (raw === undefined || raw === "")
676
- return 1;
677
- const n = Number(raw);
678
- return Number.isFinite(n) ? n : 1;
679
- }
680
- function resolveCeiling(override) {
681
- if (typeof override === "number")
682
- return override;
683
- const raw = process.env.VICE_BROKER_MAX;
684
- if (raw === undefined || raw === "")
685
- return 16;
686
- const n = Number(raw);
687
- return Number.isFinite(n) ? n : 16;
688
- }
689
- /** Promotes launching instances via probe, then -- unless a launch is
690
- * already in flight -- launches AT MOST ONE instance toward the warm floor
691
- * and returns. Never loops to reach the floor in one call: reaching
692
- * VICE_BROKER_WARM_FLOOR this way costs one
693
- * additional CALL per warm instance instead of one call total, which is the exact
694
- * trade the 2026-08-01 outage made non-negotiable (three simultaneous
695
- * x64sc launches: one SEGV, one exit 1, one exit 0 at the identical spawn
696
- * second). `async`/`await` makes launching everything needed in one go
697
- * look free and idiomatic; it is actively dangerous here. DO NOT gather
698
- * several pending launches into a single concurrent await, and do not
699
- * "helpfully" loop this function internally until the floor is met.
700
- *
701
- * D-05's probe-live floor evaluation (count_ready() trusting probe-live
702
- * instances rather than a recorded `ready` state) is explicitly Phase
703
- * 01.6.2.1's criterion L, NOT this plan's -- countReady() here still
704
- * counts by RECORDED state, exactly like the bash original's count_ready()
705
- * before Decision 5.2. A reviewer must not mistake this for an oversight:
706
- * it is the declined-for-this-phase choice RESEARCH.md §A1 recommends, and
707
- * grant_from_spare()'s own live re-probe at GRANT time (broker-kill.mts /
708
- * plan 04's territory) is a separate, already-correct mechanism this plan
709
- * does not touch. */
710
- export async function maintainWarmFloor(deps) {
858
+ /** Promotes every `launching` instance whose readiness probe now succeeds to
859
+ * `ready`, recording its readiness timestamp and logging the elapsed boot
860
+ * time. Runs regardless of whether a launch is in flight -- promotion and a
861
+ * NEW launch starting are independent concerns; an already-launched instance
862
+ * becomes usable the moment it answers, whether or not this same pass goes on
863
+ * to start anything further. No retry loop: a still-booting instance simply
864
+ * fails THIS pass and is re-probed on the next one (runBrokerPass()'s own
865
+ * per-tick cadence). */
866
+ export async function promoteLaunchingInstances(deps) {
711
867
  const log = deps.log ?? defaultLog;
712
868
  const now = deps.now ?? (() => Date.now());
713
- // WR-01: the DEFAULT probe follows this call's own backend, so a caller that
714
- // threads `backend` for the launch argv and omits `probe` gets a matching
715
- // readiness route rather than an HTTP POST at a binary-monitor port. An
716
- // explicitly injected `probe` still wins, unchanged.
717
- const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "fork" }));
718
- // Step 1: promote every "launching" instance whose probe now succeeds.
719
- // Runs regardless of whether a launch is in flight -- promotion and
720
- // speculative warming are independent concerns; an already-launched
721
- // instance becomes usable the moment it answers, whether or not this
722
- // pass goes on to warm anything further.
869
+ // The DEFAULT probe follows this call's own backend, so a caller that
870
+ // threads `backend` and omits `probe` gets a matching readiness route
871
+ // rather than an HTTP POST at a binary-monitor port. An explicitly
872
+ // injected `probe` still wins, unchanged.
873
+ const probe = deps.probe ?? ((port) => probeReady(port, { backend: deps.backend ?? "stock" }));
723
874
  for (const record of deps.state.instances.values()) {
724
875
  if (record.state !== "launching")
725
876
  continue;
@@ -732,103 +883,48 @@ export async function maintainWarmFloor(deps) {
732
883
  log(`vice-broker: port ${record.port} launching -> ready (${elapsedMs}ms)`);
733
884
  }
734
885
  }
735
- // Step 2 (P-06: the warm-zero "no readiness mechanism" branch that used
736
- // to sit here is GONE -- the surviving probe mechanism is in-process and
737
- // always available, so there is no "no mechanism" state left to warm
738
- // zero against). No new boot starts while one is already under way --
739
- // THE single in-flight counter (countLaunching) both this function and a
740
- // cold acquire (vice-broker.mts's handleAcquire) consult.
741
- if (deps.countLaunching(deps.state) > 0) {
742
- // D-07's launch-slot decision log line, this decision point's own half:
743
- // name WHICH reason currently holds the slot (the launching record's
744
- // own `reason`, whichever call produced it -- cold acquire or an
745
- // earlier warming pass), not merely that warming is waiting.
746
- const inFlightRecord = Array.from(deps.state.instances.values()).find((r) => r.state === "launching");
747
- const winningReason = inFlightRecord?.reason ?? "unknown";
748
- log(`vice-broker: launch-slot decision -- ${winningReason} holds the slot; spare waits (D-07)`);
749
- return;
750
- }
751
- const ready = deps.countReady(deps.state);
752
- const total = deps.countTotal(deps.state);
753
- const warmFloor = resolveWarmFloor(deps.warmFloor);
754
- const ceiling = resolveCeiling(deps.ceiling);
755
- if (!(ready < warmFloor && total < ceiling)) {
756
- return;
757
- }
758
- // acquirePortAndLaunch() holds the SAME single in_flight owner across
759
- // its own async port allocation -- not merely tryLaunchOne()'s
760
- // synchronous spawn instant. This is what actually closes the race
761
- // between this warm-floor launch and a cold acquire (vice-broker.mts's
762
- // handleAcquire) arriving over the TCP control listener at any moment:
763
- // the countLaunching() check just above is a cheap PRE-check (bails
764
- // early when a launch is already recorded), but nextFreePort() is
765
- // itself asynchronous, so without the guard held across the allocation
766
- // too, two overlapping callers could still both be told the same
767
- // candidate port is free before either commits it.
768
- const result = await acquirePortAndLaunch("spare", {
769
- state: deps.state,
770
- stateDir: deps.stateDir,
771
- allocatePort: deps.allocatePort,
772
- allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
773
- spawn: deps.spawn,
774
- spawnFactory: deps.spawnFactory,
775
- now: deps.now,
776
- viceBin: deps.viceBin,
777
- mcpHost: deps.mcpHost,
778
- backend: deps.backend,
779
- binmonHost: deps.binmonHost,
780
- });
781
- if (result.ok) {
782
- log(`vice-broker: warmed 1 warm instance this pass -- ${ready + 1} of ${warmFloor} ready, remainder warmed on later passes`);
783
- deps.onLaunched?.(result.record);
784
- }
785
- else if (result.reason === "no_free_port") {
786
- log(`vice-broker: no free port available -- warming no further warm instances; ${ready} of ${warmFloor} ready`);
787
- }
788
- else {
789
- // A launch started (cold or warm) between this function's own
790
- // countLaunching() check above and this call -- a narrow window
791
- // closed by the guard rather than assumed impossible.
792
- log("vice-broker: a warm-floor launch was attempted but a launch was already in flight -- deferring to a later pass");
793
- }
794
886
  }
795
887
  /** The fixed pass order (mirrors vice-broker.sh's own broker_once(), whose
796
888
  * comment names the ordering as load-bearing: "the spare invariant is
797
889
  * always re-evaluated against the freshest possible grant/teardown
798
890
  * state"). The bash version's third concern, the grant sweep, does NOT
799
- * appear here -- it is one of criterion F's six retiring file-lease
800
- * mechanisms; the TCP connection itself is the lease (D-12). The
801
- * broker-instances.json projection write does not appear either, per D-24
802
- * (see broker-state.mts's own FINDING 2 comment). Takes plain callbacks
803
- * rather than the full BrokerState/deps shape so a test can inject two
804
- * instrumented no-op functions and assert call ORDER without needing a
805
- * real broker, a real port or a real launch.
891
+ * appear here -- it is one of several retiring file-lease mechanisms; the
892
+ * TCP connection itself is the lease. The broker-instances.json projection
893
+ * write does not appear either (see broker-state.mts's own FINDING 2
894
+ * comment). Takes plain callbacks rather than the full BrokerState/deps
895
+ * shape so a test can inject two instrumented no-op functions and assert
896
+ * call ORDER without needing a real broker, a real port or a real launch.
806
897
  *
807
- * D-07 (01.6.2.1-03-PLAN.md): THIS is where launch priority actually lives
808
- * -- serving acquires before maintaining the warm floor is what lets a
809
- * request-driven launch win a freed slot before a warming launch, within
810
- * one pass, on top of the single in-flight owner (acquirePortAndLaunch()'s
811
- * own invariant comment) that this order never weakens. Inverting this
812
- * order lets a warming launch take the slot first and go untested against
813
- * a concurrently arriving acquire, which is exactly the regression
814
- * broker-launch.test.ts's own D-07 priority test is written to catch (its
815
- * own discriminating-power demonstration inverts this exact order and
816
- * observes the test go red). Priority decides only which reason wins the
817
- * NEXT freed slot -- it is never a substitute for the lock, and it never
818
- * kills or abandons whichever boot is already in flight. */
898
+ * The warm floor that non-preemptive launch priority originally reasoned
899
+ * about here is GONE -- `promoteLaunching` never calls
900
+ * acquirePortAndLaunch() and so never competes for the single in-flight
901
+ * launch slot the way a warm-floor spare launch used to. `serveAcquires()`
902
+ * (via its own drainPendingAcquires()) is now the ONLY caller in this pass
903
+ * that ever launches anything, so the original "which reason wins a freed
904
+ * slot" question has nothing left to decide BETWEEN these two steps -- that
905
+ * reasoning still applies WITHIN the acquire arm itself (two overlapping
906
+ * acquires still resolve through the single in-flight owner
907
+ * (acquirePortAndLaunch()'s own invariant comment), which this reordering
908
+ * never weakens). What the fixed order still buys: promoting AFTER serving
909
+ * means an instance that becomes probe-ready DURING this exact tick is not
910
+ * available to any acquire THIS SAME pass -- selectWarmInstance() sees it on
911
+ * the NEXT pass instead, a bound of one poll interval (VICE_BROKER_POLL_MS),
912
+ * never a correctness gap, since a cold acquire finding no ready candidate
913
+ * falls straight through to its own dedicated cold launch rather than
914
+ * waiting on one. */
819
915
  export async function runBrokerPass(deps) {
820
916
  await deps.serveAcquires();
821
- await deps.maintainWarmFloor();
917
+ await deps.promoteLaunching();
822
918
  }
823
919
  // ===========================================================================
824
- // Per-child supervision (Plan 03, Task 2 -- C2/D-23): absorbs
825
- // resources/vice-supervisor.sh WHOLESALE. The respawn loop becomes an
826
- // exit-event handler installed on the spawned child; the backoff shape
827
- // (initial delay, doubling, ceiling), the crash-loop give-up (too many
828
- // crashes inside a window), and the per-instance boot/crash log are ported
829
- // exactly, per D-1's own configuration knobs -- VICE_RESTART_BACKOFF_S,
830
- // VICE_RESTART_BACKOFF_MAX_S, VICE_MAX_RESTARTS, VICE_CRASH_WINDOW_S all
831
- // keep their exact names and semantics.
920
+ // Per-child supervision: absorbs resources/vice-supervisor.sh WHOLESALE. The
921
+ // respawn loop becomes an exit-event handler installed on the spawned
922
+ // child; the backoff shape (initial delay, doubling, ceiling), the
923
+ // crash-loop give-up (too many crashes inside a window), and the
924
+ // per-instance boot/crash log are ported exactly, keeping the same
925
+ // configuration knobs -- VICE_RESTART_BACKOFF_S, VICE_RESTART_BACKOFF_MAX_S,
926
+ // VICE_MAX_RESTARTS, VICE_CRASH_WINDOW_S all keep their exact names and
927
+ // semantics.
832
928
  // ===========================================================================
833
929
  function resolveMs(envVar, defaultSeconds, override) {
834
930
  if (typeof override === "number")
@@ -857,9 +953,11 @@ function resolveCount(envVar, defaultValue, override) {
857
953
  * UNEXPLAINED exit, not this one. The pre-kill crash history and backoff
858
954
  * are carried forward UNCHANGED, and a pre-kill "granted" state is
859
955
  * restored on the fresh record -- the relaunch primitive always creates a
860
- * new record in the "launching" state, and leaving it there would let the
861
- * warm floor's own ready-count numerator mistake a recycled session's own
862
- * machine for an available warm instance.
956
+ * new record in the "launching" state, and leaving it there (once
957
+ * promoted to "ready" by the next probe pass) would let a LATER,
958
+ * UNRELATED acquire's own selectWarmInstance() walk (vice-broker.mts)
959
+ * mistake a recycled session's own machine for an available candidate to
960
+ * grant out from under the session that already owns it.
863
961
  * - deliberateKill set WITHOUT respawnAfterKill -> "deliberate_teardown":
864
962
  * drop the instance, no respawn. This is T-01.6.2-21's whole point --
865
963
  * without reading this flag, every deliberate teardown would respawn
@@ -883,22 +981,24 @@ async function handleExit(reason, port, deps) {
883
981
  return;
884
982
  }
885
983
  const log = deps.log ?? defaultLog;
886
- // Plan 05 (BROK-02/PROTO-08): the process behind this instance's monitor
887
- // socket has just exited, by every path this function can take (crash,
888
- // recycle, or a deliberate teardown) -- clear the ownership record HERE,
889
- // once, before any of those paths branch, so a client that died without
890
- // releasing can never hold this lock forever. Redundant with the
891
- // respawn/delete paths below (a fresh InstanceRecord never carries this
892
- // field forward; a deleted one has no field to carry), but explicit for
893
- // the same reason broker-state.mts's own header comment names this as one
894
- // of the three required clearing sites. Assigned directly (not via
895
- // broker-state.mjs's clearMonitorClient()) -- this module's own
984
+ // The process behind this instance's monitor sockets has just exited, by
985
+ // every path this function can take (crash, recycle, or a deliberate
986
+ // teardown) -- clear EVERY channel's ownership record HERE, once, before
987
+ // any of those paths branch, so a client that died without releasing can
988
+ // never hold this lock forever on any channel. Redundant
989
+ // with the respawn/delete paths below (a fresh InstanceRecord never
990
+ // carries this forward; a deleted one has no field to carry), but
991
+ // explicit for the same reason broker-state.mts's own header comment
992
+ // names this as one of the required clearing sites. Assigned directly
993
+ // (not via broker-state.mjs's clearMonitorClient()) -- this module's own
896
994
  // type-only import of that sibling (see this file's own header comment a
897
995
  // few lines above) is load-bearing: a VALUE import would turn "./broker-
898
996
  // state.mjs" into a real runtime resolution this file cannot satisfy when
899
997
  // loaded directly (as broker-launch.test.ts does), rather than the
900
- // compiled resources/ sibling this specifier is actually shaped for.
901
- record.monitorClient = undefined;
998
+ // compiled resources/ sibling this specifier is actually shaped for. `{}`
999
+ // (not `undefined`) since `monitorClients` is non-optional -- "no claim
1000
+ // on any channel" is an empty map.
1001
+ record.monitorClients = {};
902
1002
  if (record.deliberateKill) {
903
1003
  if (record.respawnAfterKill) {
904
1004
  // Recycle. Capture the pre-kill state, crash history and backoff
@@ -908,13 +1008,23 @@ async function handleExit(reason, port, deps) {
908
1008
  const preKillState = record.state;
909
1009
  const preKillCrashTimes = record.crashTimes ?? [];
910
1010
  const preKillBackoffMs = record.backoffMs ?? resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
911
- // CR-02 (03-REVIEW.md): the second (`-remotemonitor`) port is carried
912
- // forward across the replacement exactly like the primary port is --
913
- // captured BEFORE launchSupervised() overwrites this port's map entry
914
- // with a brand new record, for the same reason the three values above
915
- // are.
1011
+ // The second (`-remotemonitor`) port is carried forward across the
1012
+ // replacement exactly like the primary port is -- captured BEFORE
1013
+ // launchSupervised() overwrites this port's map entry with a brand
1014
+ // new record, for the same reason the three values above are.
916
1015
  const preKillRemoteMonitorPort = record.remoteMonitorPort;
917
- const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort);
1016
+ // The launch PROFILE is carried forward for exactly the reason the
1017
+ // remote-monitor port is carried forward above, and the failure it
1018
+ // prevents is sharper. Without this, a recycled `{warp:true}`
1019
+ // instance would come back UNWARPED while its fresh record still
1020
+ // claimed `profile:{warp:true}` -- after which profileEligible()
1021
+ // (vice-broker.mts) would happily hand that instance to the next warp
1022
+ // request. That is precisely the undetectable lie this carry-forward
1023
+ // exists to structurally exclude, reintroduced one respawn later.
1024
+ // Captured BEFORE launchSupervised() overwrites this port's map entry
1025
+ // with a brand new record, same as the four values above.
1026
+ const preKillProfile = record.profile;
1027
+ const respawned = launchSupervised(reason, port, deps, preKillCrashTimes, preKillBackoffMs, preKillRemoteMonitorPort, preKillProfile);
918
1028
  if (respawned && preKillState === "granted") {
919
1029
  respawned.state = "granted";
920
1030
  }
@@ -934,7 +1044,7 @@ async function handleExit(reason, port, deps) {
934
1044
  deps.onOutcome?.("recycled", port);
935
1045
  return;
936
1046
  }
937
- // CR-02: a deliberate teardown is the END of this instance -- its
1047
+ // A deliberate teardown is the END of this instance -- its
938
1048
  // remote-monitor port must go back to the allocator with it.
939
1049
  deleteInstanceRecord(deps.state, port);
940
1050
  deps.onOutcome?.("deliberate_teardown", port);
@@ -948,7 +1058,7 @@ async function handleExit(reason, port, deps) {
948
1058
  if (crashTimes.length >= maxRestarts) {
949
1059
  log(`vice-broker: giving up on port ${port} after ${crashTimes.length} crashes within ${crashWindowMs}ms -- ` +
950
1060
  `this is not a transient crash; check VICE_ARGS and whether the port is already bound`);
951
- // CR-02: giving up is likewise terminal for this instance -- release its
1061
+ // Giving up is likewise terminal for this instance -- release its
952
1062
  // remote-monitor port rather than leaking it out of the allocation band.
953
1063
  deleteInstanceRecord(deps.state, port);
954
1064
  deps.onOutcome?.("given_up", port);
@@ -959,11 +1069,14 @@ async function handleExit(reason, port, deps) {
959
1069
  await sleepMs(currentBackoffMs);
960
1070
  const maxBackoffMs = resolveMs("VICE_RESTART_BACKOFF_MAX_S", 30, deps.maxBackoffMs);
961
1071
  const nextBackoffMs = Math.min(currentBackoffMs * 2, maxBackoffMs);
962
- // CR-02: same carry-forward as the recycle branch above -- a crash must not
1072
+ // Same carry-forward as the recycle branch above -- a crash must not
963
1073
  // silently strip `-remotemonitor` (and its InstanceRecord field) off the
964
- // replacement, which is what made D-13's "the instance record carries it"
965
- // stop being true the first time an instance was replaced.
966
- const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort);
1074
+ // replacement, which would otherwise make the instance record's claim to
1075
+ // carry that field stop being true the first time an instance was
1076
+ // replaced. Same reasoning applies to the launch profile -- an
1077
+ // unexplained crash must not silently strip `-warp`/`-console` off the
1078
+ // replacement while leaving the record claiming them.
1079
+ const respawned = launchSupervised(reason, port, deps, crashTimes, nextBackoffMs, record.remoteMonitorPort, record.profile);
967
1080
  deps.onOutcome?.(respawned ? "respawned" : "given_up", port);
968
1081
  }
969
1082
  /** The single exit-listener installation point in the whole module tree.
@@ -988,11 +1101,11 @@ async function handleExit(reason, port, deps) {
988
1101
  * a second inline listener, is what keeps the "exactly one installation
989
1102
  * point" invariant a structural gate (broker-launch.test.ts) can hold. */
990
1103
  export function withCrashSupervision(reason, port, baseSpawn, deps) {
991
- // I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
992
- // in the BODY, not just the type -- this is the hop that matters most,
993
- // because it wraps every real launch path (cold acquire, warm floor, and
994
- // every respawn). A type-only widening would still silently drop a
995
- // caller's options at this call site.
1104
+ // Forwards a third options argument in the BODY, not just the type --
1105
+ // this is the hop that matters most, because it wraps every real launch
1106
+ // path (cold acquire and every respawn -- a warm floor used to be a
1107
+ // third path here and has since been retired). A type-only widening
1108
+ // would still silently drop a caller's options at this call site.
996
1109
  return (cmd, args, options) => {
997
1110
  const child = baseSpawn(cmd, args, options);
998
1111
  child.once("exit", () => {
@@ -1002,7 +1115,7 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
1002
1115
  };
1003
1116
  }
1004
1117
  /** Launches (or relaunches) a supervised instance: spawns through
1005
- * tryLaunchOne() (the SAME single guarded primitive plan 02 established --
1118
+ * tryLaunchOne() (the SAME single guarded primitive established above --
1006
1119
  * "spawn again through the SAME single guarded launch function", never a
1007
1120
  * second, parallel spawn path), writes the per-instance boot/crash log at
1008
1121
  * the path shape the retiring supervisor used (a `logs/` directory under
@@ -1017,15 +1130,27 @@ export function withCrashSupervision(reason, port, baseSpawn, deps) {
1017
1130
  * fact that spawnAndRecordInstance() creates a BRAND NEW InstanceRecord
1018
1131
  * object on every launch, replacing the old one at the same port key.
1019
1132
  *
1020
- * CR-02 (03-REVIEW.md): `remoteMonitorPort` is threaded the SAME way and for
1021
- * the same reason -- it belongs to the instance, not to a single spawn of it.
1022
- * The replacement reuses the port the crashed/recycled process just vacated
1023
- * (already reserved in `state.blockedPorts`, so nothing else can have taken it
1133
+ * `remoteMonitorPort` is threaded the SAME way and for the same reason --
1134
+ * it belongs to the instance, not to a single spawn of it. The replacement
1135
+ * reuses the port the crashed/recycled process just vacated (already
1136
+ * reserved in `state.blockedPorts`, so nothing else can have taken it
1024
1137
  * meanwhile), exactly as it reuses the primary `port` argument; this function
1025
1138
  * stays fully synchronous and never allocates. `undefined` is the correct
1026
1139
  * value for a FIRST launch through superviseChild() and for every fork launch,
1027
- * which is why the parameter is optional. */
1028
- function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort) {
1140
+ * which is why the parameter is optional.
1141
+ *
1142
+ * `profile` is threaded the SAME way and for a sharper version of the same
1143
+ * reason -- it belongs to the instance, not to a single spawn of it, and
1144
+ * warp is fixed at spawn (there is no runtime `WarpMode` resource on stock
1145
+ * at all). A replacement that dropped it would come back unwarped while
1146
+ * its record still claimed warp, which is exactly the
1147
+ * mismatch-between-grant-and-request that this carry-forward exists to
1148
+ * make impossible. `undefined` is correct for a FIRST launch through
1149
+ * superviseChild() -- production has no profile-less first-launch call site
1150
+ * of its own left now that the warm floor is retired, but this module's
1151
+ * own unit tests still drive one directly -- and for every fork launch,
1152
+ * which is why this parameter is optional too. */
1153
+ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonitorPort, profile) {
1029
1154
  const supervisorDir = join(deps.stateDir, String(port));
1030
1155
  const epochFile = deps.epoch.epochPathFor(deps.stateDir, port);
1031
1156
  const logDir = deps.epoch.instanceLogDirFor(deps.stateDir, port);
@@ -1041,15 +1166,15 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
1041
1166
  const logFileName = `${basename(viceBin)}-${Date.now()}-e${epoch}.log`;
1042
1167
  const logPath = join(logDir, logFileName);
1043
1168
  const logRelPath = `logs/${logFileName}`;
1044
- // I-1 rider (08.2-06-PLAN.md, Task 1): forwards a third options argument
1045
- // and MERGES it with the per-instance log stdio -- caller options
1046
- // spread FIRST, `stdio` set LAST, so the per-instance log fd always
1047
- // wins. Never the other order: a caller-supplied `stdio` would silently
1048
- // redirect a crash-respawn's output away from the log file the epoch
1049
- // record names, and the forensic per-instance log (D-23) would point at
1050
- // a file that received nothing. Without this fix, a stock instance that
1051
- // crashes and respawns comes back reading the operator's real `vicerc`
1052
- // even though its original launch was isolated.
1169
+ // Forwards a third options argument and MERGES it with the per-instance
1170
+ // log stdio -- caller options spread FIRST, `stdio` set LAST, so the
1171
+ // per-instance log fd always wins. Never the other order: a
1172
+ // caller-supplied `stdio` would silently redirect a crash-respawn's
1173
+ // output away from the log file the epoch record names, and the
1174
+ // forensic per-instance log would point at a file that received nothing.
1175
+ // Without this fix, a stock instance that crashes and respawns comes back
1176
+ // reading the operator's real `vicerc` even though its original launch
1177
+ // was isolated.
1053
1178
  const defaultRealSpawn = (cmd, args, options) => {
1054
1179
  const fd = openSync(logPath, "a");
1055
1180
  return nodeSpawn(cmd, args, { ...options, stdio: ["ignore", fd, fd] });
@@ -1067,6 +1192,7 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
1067
1192
  backend: deps.backend,
1068
1193
  binmonHost: deps.binmonHost,
1069
1194
  remoteMonitorPort,
1195
+ profile,
1070
1196
  log: deps.log,
1071
1197
  });
1072
1198
  if (!record)
@@ -1103,8 +1229,19 @@ function launchSupervised(reason, port, deps, crashTimes, backoffMs, remoteMonit
1103
1229
  * (crash respawn with backoff, crash-loop give-up, kill-never-recycle via
1104
1230
  * the deliberate-kill marker, and the per-instance boot/crash log), exactly
1105
1231
  * mirroring resources/vice-supervisor.sh's own respawn loop but expressed
1106
- * as an event-loop exit handler instead of a `while true` poll. */
1107
- export function superviseChild(reason, port, deps) {
1232
+ * as an event-loop exit handler instead of a `while true` poll.
1233
+ *
1234
+ * `remoteMonitorPort` is an OPTIONAL fourth parameter, threaded straight
1235
+ * through to launchSupervised() exactly like every other optional trailing
1236
+ * parameter in this file. A `backend: "stock"` caller MUST supply it:
1237
+ * spawnAndRecordInstance()'s own construction-site assertion throws
1238
+ * otherwise, since this function is a genuine first-launch call site, not
1239
+ * merely a respawn. This is not a production stock first-launch path today
1240
+ * (only acquirePortAndLaunch() is) -- it exists for this module's own unit
1241
+ * tests to drive a supervised first launch directly, and the parameter
1242
+ * exists so a stock test case can do so without violating the same
1243
+ * guarantee production code enforces. */
1244
+ export function superviseChild(reason, port, deps, remoteMonitorPort) {
1108
1245
  const initialBackoffMs = resolveMs("VICE_RESTART_BACKOFF_S", 3, deps.initialBackoffMs);
1109
- return launchSupervised(reason, port, deps, [], initialBackoffMs);
1246
+ return launchSupervised(reason, port, deps, [], initialBackoffMs, remoteMonitorPort);
1110
1247
  }