@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.
Files changed (77) 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 +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
@@ -2,7 +2,7 @@
2
2
  // Compiled by `tsc` from vice-broker.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
  // vice-broker.mts
8
8
  //
@@ -29,26 +29,56 @@ import { join, basename, resolve as resolvePath } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { spawn as nodeSpawn } from "node:child_process";
31
31
  import { containerGuardReport, containerGuardEnforce } from "./container-guard.mjs";
32
- import { createBrokerState, nextFreePort, countReady, countTotal, countLaunching, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
33
- import { acquirePortAndLaunch, deleteInstanceRecord, maintainWarmFloor, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
34
- // Plan 02-07: resolvedBackend() is now the ONE reader of VICE_BACKEND in
35
- // this tree -- ViceBackend's own definition moved to backend-detect.mts too,
36
- // so broker-launch.mjs's own (type-only) re-import of it and this file's
37
- // VALUE import both name the same one home. A real value import is safe
38
- // here (unlike inside broker-launch.mts) because vice-broker.mts is ALWAYS
39
- // run from its own compiled resources/ form -- both modules are compiled
40
- // together in the same build.ts pass, so "./backend-detect.mjs" always
41
- // exists as a real sibling file by the time this import resolves.
32
+ // Plan 41-05 (folded todo): countReady/countTotal/countLaunching are DROPPED
33
+ // from this import -- they were used only as maintainWarmFloorForRealBroker()'s
34
+ // own deps for the now-retired maintainWarmFloor(), passed through by
35
+ // shorthand property (`countReady,` etc.), never called directly in this
36
+ // file. atCapacity() is the one survivor actually called here (its own
37
+ // cold-launch-arm gate, below).
38
+ import { createBrokerState, nextFreePort, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
39
+ import { acquirePortAndLaunch, deleteInstanceRecord,
40
+ // Plan 41-05 (folded todo): replaces maintainWarmFloor -- the warm floor
41
+ // itself is retired; this is ONLY the launching -> ready promotion sweep
42
+ // the floor used to carry as its own step 1.
43
+ promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
44
+ // Plan 02-07: resolvedBackend() resolves the emulator binary's identity --
45
+ // ViceBackend's own definition lives in backend-detect.mts too (narrowed to
46
+ // a single literal by FORKRM-01, plan 52-06), so broker-launch.mjs's own
47
+ // (type-only) re-import of it and this file's VALUE import both name the
48
+ // same one home. A real value import is safe here (unlike inside
49
+ // broker-launch.mts) because vice-broker.mts is ALWAYS run from its own
50
+ // compiled resources/ form -- both modules are compiled together in the
51
+ // same build.ts pass, so "./backend-detect.mjs" always exists as a real
52
+ // sibling file by the time this import resolves.
42
53
  import { resolvedBackend } from "./backend-detect.mjs";
43
54
  import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
44
55
  import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
56
+ // Phase 34, plan 34-01 (SEAM-01): a VALUE import of the host-tool executor --
57
+ // safe here for the SAME reason every other sibling value import above is:
58
+ // this file is ALWAYS run from its own compiled resources/ form, and
59
+ // "./host-tool.mjs" is compiled into that same directory by the same build.ts
60
+ // pass (host-tool.mts is added to HOST_BOUND_ARTIFACTS/tsconfig.build.json's
61
+ // include[] in this same commit).
62
+ import { runHostTool } from "./host-tool.mjs";
63
+ // Gap G-40-1, requirement R2 (plan 40-09): a VALUE import of the same
64
+ // handle-minting function for the SAME reason as the host-tool.mjs import
65
+ // immediately above -- this file is always run from its own compiled
66
+ // resources/ form, and "./ghidra-project.mjs" is compiled into that same
67
+ // directory by the same build.ts pass (both source and target are already
68
+ // listed in HOST_BOUND_ARTIFACTS, since plan 40-08 landed the module).
69
+ import { ensureGhidraRunsHandle } from "./ghidra-project.mjs";
45
70
  import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
46
71
  const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
47
72
  /** `--repo-root` is required UNLESS `--check-container` is given -- the
48
73
  * container guard needs no paths at all, matching the bash launcher's own
49
74
  * `--check-container` handling (answered before any path resolution).
50
75
  * `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
51
- * otherwise `.vice-supervisor` under the repo root. */
76
+ * otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08,
77
+ * D-33 -- was `.vice-supervisor`; the three-tier chain itself -- explicit
78
+ * `--state-dir`, then `VICE_POOL_DIR`, then this default -- is unchanged,
79
+ * only the default's location moved). This module is host-bound and compiled
80
+ * by `build.ts`, so it must not import the container-side `repo-root.ts`;
81
+ * the two segments are joined directly, matching that file's `toolsDir()`. */
52
82
  export function parseArgs(argv) {
53
83
  let repoRoot = null;
54
84
  let stateDir = null;
@@ -73,7 +103,9 @@ export function parseArgs(argv) {
73
103
  if (!checkContainer && !repoRoot) {
74
104
  throw new Error(USAGE);
75
105
  }
76
- const resolvedStateDir = stateDir ?? process.env.VICE_POOL_DIR ?? (repoRoot ? join(repoRoot, ".vice-supervisor") : ".vice-supervisor");
106
+ const resolvedStateDir = stateDir ??
107
+ process.env.VICE_POOL_DIR ??
108
+ (repoRoot ? join(repoRoot, ".c64-re-tools", "supervisor") : join(".c64-re-tools", "supervisor"));
77
109
  return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
78
110
  }
79
111
  /** The deployed JavaScript broker artifact's own name -- D-26's entire
@@ -82,30 +114,23 @@ export function parseArgs(argv) {
82
114
  * It now names itself. */
83
115
  export const WRITTEN_BY = "vice-broker.mjs";
84
116
  // ---------------------------------------------------------------------------
85
- // Small, locally-duplicated env-var readers (plan 05) -- the SAME pattern
117
+ // Small, locally-duplicated env-var reader (plan 05) -- the SAME pattern
86
118
  // broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
87
119
  // already established: this module cannot import broker-launch.mts's
88
- // PRIVATE resolveWarmFloor()/resolveCeiling() (they are not exported, and
89
- // this file is already the top-level wiring module value-importing every
90
- // sibling .mjs directly -- exporting them would widen broker-launch.mts's
91
- // own surface for a one-line env-var read this file can duplicate exactly
92
- // as cheaply). Both mirror broker-launch.mts's defaults precisely
93
- // (VICE_BROKER_WARM_FLOOR/1, VICE_BROKER_MAX/16) so broker.json's config echo
94
- // and host_state's own answer can never disagree with what maintainWarmFloor
95
- // itself actually enforces. The floor default dropped from 3 to 1 in
96
- // 01.6.2.1-03-PLAN.md (D-06) -- BOTH readers changed together in that same
97
- // commit, deliberately, because this invariant (the two numbers never
98
- // disagree) breaks silently the moment only one of them moves. The
99
- // ceiling's own default (16) is untouched by D-06 -- it is the unrun
100
- // concurrency-ceiling spike's territory, not this phase's.
120
+ // PRIVATE resolveCeiling() (it is not exported, and this file is already
121
+ // the top-level wiring module value-importing every sibling .mjs directly --
122
+ // exporting it would widen broker-launch.mts's own surface for a one-line
123
+ // env-var read this file can duplicate exactly as cheaply). Mirrors
124
+ // broker-launch.mts's own default precisely (VICE_BROKER_MAX/16) so
125
+ // broker.json's config echo and host_state's own answer can never disagree
126
+ // with what atCapacity() itself actually enforces. Plan 41-05 (folded todo):
127
+ // this used to be a PAIR with resolveWarmFloorForRecord() (VICE_BROKER_WARM_
128
+ // FLOOR/1), kept in lockstep with broker-launch.mts's own matching pair
129
+ // (01.6.2.1-03-PLAN.md, D-06) so the two numbers could never disagree. The
130
+ // warm-floor half of that pair is RETIRED along with the floor itself -- the
131
+ // ceiling's own default (16) is untouched, since it is a separate concern
132
+ // (VICE_BROKER_MAX / atCapacity()) this plan does not touch.
101
133
  // ---------------------------------------------------------------------------
102
- function resolveWarmFloorForRecord() {
103
- const raw = process.env.VICE_BROKER_WARM_FLOOR;
104
- if (raw === undefined || raw === "")
105
- return 1;
106
- const n = Number(raw);
107
- return Number.isFinite(n) ? n : 1;
108
- }
109
134
  function resolveCeilingForRecord() {
110
135
  const raw = process.env.VICE_BROKER_MAX;
111
136
  if (raw === undefined || raw === "")
@@ -179,7 +204,7 @@ function writeBrokerRecordFile(stateDir, record) {
179
204
  }
180
205
  /** Builds a spawn function that redirects the child's stdout/stderr into a
181
206
  * FRESH per-launch log file under logDir (D-23: per-instance boot/crash
182
- * logs survive under .vice-supervisor/<port>/logs/, same paths, same
207
+ * logs survive under .c64-re-tools/supervisor/<port>/logs/, same paths, same
183
208
  * format as the retiring bash supervisor), returning both the spawn
184
209
  * closure and the log's path relative to supervisorDir (the epoch
185
210
  * record's own `log` field). Shared by both launch paths -- a cold
@@ -233,9 +258,10 @@ function writeEpochForLaunch(record, logRelPath) {
233
258
  record.epoch = epochRecord.epoch;
234
259
  }
235
260
  /** Builds the supervision dependency object for withCrashSupervision(),
236
- * once per launch, so both real launch paths (handleAcquire here; Task 2's
237
- * maintainWarmFloorForRealBroker) pass a structurally identical
238
- * SuperviseChildDeps object into the SAME shared wrapper. Deliberately does
261
+ * once per launch, so the real launch path (handleAcquire's own cold arm,
262
+ * here -- plan 41-05 retires the second real launch path this comment used
263
+ * to name, the warm floor) passes a structurally identical SuperviseChildDeps
264
+ * object into the shared wrapper. Deliberately does
239
265
  * NOT set spawnFactory: on a respawn, launchSupervised() (broker-launch.mts)
240
266
  * derives its own per-instance log path from instanceLogDirFor and names
241
267
  * that same path in the epoch record it writes -- supplying a competing
@@ -248,11 +274,11 @@ function writeEpochForLaunch(record, logRelPath) {
248
274
  * CR-01 (03-REVIEW.md): `backend` is a REQUIRED positional parameter, not an
249
275
  * optional field a call site may quietly omit. Before this, both real call
250
276
  * sites built their deps here WITHOUT it, so `spawnAndRecordInstance()`'s own
251
- * `deps.backend ?? "fork"` default silently took over the moment crash
252
- * supervision replaced an instance -- a stock instance's crash-respawn or
253
- * `vice_recycle` relaunched it with the FORK's `-mcpserver` argv, which stock
254
- * upstream VICE does not understand at all, leaving a pool member that can
255
- * never be reached over the binary monitor again while still counting toward
277
+ * unset-parameter default silently took over the moment crash supervision
278
+ * replaced an instance -- a stock instance's crash-respawn or `vice_recycle`
279
+ * could relaunch it with a different backend's argv shape than the one it
280
+ * was actually launched with, leaving a pool member that can never be
281
+ * reached over the binary monitor again while still counting toward
256
282
  * countReady()/countTotal(). Making it positional and required is what makes
257
283
  * that omission a compile error rather than a silent backend swap: the FIRST
258
284
  * launch and every REPLACEMENT of it now build their argv from the SAME
@@ -294,6 +320,47 @@ function markDeliberateDeath(instance, respawnAfterKill) {
294
320
  instance.deliberateKill = true;
295
321
  instance.respawnAfterKill = respawnAfterKill;
296
322
  }
323
+ // ---------------------------------------------------------------------------
324
+ // Phase 33, plan 33-06 (REPRO-05, D-16, T-33-23/T-33-24): the warm-instance
325
+ // PROFILE-ELIGIBILITY rule.
326
+ //
327
+ // THE DECISION, stated out loud because two of the three available answers
328
+ // are wrong in ways the CALLER CANNOT DETECT:
329
+ // - Refuse the acquire outright when a mismatched warm instance exists ->
330
+ // warp becomes unusable whenever a warm floor exists (the default is 1,
331
+ // so: essentially always).
332
+ // - Serve the request with the mismatched instance -> the caller asked for
333
+ // warp, got an unwarped machine, and received a confident grant. The knob
334
+ // is a lie and nothing in the response says so.
335
+ // - D-16, what this implements: the mismatched instance is INELIGIBLE. The
336
+ // walk skips it and the acquire falls through to the cold arm, which
337
+ // launches a DEDICATED instance for that grant.
338
+ //
339
+ // WHAT MUST NEVER BE ADDED HERE: a retro-warp, and a kill-then-relaunch of a
340
+ // mismatched warm instance. There is no runtime `WarpMode` resource on stock
341
+ // at all (vsync.c:220-241, deliberately), so an existing instance cannot be
342
+ // adjusted -- it can only be ineligible. And "killing or relaunching
343
+ // preemptively to serve a newer request" is a NAMED anti-pattern in this
344
+ // project (CLAUDE.md): it would make an interactive session's emulator vanish
345
+ // because some capture run asked for warp. A test asserts the kill dependency
346
+ // is not called and the instance stays `ready`.
347
+ // ---------------------------------------------------------------------------
348
+ /** True when `record` was launched with the SAME profile `requested` asks
349
+ * for. FULLY SYNCHRONOUS by requirement, not by convenience -- see the call
350
+ * site inside selectWarmInstance() below for why.
351
+ *
352
+ * Absent is `{}`: a record with no `profile` field (a pre-33-06 record, a
353
+ * fork launch, a warm-floor spare, or a record a broker restarted mid-phase
354
+ * read from a state directory written before the field existed) is compared
355
+ * as though it carried `{}`, and so is an absent request. Each knob is
356
+ * compared `=== true` on BOTH sides, so `undefined` and `false` are the same
357
+ * request -- which is what makes an absent profile, an explicit `{}` and
358
+ * `{warp:false, headless:false}` one single behaviour rather than three. */
359
+ export function profileEligible(record, requested) {
360
+ const have = record.profile ?? {};
361
+ const want = requested ?? {};
362
+ return (have.warp === true) === (want.warp === true) && (have.headless === true) === (want.headless === true);
363
+ }
297
364
  /** Walks `state.instances` for probe-live `ready` candidates, in iteration
298
365
  * order, and returns the first that answers a grant-time re-probe (P-02) --
299
366
  * or `null` once every candidate has been tried and none answered, letting
@@ -354,6 +421,28 @@ async function selectWarmInstance(state, deps) {
354
421
  for (const record of Array.from(state.instances.values())) {
355
422
  if (record.state !== "ready")
356
423
  continue;
424
+ // Phase 33, plan 33-06 (D-16, T-33-23): a SYNCHRONOUS `continue`, sitting
425
+ // immediately beside the `record.state !== "ready"` filter directly
426
+ // above and BEFORE the readiness probe below. That placement is
427
+ // load-bearing twice over, and neither reason is stylistic:
428
+ //
429
+ // 1. It introduces NO new `await` into the region the single-owner
430
+ // `inFlight` launch guard protects. That guard exists because of the
431
+ // 2026-08-01 triple-launch outage and must stay a synchronous
432
+ // check-and-set with no `await` between (CLAUDE.md, regression-
433
+ // tested). A filter placed after the probe would put a fresh
434
+ // suspension point inside that region -- which is why this plan
435
+ // verifies the placement by line-number comparison, not by comment.
436
+ // 2. An ineligible candidate costs no probe at all -- no socket, no
437
+ // round trip, no wait.
438
+ //
439
+ // An ineligible miss falls through EXACTLY as a "no warm instance" miss
440
+ // does: to the caller's own cold arm, which records the one and only
441
+ // grant. It opens no second `state.grants.set()` call, and it never
442
+ // kills, recycles or re-warps the mismatched instance (see
443
+ // profileEligible()'s own banner for why those are excluded by design).
444
+ if (!profileEligible(record, deps.requestedProfile))
445
+ continue;
357
446
  const isReady = await deps.probe(record.port);
358
447
  // A sibling acquire may have granted OR dropped this exact candidate
359
448
  // while this probe was in flight. "Granted" changes record.state;
@@ -437,14 +526,14 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
437
526
  // WR-01: the readiness probe is backend-aware, from the SAME threaded-down
438
527
  // verdict handleAcquire already uses for buildViceArgs() -- on stock the port
439
528
  // speaks the binary monitor, so an HTTP POST there can never succeed.
440
- const backend = deps.backend ?? "fork";
529
+ const backend = deps.backend ?? "stock";
441
530
  const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
442
531
  // Textually a verifiedKill( call site, not merely a reference -- reused
443
532
  // UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
444
533
  // re-derived, and never replaced by a bare process.kill().
445
534
  const kill = deps.kill ?? ((opts) => verifiedKill(opts));
446
535
  const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
447
- const winner = await selectWarmInstance(state, { probe, kill, log });
536
+ const winner = await selectWarmInstance(state, { probe, kill, log, requestedProfile: deps.profile });
448
537
  let record;
449
538
  if (winner) {
450
539
  record = winner;
@@ -473,6 +562,14 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
473
562
  // build a different backend's argv than the launch it replaces.
474
563
  backend,
475
564
  allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
565
+ // Phase 33, plan 33-06 (D-15/D-16): the profile the warm arm just
566
+ // refused to compromise on reaches buildViceArgs() here, and is
567
+ // mirrored onto the fresh InstanceRecord by spawnAndRecordInstance()
568
+ // in the SAME step -- so this instance's recorded profile and its real
569
+ // argv are written together and cannot disagree. This is the arm that
570
+ // makes "a dedicated instance for that grant" true rather than
571
+ // aspirational.
572
+ profile: deps.profile,
476
573
  spawnFactory: deps.buildColdSpawnFactory ??
477
574
  ((port) => {
478
575
  const supervisorDir = join(stateDir, String(port));
@@ -482,6 +579,13 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
482
579
  }),
483
580
  });
484
581
  if (!result.ok) {
582
+ // Plan 41-05 (D-16, checkpoint option B): `result.reason` passes
583
+ // straight through -- `AcquireLaunchResult`'s reason union
584
+ // ("launch_in_flight" | "no_free_port" | "no_free_text_port") is a
585
+ // subset of `AcquireOutcome`'s, so a failed text-port allocation's own
586
+ // `no_free_text_port` reaches the control plane as its own distinct
587
+ // code (broker-control.mts's ControlErrorCode) rather than collapsing
588
+ // to `internal` or to the generic `no_free_port`.
485
589
  return { ok: false, reason: result.reason };
486
590
  }
487
591
  if (result.record.pid === null) {
@@ -494,16 +598,29 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
494
598
  // even though the caller was already told "internal" right now.
495
599
  // CR-02: deleteInstanceRecord(), not a bare map delete -- a stock launch
496
600
  // that failed this way already had its second port allocated and
497
- // blocked by acquirePortAndLaunch().
601
+ // blocked by acquirePortAndLaunch(), and deleteInstanceRecord() hands
602
+ // that second port back to the allocator (via state.blockedPorts) in
603
+ // the SAME step as it removes the broken record -- confirmed still
604
+ // true after plan 41-05 (D-16): this branch is reached only once a
605
+ // record already exists, i.e. only once BOTH allocations already
606
+ // succeeded (a failed second allocation now fails the acquire before
607
+ // any record -- and before this `pid === null` check -- is ever
608
+ // reached at all).
498
609
  deleteInstanceRecord(state, result.record.port);
499
610
  return { ok: false, reason: "internal" };
500
611
  }
501
612
  record = result.record;
502
- // Only the cold-launch arm ever writes a FRESH epoch record -- the warm
503
- // arm's winner already has one, written when it was warmed
504
- // (maintainWarmFloorForRealBroker()'s own onLaunched hook), and
505
- // rewriting it here would advance an epoch no restart caused, which the
506
- // container-side assertSameMachine() would read as a machine change.
613
+ // Only the cold-launch arm ever writes a FRESH epoch record here --
614
+ // selectWarmInstance()'s own winner already has one. Plan 41-05 (folded
615
+ // todo) changes WHY that is true without changing that it IS true: a
616
+ // ready, ungranted candidate no longer comes from a warm-floor pass's
617
+ // own onLaunched hook (retired along with the floor) -- it comes from
618
+ // broker-launch.mts's own crash-supervision respawn path
619
+ // (launchSupervised(), which writes its own epoch record via
620
+ // deps.epoch.writeEpochRecord() on every launch and every respawn).
621
+ // Either way, rewriting the epoch here would advance an epoch no restart
622
+ // caused, which the container-side assertSameMachine() would read as a
623
+ // machine change.
507
624
  writeEpochForLaunch(record, lastLogRelPath);
508
625
  }
509
626
  // THE single grant-recording step, fed by both arms above -- no `await`
@@ -515,7 +632,18 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
515
632
  record.state = "granted";
516
633
  return {
517
634
  ok: true,
518
- grant: { port: record.port, url: record.url, epochFile: record.epochFile, supervisorDir: record.supervisorDir },
635
+ grant: {
636
+ port: record.port,
637
+ url: record.url,
638
+ epochFile: record.epochFile,
639
+ supervisorDir: record.supervisorDir,
640
+ // Plan 41-01 (D-15): key omitted entirely when the record has none --
641
+ // the fork case, and (until a later plan closes the port-allocation
642
+ // degrade path) a stock instance whose second port allocation itself
643
+ // failed. Same key-omitted-when-undefined idiom
644
+ // spawnAndRecordInstance() already uses for this same field.
645
+ ...(record.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: record.remoteMonitorPort }),
646
+ },
519
647
  };
520
648
  }
521
649
  /** Answers the `status` control-plane request: one entry per instance,
@@ -529,7 +657,10 @@ function handleStatus(state) {
529
657
  state: r.state,
530
658
  reason: r.reason,
531
659
  epoch: typeof r.epoch === "number" ? r.epoch : null,
532
- hasMonitorClient: r.monitorClient !== undefined,
660
+ // Plan 41-03 (D-14): "at least one channel is claimed" -- promoted from
661
+ // a single-field check, byte-identical wire shape, meaning stated
662
+ // explicitly (D-15).
663
+ hasMonitorClient: Object.keys(r.monitorClients).length > 0,
533
664
  }));
534
665
  }
535
666
  /** Resolves a monitor_claim/monitor_release target the SAME way
@@ -544,49 +675,55 @@ function resolveInstanceForMonitorTarget(targetId, state) {
544
675
  return null;
545
676
  return state.instances.get(grant.port) ?? null;
546
677
  }
547
- /** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13): exclusive
548
- * monitor-socket ownership enforced HERE, broker-side, so a conflicting
549
- * claim is refused by name before any second `connect()` is ever attempted
550
- * -- the one state stock VICE cannot report and no client-side heuristic
551
- * can diagnose. `targetId` doubles as both "which instance" (resolved via
552
- * the SAME grant lookup handleRelease()/handleRecycleForRealBroker() already
553
- * use) and "the requesting grant's own identity" -- the claim IS the grant,
554
- * so there is no separate identity to carry. A repeated claim from the SAME
555
- * grant is idempotent (`ok: true`, no second holder created); a claim from
556
- * a DIFFERENT grant while the instance already has a holder is refused,
557
- * naming the current holder (T-02-18) -- never the emulator's own fault. */
558
- export function handleMonitorClaim(requestId, targetId, state) {
678
+ /** Answers `monitor_claim` (plan 05, BROK-02/PROTO-08, D-13; per-channel
679
+ * since plan 41-03, D-14): exclusive monitor-socket ownership enforced
680
+ * HERE, broker-side, PER CHANNEL, so a conflicting claim is refused by name
681
+ * before any second `connect()` is ever attempted -- the one state stock
682
+ * VICE cannot report and no client-side heuristic can diagnose. `targetId`
683
+ * doubles as both "which instance" (resolved via the SAME grant lookup
684
+ * handleRelease()/handleRecycleForRealBroker() already use) and "the
685
+ * requesting grant's own identity" -- the claim IS the grant, so there is
686
+ * no separate identity to carry. A repeated claim from the SAME grant on
687
+ * the SAME channel is idempotent (`ok: true`, no second holder created); a
688
+ * claim from a DIFFERENT grant while that channel already has a holder is
689
+ * refused, naming the current holder and the channel (T-02-18) -- never the
690
+ * emulator's own fault. A DIFFERENT channel's holder is irrelevant to this
691
+ * decision -- claiming one channel never evicts or is refused by the
692
+ * other's holder. */
693
+ export function handleMonitorClaim(requestId, targetId, channel, state) {
559
694
  void requestId; // correlation only -- the claim's own identity is targetId itself
560
695
  const instance = resolveInstanceForMonitorTarget(targetId, state);
561
696
  if (!instance)
562
697
  return { ok: false, code: "bad_request" };
563
- const existing = instance.monitorClient;
698
+ const existing = instance.monitorClients[channel];
564
699
  if (!existing) {
565
- instance.monitorClient = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
700
+ instance.monitorClients[channel] = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
566
701
  return { ok: true };
567
702
  }
568
703
  if (existing.grantId === targetId) {
569
- return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
704
+ return { ok: true }; // idempotent repeat from the SAME grant on the SAME channel -- no second holder
570
705
  }
571
- return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
706
+ return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid, channel } };
572
707
  }
573
- /** Answers `monitor_release` (plan 05, T-02-01): clears `monitorClient` ONLY
574
- * when `targetId` names the CURRENT holder -- a non-holder is refused, not
708
+ /** Answers `monitor_release` (plan 05, T-02-01; per-channel since plan
709
+ * 41-03, D-14): clears ONLY the named channel's entry, ONLY when `targetId`
710
+ * names that channel's CURRENT holder -- a non-holder is refused, not
575
711
  * silently accepted (spoofing a release is exactly T-02-01's own
576
- * disposition). An instance with no current holder at all tolerates the
712
+ * disposition). A channel with no current holder at all tolerates the
577
713
  * release as a success, matching the container-side client's own documented
578
714
  * tolerance for releasing a socket the broker already cleared. */
579
- export function handleMonitorRelease(requestId, targetId, state) {
715
+ export function handleMonitorRelease(requestId, targetId, channel, state) {
580
716
  void requestId; // correlation only, matching handleMonitorClaim()'s own posture
581
717
  const instance = resolveInstanceForMonitorTarget(targetId, state);
582
718
  if (!instance)
583
719
  return { ok: false, code: "bad_request" };
584
- if (!instance.monitorClient)
720
+ const existing = instance.monitorClients[channel];
721
+ if (!existing)
585
722
  return { ok: true }; // already cleared -- tolerated, not an error
586
- if (instance.monitorClient.grantId !== targetId) {
723
+ if (existing.grantId !== targetId) {
587
724
  return { ok: false, code: "denied" };
588
725
  }
589
- clearMonitorClient(instance);
726
+ clearMonitorClient(instance, channel);
590
727
  return { ok: true };
591
728
  }
592
729
  /** Resolves a recycle target's emulator child pid from THIS broker's own
@@ -664,83 +801,26 @@ async function handleRecycleForRealBroker(targetId, state) {
664
801
  const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
665
802
  return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
666
803
  }
667
- /** The warm-floor concern of the fixed-order evaluation pass (D-24 drops
668
- * the projection write; the grant sweep does not appear -- D-12's
669
- * connection-is-the-lease). Builds a fresh MaintainWarmFloorDeps per call
670
- * (never reused across passes) wiring broker-state.mjs's real
671
- * allocatePort/counts and broker-launch.mjs's real probeReady, and hooks
672
- * onLaunched to write the SAME epoch record a cold acquire writes -- a
673
- * warm instance is a real process the moment it exists, per D-04.
674
- *
675
- * WR-04 (01.6.2.1-REVIEW.md): the log-path stash below is a LOCAL variable,
676
- * declared fresh once per call to THIS function -- exactly mirroring how
677
- * handleAcquire()'s own equivalent cold-launch log-path variable
678
- * (`lastLogRelPath`) is already scoped locally rather than to the module.
679
- * Both the write site (the spawn-wrapping closure) and the read site (the
680
- * `onLaunched` callback) live inside this SAME function body, so this is a
681
- * pure relocation with no behavioural change -- it removes the
682
- * cross-call-sharing risk a module-level `let` carried (correct only
683
- * because of invariants -- at most one launch per call, never invoked
684
- * concurrently with itself -- enforced elsewhere and never checked at the
685
- * point the variable used to be declared). */
686
- function maintainWarmFloorForRealBroker(stateDir, state, backend) {
687
- let lastWarmLaunchLogRelPath = "";
688
- return maintainWarmFloor({
804
+ /** Plan 41-05 (folded todo): the second concern of the fixed-order
805
+ * evaluation pass, RENAMED from the retired warm-floor maintenance function
806
+ * this replaces (D-24 drops the projection write; the grant sweep does not
807
+ * appear -- D-12's connection-is-the-lease). Unlike the function it
808
+ * replaces, this one never launches anything -- it wires only
809
+ * broker-launch.mjs's real promoteLaunchingInstances() against this
810
+ * broker's own state and the backend-aware readiness probe, so a
811
+ * `launching` instance (however it got there -- a cold acquire's own
812
+ * instance, or a crash-respawn) is promoted to `ready` the moment it
813
+ * answers. */
814
+ function promoteLaunchingForRealBroker(state, backend) {
815
+ return promoteLaunchingInstances({
689
816
  state,
690
- stateDir,
691
817
  backend,
692
- spawnFactory: (port) => {
693
- const supervisorDir = join(stateDir, String(port));
694
- const { spawn, logRelPath } = makeLoggingSpawn(join(supervisorDir, "logs"));
695
- // I-1 rider (08.2-06-PLAN.md, Task 2): forwards a third options
696
- // argument -- this is a SECOND, independent dropper on the
697
- // warm-floor arm; fixing only makeLoggingSpawn above would leave
698
- // this arm's own scratch XDG_CONFIG_HOME dropped right here.
699
- const stashingSpawn = (cmd, args, options) => {
700
- const child = spawn(cmd, args, options);
701
- // Stash the log path where onLaunched (fired synchronously right
702
- // after this returns, still within the SAME maintainWarmFloor()
703
- // call -- at most one launch per call, per the serialised-warming
704
- // invariant) can find it. withCrashSupervision() below composes
705
- // AROUND this function, so the stash still runs (and still
706
- // completes before onLaunched reads it) before the exit listener
707
- // is ever attached.
708
- lastWarmLaunchLogRelPath = logRelPath;
709
- return child;
710
- };
711
- // CR-01 (03-REVIEW.md): the SAME resolved `backend` this function
712
- // already receives for the launch argv is threaded into the supervision
713
- // deps, so a warm instance's own crash-respawn stays on its backend.
714
- return withCrashSupervision("spare", port, stashingSpawn, superviseDepsFor(stateDir, state, backend));
715
- },
716
- // WR-01: same backend-aware probe route as handleAcquire's, from the SAME
717
- // resolved verdict this function already receives for the launch argv.
818
+ // WR-01: same backend-aware probe route as handleAcquire's, from the
819
+ // SAME resolved verdict this function already receives.
718
820
  probe: (port) => probeReady(port, { backend }),
719
- allocatePort: nextFreePort,
720
- // Plan 03-04 (DIRECT-06, D-13): same wiring as handleAcquire()'s own
721
- // cold-launch arm -- acquirePortAndLaunch() (reached via
722
- // maintainWarmFloor() below) gates the second allocation on
723
- // `backend === "stock"` itself, so this function need not check the
724
- // backend before passing it.
725
- allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
726
- countReady,
727
- countTotal,
728
- countLaunching,
729
- onLaunched: (record) => {
730
- writeEpochForLaunch(record, lastWarmLaunchLogRelPath);
731
- },
732
821
  log: (line) => process.stderr.write(`${line}\n`),
733
822
  });
734
823
  }
735
- /** Exported ONLY so a test can drive the warm-floor arm's REAL spawn
736
- * composition (this function's own makeLoggingSpawn()+stashingSpawn+
737
- * withCrashSupervision() closure above) through the built artifact, the
738
- * same escape-hatch pattern `_superviseDepsFor` already establishes for the
739
- * respawn composition -- see vice-broker-acquire.test.ts's I-1 composition
740
- * tests (08.2-06-PLAN.md, Task 3), which call this directly with no spawn
741
- * override so the warm floor's own independent `stashingSpawn` dropper
742
- * cannot hide behind an injected stub. */
743
- export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
744
824
  /** Releases a grant and identity-verified-kills its instance -- but ONLY
745
825
  * when the port's CURRENT occupant is proven to be the SAME process this
746
826
  * grant was actually issued for (its own recorded `pid`, set at grant time
@@ -785,10 +865,11 @@ export function handleRelease(requestId, state) {
785
865
  const instance = state.instances.get(grant.port);
786
866
  if (instance && instance.pid === grant.pid) {
787
867
  markDeliberateDeath(instance, false);
788
- // Plan 05: releasing clears monitor-client ownership as a side effect
789
- // -- redundant with the instance-map deletion two lines below (the
790
- // WHOLE record, monitorClient included, is going away), but explicit
791
- // for the same reason GrantRecord's own clearing is explicit here: the
868
+ // Plan 05: releasing clears monitor-client ownership (every channel) as
869
+ // a side effect -- redundant with the instance-map deletion two lines
870
+ // below (the WHOLE record, monitorClients included, is going away), but
871
+ // explicit for the same reason GrantRecord's own clearing is explicit
872
+ // here: the
792
873
  // instance-map deletion is a Task-2-era invariant this task must not
793
874
  // depend on silently continuing to hold.
794
875
  clearMonitorClient(instance);
@@ -860,18 +941,47 @@ async function run(args) {
860
941
  // launch, and never called from inside broker-launch.mts's `inFlight`
861
942
  // single-owner guard (this call sits entirely outside it; no launch is
862
943
  // even possible yet at this point in run()). `supervisorDir: args.stateDir`
863
- // is passed explicitly -- args.stateDir IS `.vice-supervisor` under this
944
+ // is passed explicitly -- args.stateDir IS `.c64-re-tools/supervisor` under this
864
945
  // broker's own repo root (see parseArgs() above), so this is the SAME
865
946
  // directory repo-root.ts's supervisorDir() would resolve to, without this
866
947
  // host-bound module ever importing that container-side resolver directly
867
- // (backend-detect.mts's own header comment explains why it cannot). An
868
- // `indeterminate` outcome does not prevent the broker from starting: it
869
- // logs its own note (backend-detect.mts) and this line proceeds with the
870
- // "fork" answer resolvedBackend() already returns for that case -- the
871
- // pre-Phase-2 behaviour every existing install already has.
948
+ // (backend-detect.mts's own header comment explains why it cannot).
949
+ // FORKRM-01 (plan 52-06): there is nothing left to detect -- the resolved
950
+ // `backend` is always `"stock"`; what this call still does is resolve the
951
+ // binary's own identity for the log line below and initialise the
952
+ // capability cache backend-detect.mts's own BACK-04 record depends on.
872
953
  const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
873
954
  const backend = backendResult.backend;
874
- process.stderr.write(`vice-broker: backend "${backend}" (source: ${backendResult.source}, binary: ${backendResult.binPath})\n`);
955
+ process.stderr.write(`vice-broker: backend "${backend}" (binary: ${backendResult.binPath})\n`);
956
+ // Gap G-40-1, requirement R2 (plan 40-09): THE BROKER mints/verifies the
957
+ // Ghidra runs-root handle here -- after the unconditional startup reap
958
+ // above, and BEFORE the control listener below accepts a single
959
+ // connection -- so a container-side MCP server with no host tooling of
960
+ // its own still finds the handle in place the moment it can reach this
961
+ // broker at all. This is deliberately NOT the only call site:
962
+ // resolveGhidraProject() (ghidra-project.mts) calls the same function as
963
+ // an idempotent precondition, because two host-side routes never involve
964
+ // a broker at all -- the direct spawn of resources/host-tool.mjs from
965
+ // host-tool-client.ts:269-273 (the everyday route on a host with no
966
+ // devcontainer, and the route CI uses), and tests importing that
967
+ // artifact directly. Both callers write the identical relative-target
968
+ // link, so a race between them is a benign EEXIST, not a conflict (see
969
+ // ensureGhidraRunsHandle()'s own header). The negative rule: container-
970
+ // side code must NEVER mint this handle -- the link target is relative
971
+ // and correct only when written from the host's view of the workspace.
972
+ //
973
+ // Handled WITHOUT throwing: run() has no try/catch around this region and
974
+ // the broker must start regardless of the outcome here -- it serves
975
+ // twelve allowlisted tool ids and only one of them (ghidra.analyze) needs
976
+ // this handle. A refusal is surfaced as ONE stderr line naming the
977
+ // consequence; every other tool id is unaffected.
978
+ const ghidraHandleResult = ensureGhidraRunsHandle(args.repoRoot);
979
+ if (ghidraHandleResult.ok) {
980
+ process.stderr.write(`vice-broker: ghidra runs handle ${ghidraHandleResult.handle} -> ${ghidraHandleResult.target}\n`);
981
+ }
982
+ else {
983
+ process.stderr.write(`vice-broker: ghidra runs handle refused: ${ghidraHandleResult.message} -- ghidra.analyze will refuse by name until this is fixed by hand; every other tool id is unaffected\n`);
984
+ }
875
985
  // D-18: the singleton guarantee holds only while the control port keeps its default -- two brokers deliberately configured onto different ports are two brokers, and no code prevents that.
876
986
  let listener;
877
987
  try {
@@ -879,29 +989,52 @@ async function run(args) {
879
989
  host: controlHost,
880
990
  port: controlPort,
881
991
  token,
882
- onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
992
+ onAcquire: (requestId, profile) => handleAcquire(requestId, args.stateDir, state, {
883
993
  backend,
884
994
  // Plan 03-04 (DIRECT-06, D-13): threaded down to
885
995
  // acquirePortAndLaunch()'s own gate (backend === "stock"); this
886
- // callback does NOT re-read VICE_BACKEND itself.
996
+ // callback does not re-read any environment variable itself.
887
997
  allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
998
+ // Phase 33, plan 33-06 (REPRO-05, D-15): the ALREADY-NARROWED
999
+ // profile broker-control.mts handed this callback. Nothing here
1000
+ // re-validates it and nothing here reads a raw wire field --
1001
+ // normaliseLaunchProfile() is the single narrowing site, and it ran
1002
+ // before this callback was ever invoked.
1003
+ profile,
888
1004
  }),
889
1005
  onRelease: (requestId) => handleRelease(requestId, state),
890
1006
  onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
891
1007
  onStatus: () => handleStatus(state),
892
- onMonitorClaim: (requestId, targetId) => handleMonitorClaim(requestId, targetId, state),
893
- onMonitorRelease: (requestId, targetId) => handleMonitorRelease(requestId, targetId, state),
1008
+ // Phase 34, plan 34-01 (SEAM-01): its OWN callback, wired alongside
1009
+ // (never derived from) the other six above -- handed only
1010
+ // `args.repoRoot` and a stderr logger, never this broker's `state` map,
1011
+ // so it structurally cannot reach lease state through this closure.
1012
+ // 34-09 (CR-04): deliberately supplies no timeout, and that is
1013
+ // authoritative here, not an omission -- the per-tool budget table
1014
+ // inside runHostTool()/hostToolTimeoutMs() (host-tool.mts) is the ONE
1015
+ // place a budget is decided, and no wire field carries one across the
1016
+ // seam at all (the `deps.timeoutMs` this callback could pass is an
1017
+ // in-process test seam, not something a caller's request ever
1018
+ // supplies). A reader arriving here from the artifact this plan's
1019
+ // completeness case checks should find this comment as the answer,
1020
+ // not an apparent gap.
1021
+ onHostTool: (raw) => runHostTool(raw, {
1022
+ repoRoot: args.repoRoot,
1023
+ log: (line) => process.stderr.write(`${line}\n`),
1024
+ }),
1025
+ onMonitorClaim: (requestId, targetId, channel) => handleMonitorClaim(requestId, targetId, channel, state),
1026
+ onMonitorRelease: (requestId, targetId, channel) => handleMonitorRelease(requestId, targetId, channel, state),
894
1027
  onHostState: () => ({
895
1028
  pid: process.pid,
896
1029
  startedAt,
897
1030
  nodeVersion: process.version,
898
1031
  viceBin: resolveViceBinForHostState(),
899
- warmFloor: resolveWarmFloorForRecord(),
900
1032
  maxInstances: resolveCeilingForRecord(),
901
1033
  basePort: resolveBasePort(),
902
- // WR-04: the verdict THIS process resolved once, at startup, above --
903
- // the same one every launch argv is built from. Never a second
904
- // resolvedBackend() call (backend-detect.mts's own prohibition).
1034
+ // FORKRM-01 (plan 52-06): the verdict THIS process resolved once, at
1035
+ // startup, above -- kept on the wire because text-tools.ts's own
1036
+ // broker-identity cross-check (out of this plan's scope) still reads
1037
+ // it. Never a second resolvedBackend() call.
905
1038
  backend,
906
1039
  }),
907
1040
  });
@@ -951,11 +1084,21 @@ async function run(args) {
951
1084
  registerShutdownHandlers({ state });
952
1085
  // A successful bind writes the record UNCONDITIONALLY, overwriting
953
1086
  // whatever was there -- the bind itself is the proof of singleton status
954
- // (D-17). The fourteen-field set (D-27, criterion G): the lease
955
- // time-to-live field the bash original carried is gone -- the connection
956
- // is the lease now (D-12) -- and every other config-echo field survives
1087
+ // (D-17). The fourteen-field set (D-27, criterion G; narrowed from
1088
+ // fourteen to thirteen by plan 41-05, then widened back to fourteen to add
1089
+ // node_exec_path): the lease time-to-live field the bash original carried
1090
+ // is gone -- the connection is the lease now (D-12) -- `warm_floor` is
1091
+ // likewise gone (plan 41-05: there is no warm floor left to echo a
1092
+ // configured value for) -- and every other config-echo field survives
957
1093
  // even though no consumer parses it beyond a status message, because a
958
1094
  // human reading this file by hand benefits from the full echo.
1095
+ //
1096
+ // node_exec_path is process.execPath, not something threaded in from
1097
+ // outside: exec() replaces the process image, so whatever interpreter the
1098
+ // launcher resolved and gated IS this process's own execPath by the time
1099
+ // this line runs -- the record tells the truth without either side having
1100
+ // to pass anything, and it stays truthful even when this broker was
1101
+ // started directly, bypassing the launcher entirely.
959
1102
  let record = {
960
1103
  version: 1,
961
1104
  written_by: WRITTEN_BY,
@@ -963,17 +1106,17 @@ async function run(args) {
963
1106
  started_at: startedAt,
964
1107
  heartbeat_at: new Date().toISOString(),
965
1108
  node_version: process.version,
1109
+ node_exec_path: process.execPath,
966
1110
  control_host: listener.host,
967
1111
  control_port: listener.port,
968
1112
  control_token: token, // never logged -- T-01.6.2-02
969
- warm_floor: resolveWarmFloorForRecord(),
970
1113
  max_instances: resolveCeilingForRecord(),
971
1114
  base_port: resolveBasePort(),
972
1115
  poll_ms: pollMs,
973
1116
  dry_run: args.dryRun,
974
1117
  };
975
1118
  writeBrokerRecordFile(args.stateDir, record);
976
- process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version}); control listener bound on ${listener.host}:${listener.port}\n`);
1119
+ process.stderr.write(`vice-broker: wrote ${finalPath} (node ${record.node_version} at ${record.node_exec_path}); control listener bound on ${listener.host}:${listener.port}\n`);
977
1120
  const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
978
1121
  setInterval(() => {
979
1122
  // The refresh path goes through the SAME atomic tmp-then-rename choke
@@ -984,18 +1127,21 @@ async function run(args) {
984
1127
  writeBrokerRecordFile(args.stateDir, record);
985
1128
  }, heartbeatMs);
986
1129
  // The fixed-order evaluation pass (runBrokerPass, broker-launch.mts):
987
- // serve pending acquires, then maintain the warm floor -- mirroring
988
- // vice-broker.sh's own broker_once() ordering. Ticks on
989
- // VICE_BROKER_POLL_MS (default 500, the SAME env var name and semantics
990
- // the bash daemon used). serveAcquires now drains the arrival-ordered
991
- // pending-acquire structure this listener instance owns (D-08's
992
- // mechanism; plan 02's own `serveAcquires: () => {}` comment reserved
993
- // exactly this room) -- an acquire queued because a launch was already in
994
- // flight is retried here, on the SAME pass that also maintains the warm
995
- // floor, so a stalled pass shows up as a stale record rather than a
996
- // silently wrong one. Re-entrancy guarded: a pass that is still running
997
- // (e.g. a slow readiness probe against a genuinely slow host) is never
998
- // overlapped by the next tick.
1130
+ // serve pending acquires, then promote launching -> ready -- mirroring
1131
+ // vice-broker.sh's own broker_once() ordering (plan 41-05, folded todo:
1132
+ // the warm floor this pass used to maintain as its second concern is
1133
+ // RETIRED; see runBrokerPass()'s own comment in broker-launch.mts for what
1134
+ // the fixed order still buys now that only serveAcquires() ever launches
1135
+ // anything). Ticks on VICE_BROKER_POLL_MS (default 500, the SAME env var
1136
+ // name and semantics the bash daemon used). serveAcquires now drains the
1137
+ // arrival-ordered pending-acquire structure this listener instance owns
1138
+ // (D-08's mechanism; plan 02's own `serveAcquires: () => {}` comment
1139
+ // reserved exactly this room) -- an acquire queued because a launch was
1140
+ // already in flight is retried here, on the SAME pass that also promotes
1141
+ // any newly-ready instance, so a stalled pass shows up as a stale record
1142
+ // rather than a silently wrong one. Re-entrancy guarded: a pass that is
1143
+ // still running (e.g. a slow readiness probe against a genuinely slow
1144
+ // host) is never overlapped by the next tick.
999
1145
  let passInFlight = false;
1000
1146
  setInterval(() => {
1001
1147
  if (passInFlight)
@@ -1003,7 +1149,7 @@ async function run(args) {
1003
1149
  passInFlight = true;
1004
1150
  runBrokerPass({
1005
1151
  serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
1006
- maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state, backend),
1152
+ promoteLaunching: () => promoteLaunchingForRealBroker(state, backend),
1007
1153
  })
1008
1154
  .catch((e) => {
1009
1155
  process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);