@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,12 +2,12 @@
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
  //
9
- // The long-lived host broker entry point (Phase 01.6.2). Extends the Phase
10
- // 01.6 tracer in place rather than replacing it: parseArgs(),
9
+ // The long-lived host broker entry point. Extends an earlier write-once
10
+ // tracer script in place rather than replacing it: parseArgs(),
11
11
  // readBrokerRecordMaybe() and the atomic tmp-sibling-then-rename write
12
12
  // discipline all survive; main() grows a real control listener, a
13
13
  // heartbeat and a real acquire/release path spawning a real child.
@@ -29,26 +29,58 @@ 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
+ // countReady/countTotal/countLaunching are DROPPED from this import -- they
33
+ // were used only as maintainWarmFloorForRealBroker()'s own deps for the
34
+ // now-retired maintainWarmFloor() (the warm floor itself was retired once
35
+ // the connection became the lease, with no separate expiry left to
36
+ // maintain), passed through by shorthand property (`countReady,` etc.),
37
+ // never called directly in this file. atCapacity() is the one survivor
38
+ // actually called here (its own cold-launch-arm gate, below).
39
+ import { createBrokerState, nextFreePort, atCapacity, resolveBasePort, clearMonitorClient, } from "./broker-state.mjs";
40
+ import { acquirePortAndLaunch, deleteInstanceRecord,
41
+ // Replaces maintainWarmFloor -- the warm floor itself is retired (the
42
+ // connection is the lease now, so there is no separate floor left to
43
+ // keep warm); this is ONLY the launching -> ready promotion sweep the
44
+ // floor used to carry as its own step 1.
45
+ promoteLaunchingInstances, probeReady, runBrokerPass, withCrashSupervision, } from "./broker-launch.mjs";
46
+ // resolvedBackend() resolves the emulator binary's identity -- ViceBackend's
47
+ // own definition lives in backend-detect.mts too (narrowed to a single
48
+ // literal now that the fork backend has been removed entirely), so
49
+ // broker-launch.mjs's own (type-only) re-import of it and this file's VALUE
50
+ // import both name the same one home. A real value import is safe here
51
+ // (unlike inside
52
+ // broker-launch.mts) because vice-broker.mts is ALWAYS run from its own
53
+ // compiled resources/ form -- both modules are compiled together in the
54
+ // same build.ts pass, so "./backend-detect.mjs" always exists as a real
55
+ // sibling file by the time this import resolves.
42
56
  import { resolvedBackend } from "./backend-detect.mjs";
43
57
  import { verifiedKill, registerShutdownHandlers, startupBanner, reapOrphanedInstances } from "./broker-kill.mjs";
44
58
  import { writeEpochRecord, epochPathFor, nextEpochFor, instanceLogDirFor } from "./broker-epoch.mjs";
59
+ // A VALUE import of the host-tool executor -- safe here for the SAME reason
60
+ // every other sibling value import above is:
61
+ // this file is ALWAYS run from its own compiled resources/ form, and
62
+ // "./host-tool.mjs" is compiled into that same directory by the same build.ts
63
+ // pass (host-tool.mts is added to HOST_BOUND_ARTIFACTS/tsconfig.build.json's
64
+ // include[] in this same commit).
65
+ import { runHostTool } from "./host-tool.mjs";
66
+ // A VALUE import of the same handle-minting function for the SAME reason as
67
+ // the host-tool.mjs import immediately above -- this file is always run
68
+ // from its own compiled resources/ form, and "./ghidra-project.mjs" is
69
+ // compiled into that same directory by the same build.ts pass (both source
70
+ // and target are already listed in HOST_BOUND_ARTIFACTS).
71
+ import { ensureGhidraRunsHandle } from "./ghidra-project.mjs";
45
72
  import { startControlListener, newControlToken, drainPendingAcquires, resolveControlPort, } from "./broker-control.mjs";
46
73
  const USAGE = "usage: vice-broker.mjs --repo-root <path> [--state-dir <path>] [--check-container] [--dry-run]";
47
74
  /** `--repo-root` is required UNLESS `--check-container` is given -- the
48
75
  * container guard needs no paths at all, matching the bash launcher's own
49
76
  * `--check-container` handling (answered before any path resolution).
50
77
  * `--state-dir` defaults to VICE_POOL_DIR from the environment when set,
51
- * otherwise `.vice-supervisor` under the repo root. */
78
+ * otherwise `.c64-re-tools/supervisor` under the repo root (moved 2026-09-08
79
+ * from `.vice-supervisor`; the three-tier chain itself -- explicit
80
+ * `--state-dir`, then `VICE_POOL_DIR`, then this default -- is unchanged,
81
+ * only the default's location moved). This module is host-bound and compiled
82
+ * by `build.ts`, so it must not import the container-side `repo-root.ts`;
83
+ * the two segments are joined directly, matching that file's `toolsDir()`. */
52
84
  export function parseArgs(argv) {
53
85
  let repoRoot = null;
54
86
  let stateDir = null;
@@ -73,39 +105,33 @@ export function parseArgs(argv) {
73
105
  if (!checkContainer && !repoRoot) {
74
106
  throw new Error(USAGE);
75
107
  }
76
- const resolvedStateDir = stateDir ?? process.env.VICE_POOL_DIR ?? (repoRoot ? join(repoRoot, ".vice-supervisor") : ".vice-supervisor");
108
+ const resolvedStateDir = stateDir ??
109
+ process.env.VICE_POOL_DIR ??
110
+ (repoRoot ? join(repoRoot, ".c64-re-tools", "supervisor") : join(".c64-re-tools", "supervisor"));
77
111
  return { repoRoot: repoRoot ?? "", stateDir: resolvedStateDir, checkContainer, dryRun };
78
112
  }
79
- /** The deployed JavaScript broker artifact's own name -- D-26's entire
80
- * point: this field used to read "vice-broker.sh" (the retiring bash
81
- * daemon), which was false the moment a real TypeScript broker existed.
82
- * It now names itself. */
113
+ /** The deployed JavaScript broker artifact's own name. This field used to
114
+ * read "vice-broker.sh" (the retiring bash daemon), which was false the
115
+ * moment a real TypeScript broker existed. It now names itself. */
83
116
  export const WRITTEN_BY = "vice-broker.mjs";
84
117
  // ---------------------------------------------------------------------------
85
- // Small, locally-duplicated env-var readers (plan 05) -- the SAME pattern
118
+ // Small, locally-duplicated env-var reader (plan 05) -- the SAME pattern
86
119
  // broker-kill.mts's own resolveBasePortForReap()/resolveViceBinForReap()
87
120
  // 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.
121
+ // PRIVATE resolveCeiling() (it is not exported, and this file is already
122
+ // the top-level wiring module value-importing every sibling .mjs directly --
123
+ // exporting it would widen broker-launch.mts's own surface for a one-line
124
+ // env-var read this file can duplicate exactly as cheaply). Mirrors
125
+ // broker-launch.mts's own default precisely (VICE_BROKER_MAX/16) so
126
+ // broker.json's config echo and host_state's own answer can never disagree
127
+ // with what atCapacity() itself actually enforces. This used to be a PAIR
128
+ // with resolveWarmFloorForRecord() (VICE_BROKER_WARM_FLOOR/1), kept in
129
+ // lockstep with broker-launch.mts's own matching pair so the two numbers
130
+ // could never disagree. The warm-floor half of that pair is RETIRED along
131
+ // with the floor itself -- the
132
+ // ceiling's own default (16) is untouched, since it is a separate concern
133
+ // (VICE_BROKER_MAX / atCapacity()) this plan does not touch.
101
134
  // ---------------------------------------------------------------------------
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
135
  function resolveCeilingForRecord() {
110
136
  const raw = process.env.VICE_BROKER_MAX;
111
137
  if (raw === undefined || raw === "")
@@ -178,21 +204,21 @@ function writeBrokerRecordFile(stateDir, record) {
178
204
  return finalPath;
179
205
  }
180
206
  /** Builds a spawn function that redirects the child's stdout/stderr into a
181
- * 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
+ * FRESH per-launch log file under logDir (so per-instance boot/crash logs
208
+ * survive under .c64-re-tools/supervisor/<port>/logs/, same paths, same
183
209
  * format as the retiring bash supervisor), returning both the spawn
184
210
  * closure and the log's path relative to supervisorDir (the epoch
185
211
  * record's own `log` field). Shared by both launch paths -- a cold
186
212
  * acquire and warm-floor maintenance -- so there is exactly one place that
187
213
  * opens a launch log fd.
188
214
  *
189
- * I-1 rider (08.2-06-PLAN.md, Task 2): the returned `spawn` now also
190
- * forwards a caller options object (audit item I-1), MERGING it into the
191
- * object handed to nodeSpawn() -- caller options spread FIRST, `stdio` set
192
- * LAST, so the launch log fd always wins over any caller-supplied `stdio`.
193
- * Merging in the other order would silently redirect a launch's output
194
- * away from the per-instance log file the epoch record names, breaking
195
- * D-23's forensic logs while appearing to work. */
215
+ * The returned `spawn` now also forwards a caller options object, MERGING
216
+ * it into the object handed to nodeSpawn() -- caller options spread FIRST,
217
+ * `stdio` set LAST, so the launch log fd always wins over any
218
+ * caller-supplied `stdio`. Merging in the other order would silently
219
+ * redirect a launch's output away from the per-instance log file the
220
+ * epoch record names, breaking the per-instance forensic logs while
221
+ * appearing to work. */
196
222
  function makeLoggingSpawn(logDir) {
197
223
  mkdirSync(logDir, { recursive: true });
198
224
  const viceBinForLog = basename(process.env.VICE_BIN ?? "x64sc");
@@ -204,8 +230,9 @@ function makeLoggingSpawn(logDir) {
204
230
  };
205
231
  }
206
232
  /** Writes the epoch record for a just-launched instance -- shared by both
207
- * launch paths so D-04's contract (format, location, atomic-write
208
- * discipline, all unchanged -- only the writer moves) is discharged from
233
+ * launch paths so the epoch record's own contract (format, location,
234
+ * atomic-write discipline, all unchanged -- only the writer moves) is
235
+ * discharged from
209
236
  * exactly one place regardless of WHY the instance was launched. A
210
237
  * granted instance and a still-warm instance are equally real processes; both
211
238
  * need a real epoch.json the moment they exist, or plan 04's grant-time
@@ -233,26 +260,28 @@ function writeEpochForLaunch(record, logRelPath) {
233
260
  record.epoch = epochRecord.epoch;
234
261
  }
235
262
  /** 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
239
- * NOT set spawnFactory: on a respawn, launchSupervised() (broker-launch.mts)
240
- * derives its own per-instance log path from instanceLogDirFor and names
241
- * that same path in the epoch record it writes -- supplying a competing
242
- * spawn factory here would produce two log files per respawn with the
243
- * epoch record naming the wrong one. Leaving it unset means a respawn's
244
- * output lands in the supervision module's own log file under the same
245
- * per-instance logs directory D-23 requires, and the epoch record names
246
- * the file that actually received the output.
263
+ * once per launch, so the real launch path (handleAcquire's own cold arm,
264
+ * here -- the second real launch path this comment used to name, the warm
265
+ * floor, was retired once the connection became the lease) passes a
266
+ * structurally identical SuperviseChildDeps object into the shared wrapper.
267
+ * Deliberately does NOT set spawnFactory: on a respawn, launchSupervised()
268
+ * (broker-launch.mts) derives its own per-instance log path from
269
+ * instanceLogDirFor and names that same path in the epoch record it writes
270
+ * -- supplying a competing spawn factory here would produce two log files
271
+ * per respawn with the epoch record naming the wrong one. Leaving it unset
272
+ * means a respawn's output lands in the supervision module's own log file
273
+ * under the same per-instance logs directory the epoch record already
274
+ * requires, and the epoch record names the file that actually received the
275
+ * output.
247
276
  *
248
- * CR-01 (03-REVIEW.md): `backend` is a REQUIRED positional parameter, not an
277
+ * `backend` is a REQUIRED positional parameter, not an
249
278
  * optional field a call site may quietly omit. Before this, both real call
250
279
  * 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
280
+ * unset-parameter default silently took over the moment crash supervision
281
+ * replaced an instance -- a stock instance's crash-respawn or `vice_recycle`
282
+ * could relaunch it with a different backend's argv shape than the one it
283
+ * was actually launched with, leaving a pool member that can never be
284
+ * reached over the binary monitor again while still counting toward
256
285
  * countReady()/countTotal(). Making it positional and required is what makes
257
286
  * that omission a compile error rather than a silent backend swap: the FIRST
258
287
  * launch and every REPLACEMENT of it now build their argv from the SAME
@@ -275,8 +304,8 @@ function superviseDepsFor(stateDir, state, backend, binmonHost) {
275
304
  /** Exported ONLY so a test can install withCrashSupervision() through the
276
305
  * REAL deps object this module actually uses in production, rather than a
277
306
  * hand-built SuperviseChildDeps that can (and did) diverge from it -- the
278
- * exact blind spot CR-01 (03-REVIEW.md) lived in: broker-launch.test.ts's own
279
- * respawn/recycle tests each construct their deps inline and therefore pass
307
+ * exact blind spot the backend-argv bug above lived in: broker-launch.test.ts's
308
+ * own respawn/recycle tests each construct their deps inline and therefore pass
280
309
  * `backend: "stock"` directly, so the production builder's missing field was
281
310
  * invisible to the whole suite. Same discipline as broker-kill.mts's
282
311
  * `_HANDLED_SIGNALS`: an underscore-prefixed alias, never called by any
@@ -294,37 +323,75 @@ function markDeliberateDeath(instance, respawnAfterKill) {
294
323
  instance.deliberateKill = true;
295
324
  instance.respawnAfterKill = respawnAfterKill;
296
325
  }
326
+ // ---------------------------------------------------------------------------
327
+ // THE WARM-INSTANCE PROFILE-ELIGIBILITY RULE.
328
+ //
329
+ // THE DECISION, stated out loud because two of the three available answers
330
+ // are wrong in ways the CALLER CANNOT DETECT:
331
+ // - Refuse the acquire outright when a mismatched warm instance exists ->
332
+ // warp becomes unusable whenever a warm floor exists (the default is 1,
333
+ // so: essentially always).
334
+ // - Serve the request with the mismatched instance -> the caller asked for
335
+ // warp, got an unwarped machine, and received a confident grant. The knob
336
+ // is a lie and nothing in the response says so.
337
+ // - What this implements: the mismatched instance is INELIGIBLE. The
338
+ // walk skips it and the acquire falls through to the cold arm, which
339
+ // launches a DEDICATED instance for that grant.
340
+ //
341
+ // WHAT MUST NEVER BE ADDED HERE: a retro-warp, and a kill-then-relaunch of a
342
+ // mismatched warm instance. There is no runtime `WarpMode` resource on stock
343
+ // at all (vsync.c:220-241, deliberately), so an existing instance cannot be
344
+ // adjusted -- it can only be ineligible. And "killing or relaunching
345
+ // preemptively to serve a newer request" is a NAMED anti-pattern in this
346
+ // project (CLAUDE.md): it would make an interactive session's emulator vanish
347
+ // because some capture run asked for warp. A test asserts the kill dependency
348
+ // is not called and the instance stays `ready`.
349
+ // ---------------------------------------------------------------------------
350
+ /** True when `record` was launched with the SAME profile `requested` asks
351
+ * for. FULLY SYNCHRONOUS by requirement, not by convenience -- see the call
352
+ * site inside selectWarmInstance() below for why.
353
+ *
354
+ * Absent is `{}`: a record with no `profile` field (a pre-33-06 record, a
355
+ * fork launch, a warm-floor spare, or a record a broker restarted mid-phase
356
+ * read from a state directory written before the field existed) is compared
357
+ * as though it carried `{}`, and so is an absent request. Each knob is
358
+ * compared `=== true` on BOTH sides, so `undefined` and `false` are the same
359
+ * request -- which is what makes an absent profile, an explicit `{}` and
360
+ * `{warp:false, headless:false}` one single behaviour rather than three. */
361
+ export function profileEligible(record, requested) {
362
+ const have = record.profile ?? {};
363
+ const want = requested ?? {};
364
+ return (have.warp === true) === (want.warp === true) && (have.headless === true) === (want.headless === true);
365
+ }
297
366
  /** Walks `state.instances` for probe-live `ready` candidates, in iteration
298
367
  * order, and returns the first that answers a grant-time re-probe (P-02) --
299
368
  * or `null` once every candidate has been tried and none answered, letting
300
- * the caller fall through to a cold launch (P-03). Regardless of
301
- * `record.reason`: per D-07, a waiting request takes an instance whichever
302
- * reason booted it, so a warm-floor instance and a not-yet-granted instance are
303
- * equally eligible. Kill-never-recycle needs no separate guard here --
369
+ * the caller fall through to a cold launch. Regardless of `record.reason`:
370
+ * a waiting request takes an instance whichever reason booted it, so a
371
+ * warm-floor instance and a not-yet-granted instance are equally eligible.
372
+ * Kill-never-recycle needs no separate guard here --
304
373
  * handleRelease() below already deletes a released instance's record
305
374
  * outright, so a released instance is structurally absent from
306
375
  * `state.instances` and can never be a candidate.
307
376
  *
308
377
  * A candidate whose grant-time probe FAILS is dropped -- de-registered from
309
378
  * `state.instances` -- and identity-verified-killed BEFORE the walk
310
- * continues to the next candidate, but per WR-02
311
- * (`.planning/todos/pending/2026-08-05-wr-02-*`, decision: fix now rather
312
- * than defer further) the kill itself is fire-and-forget, matching
313
- * handleRelease()'s own posture a few hundred lines below
314
- * (`verifiedKill(...).catch(...)`, never awaited by that call site either):
315
- * the acquiring request must not wait up to `VICE_BROKER_KILL_WAIT_S`
316
- * (default 5s) of SIGTERM-then-poll-then-SIGKILL PER DEAD CANDIDATE before
317
- * the walk can move on -- that wait is exactly what turns a warm floor's
318
- * fast, in-memory grant into a multi-second serial teardown on a single
319
- * request's hot path once the warm floor is configured above its default
320
- * of 1 (WR-02's own bounding condition). The drop -- `markDeliberateDeath()`
379
+ * continues to the next candidate, but the kill itself is deliberately
380
+ * fire-and-forget, matching handleRelease()'s own posture a few hundred
381
+ * lines below (`verifiedKill(...).catch(...)`, never awaited by that call
382
+ * site either): the acquiring request must not wait up to
383
+ * `VICE_BROKER_KILL_WAIT_S` (default 5s) of SIGTERM-then-poll-then-SIGKILL
384
+ * PER DEAD CANDIDATE before the walk can move on -- that wait is exactly
385
+ * what turns a warm floor's fast, in-memory grant into a multi-second
386
+ * serial teardown on a single request's hot path once the warm floor is
387
+ * configured above its default of 1. The drop -- `markDeliberateDeath()`
321
388
  * plus `state.instances.delete()` -- still happens SYNCHRONOUSLY, in the
322
389
  * same tick as the probe failure, before `deps.kill(...)` is even invoked;
323
- * only the kill's own SETTLEMENT is decoupled from this walk. This is
324
- * WR-02's fix option 1, not option 2 (capping how many failed candidates a
325
- * single acquire will wait through): option 1 matches an idiom the file
326
- * already uses elsewhere rather than inventing a new bound, and removes the
327
- * wait entirely rather than merely capping it. The grant-time-probe-failure
390
+ * only the kill's own SETTLEMENT is decoupled from this walk. This matches
391
+ * an idiom the file already uses elsewhere rather than inventing a new
392
+ * bound, and removes the wait entirely rather than merely capping it, by
393
+ * design: capping how many failed candidates a single acquire will wait
394
+ * through was the alternative considered and rejected. The grant-time-probe-failure
328
395
  * log line's own ordering is decoupled accordingly (see below) -- it can no
329
396
  * longer name the kill's resolved stage synchronously, since nothing here
330
397
  * waits for it to resolve. The marker is set BEFORE any signal reaches the
@@ -337,29 +404,50 @@ function markDeliberateDeath(instance, respawnAfterKill) {
337
404
  * immediately after every `await` (the probe call itself) and BEFORE ever
338
405
  * treating a probe-live candidate as the winner -- this is what makes the
339
406
  * caller's own "no await between selection and the grant-recording step"
340
- * property (T-01.6.2.1-03) actually hold under two concurrent acquires. A
341
- * candidate's own probe response cannot change because a sibling acquire
342
- * granted it first, but its RECORDED state does, the instant that sibling's
343
- * synchronous grant step runs -- recorded state alone catches that case.
344
- * It does NOT catch a sibling that has already DROPPED this exact candidate
345
- * (a failed grant-time probe: markDeliberateDeath() + state.instances.delete(),
346
- * which never touches record.state -- the drop path a few lines below) --
347
- * 01.6.2.1-VERIFICATION.md's CR-01 finding, re-confirmed here: a state-only
348
- * recheck is blind to a concurrent drop, letting a second caller's stale
349
- * object reference win a grant for a record that is no longer in
350
- * state.instances at all, orphaning the grant. Rechecking
407
+ * property actually hold under two concurrent acquires. A candidate's own
408
+ * probe response cannot change because a sibling acquire granted it first,
409
+ * but its RECORDED state does, the instant that sibling's synchronous grant
410
+ * step runs -- recorded state alone catches that case. It does NOT catch a
411
+ * sibling that has already DROPPED this exact candidate (a failed
412
+ * grant-time probe: markDeliberateDeath() + state.instances.delete(), which
413
+ * never touches record.state -- the drop path a few lines below) -- a
414
+ * state-only recheck is blind to a concurrent drop, letting a second
415
+ * caller's stale object reference win a grant for a record that is no
416
+ * longer in state.instances at all, orphaning the grant. Rechecking
351
417
  * `state.instances.get(record.port) === record` (identity, not merely a
352
418
  * port-number lookup) closes that case too. */
353
419
  async function selectWarmInstance(state, deps) {
354
420
  for (const record of Array.from(state.instances.values())) {
355
421
  if (record.state !== "ready")
356
422
  continue;
423
+ // A SYNCHRONOUS `continue`, sitting
424
+ // immediately beside the `record.state !== "ready"` filter directly
425
+ // above and BEFORE the readiness probe below. That placement is
426
+ // load-bearing twice over, and neither reason is stylistic:
427
+ //
428
+ // 1. It introduces NO new `await` into the region the single-owner
429
+ // `inFlight` launch guard protects. That guard exists because of the
430
+ // 2026-08-01 triple-launch outage and must stay a synchronous
431
+ // check-and-set with no `await` between (CLAUDE.md, regression-
432
+ // tested). A filter placed after the probe would put a fresh
433
+ // suspension point inside that region -- which is why this plan
434
+ // verifies the placement by line-number comparison, not by comment.
435
+ // 2. An ineligible candidate costs no probe at all -- no socket, no
436
+ // round trip, no wait.
437
+ //
438
+ // An ineligible miss falls through EXACTLY as a "no warm instance" miss
439
+ // does: to the caller's own cold arm, which records the one and only
440
+ // grant. It opens no second `state.grants.set()` call, and it never
441
+ // kills, recycles or re-warps the mismatched instance (see
442
+ // profileEligible()'s own banner for why those are excluded by design).
443
+ if (!profileEligible(record, deps.requestedProfile))
444
+ continue;
357
445
  const isReady = await deps.probe(record.port);
358
446
  // A sibling acquire may have granted OR dropped this exact candidate
359
447
  // while this probe was in flight. "Granted" changes record.state;
360
448
  // "dropped" removes the record from state.instances outright and never
361
449
  // touches record.state -- so map membership must be rechecked too, not
362
- // merely the state field (CR-01, 01.6.2.1-REVIEW.md/01.6.2.1-VERIFICATION.md).
450
+ // merely the state field.
363
451
  if (record.state !== "ready" || state.instances.get(record.port) !== record) {
364
452
  continue;
365
453
  }
@@ -367,27 +455,27 @@ async function selectWarmInstance(state, deps) {
367
455
  return record;
368
456
  }
369
457
  // Drop and de-register FIRST, synchronously, before the kill is even
370
- // invoked -- this is what CR-01's identity recheck above depends on:
371
- // the record must already be gone from state.instances by the time a
372
- // concurrent sibling's own probe on this same candidate resolves.
373
- // WR-02 only changes what happens to the kill's own PROMISE next, never
374
- // this ordering.
458
+ // invoked -- this is what the identity recheck above depends on: the
459
+ // record must already be gone from state.instances by the time a
460
+ // concurrent sibling's own probe on this same candidate resolves. The
461
+ // fire-and-forget kill below only changes what happens to the kill's
462
+ // own PROMISE next, never this ordering.
375
463
  markDeliberateDeath(record, false);
376
- // CR-02 (03-REVIEW.md): dropping a record is also where its second
464
+ // Dropping a record is also where its second
377
465
  // (`-remotemonitor`) port stops being spoken for -- deleteInstanceRecord()
378
466
  // is the ONE place both mutations happen together, so a drop can never
379
467
  // leak a port out of the fixed allocation band.
380
468
  deleteInstanceRecord(state, record.port);
381
469
  // Distinct wording from shutdown()'s own "shutdown complete" line
382
470
  // (broker-kill.mts) and from handleRecycleForRealBroker's own log-free
383
- // path -- D-07's standing constraint that a lifecycle decision must be
471
+ // path -- the standing constraint that a lifecycle decision must be
384
472
  // reconstructable from the log after an incident (both 2026-08-01 and
385
473
  // 2026-08-02 were diagnosed from broker log lines). Logged BEFORE the
386
- // kill settles (WR-02): the walk does not wait for deps.kill(...) to
474
+ // kill settles: the walk does not wait for deps.kill(...) to
387
475
  // resolve, so this line can no longer name the kill's resolved stage --
388
476
  // that gets its own, separately-logged line once the kill settles,
389
477
  // below.
390
- deps.log(`vice-broker: grant-time probe failed for port ${record.port} (pid ${record.pid ?? "null"}) -- dropped the record and kicked off an identity-verified kill of the pid (not awaited by the acquire walk, WR-02)`);
478
+ deps.log(`vice-broker: grant-time probe failed for port ${record.port} (pid ${record.pid ?? "null"}) -- dropped the record and kicked off an identity-verified kill of the pid (not awaited by the acquire walk)`);
391
479
  // Fire-and-forget, matching handleRelease()'s own posture
392
480
  // (`verifiedKill(...).catch(...)`, a few hundred lines below in this
393
481
  // same file) -- the acquire walk moves on to the next candidate (or
@@ -414,8 +502,8 @@ async function selectWarmInstance(state, deps) {
414
502
  * The warm-instance selection arm (selectWarmInstance(), P-01) runs BEFORE
415
503
  * the cold-launch arm; `atCapacity()` gates ONLY the cold-launch arm --
416
504
  * checked only once selectWarmInstance() has already answered `null` (no
417
- * probe-live candidate available) -- NOT before either arm (WR-01,
418
- * 01.6.2.1-REVIEW.md). A full host still refuses a fresh cold launch before
505
+ * probe-live candidate available) -- NOT before either arm. A full host
506
+ * still refuses a fresh cold launch before
419
507
  * ever touching the port allocator, but a ready, probe-live warm candidate
420
508
  * is grantable even when the ceiling is already reached: granting it
421
509
  * creates no NEW instance and does not raise `countTotal()`, so refusing to
@@ -434,17 +522,17 @@ async function selectWarmInstance(state, deps) {
434
522
  * control.mts's own attemptAcquire()/enqueueAcquire() queue the request and
435
523
  * retry it later rather than refusing it. */
436
524
  export async function handleAcquire(requestId, stateDir, state, deps = {}) {
437
- // WR-01: the readiness probe is backend-aware, from the SAME threaded-down
525
+ // The readiness probe is backend-aware, from the SAME threaded-down
438
526
  // verdict handleAcquire already uses for buildViceArgs() -- on stock the port
439
527
  // speaks the binary monitor, so an HTTP POST there can never succeed.
440
- const backend = deps.backend ?? "fork";
528
+ const backend = deps.backend ?? "stock";
441
529
  const probe = deps.probe ?? ((port) => probeReady(port, { backend }));
442
530
  // Textually a verifiedKill( call site, not merely a reference -- reused
443
- // UNCHANGED from broker-kill.mts (Phase 01.6.2 criterion 6), never
444
- // re-derived, and never replaced by a bare process.kill().
531
+ // UNCHANGED from broker-kill.mts, never re-derived, and never replaced by
532
+ // a bare process.kill().
445
533
  const kill = deps.kill ?? ((opts) => verifiedKill(opts));
446
534
  const log = deps.log ?? ((line) => process.stderr.write(`${line}\n`));
447
- const winner = await selectWarmInstance(state, { probe, kill, log });
535
+ const winner = await selectWarmInstance(state, { probe, kill, log, requestedProfile: deps.profile });
448
536
  let record;
449
537
  if (winner) {
450
538
  record = winner;
@@ -467,12 +555,20 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
467
555
  state,
468
556
  stateDir,
469
557
  allocatePort: nextFreePort,
470
- // CR-01 (03-REVIEW.md): the SAME local `backend` const resolved at the
558
+ // The SAME local `backend` const resolved at the
471
559
  // top of this function feeds BOTH the initial argv (here) and the
472
560
  // supervision deps below, so a crash-respawn of this instance can never
473
561
  // build a different backend's argv than the launch it replaces.
474
562
  backend,
475
563
  allocateRemoteMonitorPort: deps.allocateRemoteMonitorPort,
564
+ // The profile the warm arm just
565
+ // refused to compromise on reaches buildViceArgs() here, and is
566
+ // mirrored onto the fresh InstanceRecord by spawnAndRecordInstance()
567
+ // in the SAME step -- so this instance's recorded profile and its real
568
+ // argv are written together and cannot disagree. This is the arm that
569
+ // makes "a dedicated instance for that grant" true rather than
570
+ // aspirational.
571
+ profile: deps.profile,
476
572
  spawnFactory: deps.buildColdSpawnFactory ??
477
573
  ((port) => {
478
574
  const supervisorDir = join(stateDir, String(port));
@@ -482,46 +578,76 @@ export async function handleAcquire(requestId, stateDir, state, deps = {}) {
482
578
  }),
483
579
  });
484
580
  if (!result.ok) {
581
+ // `result.reason` passes
582
+ // straight through -- `AcquireLaunchResult`'s reason union
583
+ // ("launch_in_flight" | "no_free_port" | "no_free_text_port") is a
584
+ // subset of `AcquireOutcome`'s, so a failed text-port allocation's own
585
+ // `no_free_text_port` reaches the control plane as its own distinct
586
+ // code (broker-control.mts's ControlErrorCode) rather than collapsing
587
+ // to `internal` or to the generic `no_free_port`.
485
588
  return { ok: false, reason: result.reason };
486
589
  }
487
590
  if (result.record.pid === null) {
488
- // WR-03 (01.6.2.1-REVIEW.md): the spawn never forked a real process
489
- // (e.g. a bad VICE_BIN path), so there is nothing to signal -- the
490
- // fix is deleting the just-created broken record alone. Without this,
491
- // a configuration failure would silently occupy a port slot and count
492
- // toward countTotal()/atCapacity() until crash supervision's own
493
- // delayed respawn/give-up machinery eventually noticed and freed it,
494
- // even though the caller was already told "internal" right now.
495
- // CR-02: deleteInstanceRecord(), not a bare map delete -- a stock launch
496
- // that failed this way already had its second port allocated and
497
- // blocked by acquirePortAndLaunch().
591
+ // The spawn never forked a real process (e.g. a bad VICE_BIN path),
592
+ // so there is nothing to signal -- the fix is deleting the
593
+ // just-created broken record alone. Without this, a configuration
594
+ // failure would silently occupy a port slot and count toward
595
+ // countTotal()/atCapacity() until crash supervision's own delayed
596
+ // respawn/give-up machinery eventually noticed and freed it, even
597
+ // though the caller was already told "internal" right now.
598
+ // deleteInstanceRecord(), not a bare map delete -- a stock launch that
599
+ // failed this way already had its second port allocated and blocked
600
+ // by acquirePortAndLaunch(), and deleteInstanceRecord() hands that
601
+ // second port back to the allocator (via state.blockedPorts) in the
602
+ // SAME step as it removes the broken record -- this branch is reached
603
+ // only once a record already exists, i.e. only once BOTH allocations
604
+ // already succeeded (a failed second allocation now fails the acquire
605
+ // before any record -- and before this `pid === null` check -- is
606
+ // ever reached at all).
498
607
  deleteInstanceRecord(state, result.record.port);
499
608
  return { ok: false, reason: "internal" };
500
609
  }
501
610
  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.
611
+ // Only the cold-launch arm ever writes a FRESH epoch record here --
612
+ // selectWarmInstance()'s own winner already has one. WHY that is true
613
+ // changed without changing that it IS true: a
614
+ // ready, ungranted candidate no longer comes from a warm-floor pass's
615
+ // own onLaunched hook (retired along with the floor) -- it comes from
616
+ // broker-launch.mts's own crash-supervision respawn path
617
+ // (launchSupervised(), which writes its own epoch record via
618
+ // deps.epoch.writeEpochRecord() on every launch and every respawn).
619
+ // Either way, rewriting the epoch here would advance an epoch no restart
620
+ // caused, which the container-side assertSameMachine() would read as a
621
+ // machine change.
507
622
  writeEpochForLaunch(record, lastLogRelPath);
508
623
  }
509
624
  // THE single grant-recording step, fed by both arms above -- no `await`
510
625
  // between resolving `record` (whichever arm produced it) and this
511
626
  // synchronous pair, so two concurrent acquires can never both grant the
512
- // SAME record (T-01.6.2.1-03; see selectWarmInstance()'s own re-check for
627
+ // SAME record (see selectWarmInstance()'s own re-check for
513
628
  // the other half of that guarantee).
514
629
  state.grants.set(requestId, { id: requestId, port: record.port, grantedAt: Date.now(), pid: record.pid });
515
630
  record.state = "granted";
516
631
  return {
517
632
  ok: true,
518
- grant: { port: record.port, url: record.url, epochFile: record.epochFile, supervisorDir: record.supervisorDir },
633
+ grant: {
634
+ port: record.port,
635
+ url: record.url,
636
+ epochFile: record.epochFile,
637
+ supervisorDir: record.supervisorDir,
638
+ // Key omitted entirely when the record has none --
639
+ // the fork case, and (until a later plan closes the port-allocation
640
+ // degrade path) a stock instance whose second port allocation itself
641
+ // failed. Same key-omitted-when-undefined idiom
642
+ // spawnAndRecordInstance() already uses for this same field.
643
+ ...(record.remoteMonitorPort === undefined ? {} : { remoteMonitorPort: record.remoteMonitorPort }),
644
+ },
519
645
  };
520
646
  }
521
647
  /** Answers the `status` control-plane request: one entry per instance,
522
648
  * computed on demand from the SAME in-memory map every other count reads --
523
649
  * strictly better than the dropped broker-instances.json projection, which
524
- * could go stale between passes (D-24). */
650
+ * could go stale between passes. */
525
651
  function handleStatus(state) {
526
652
  return Array.from(state.instances.values()).map((r) => ({
527
653
  port: r.port,
@@ -529,64 +655,72 @@ function handleStatus(state) {
529
655
  state: r.state,
530
656
  reason: r.reason,
531
657
  epoch: typeof r.epoch === "number" ? r.epoch : null,
532
- hasMonitorClient: r.monitorClient !== undefined,
658
+ // "at least one channel is claimed" -- promoted from
659
+ // a single-field check, byte-identical wire shape, meaning stated
660
+ // explicitly.
661
+ hasMonitorClient: Object.keys(r.monitorClients).length > 0,
533
662
  }));
534
663
  }
535
664
  /** Resolves a monitor_claim/monitor_release target the SAME way
536
665
  * handleRelease() and handleRecycleForRealBroker() already resolve theirs:
537
666
  * `targetId` is a grant id, looked up in state.grants for its port, then
538
667
  * the instance at that port. Returns `null` for an unknown target_id/port
539
- * so callers answer `bad_request`, never `internal` (plan 05's own
540
- * acceptance criterion). */
668
+ * so callers answer `bad_request`, never `internal`. */
541
669
  function resolveInstanceForMonitorTarget(targetId, state) {
542
670
  const grant = state.grants.get(targetId);
543
671
  if (!grant)
544
672
  return null;
545
673
  return state.instances.get(grant.port) ?? null;
546
674
  }
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) {
675
+ /** Answers `monitor_claim` (per-channel): exclusive monitor-socket
676
+ * ownership enforced HERE, broker-side, PER CHANNEL, so a conflicting
677
+ * claim is refused by name
678
+ * before any second `connect()` is ever attempted -- the one state stock
679
+ * VICE cannot report and no client-side heuristic can diagnose. `targetId`
680
+ * doubles as both "which instance" (resolved via the SAME grant lookup
681
+ * handleRelease()/handleRecycleForRealBroker() already use) and "the
682
+ * requesting grant's own identity" -- the claim IS the grant, so there is
683
+ * no separate identity to carry. A repeated claim from the SAME grant on
684
+ * the SAME channel is idempotent (`ok: true`, no second holder created); a
685
+ * claim from a DIFFERENT grant while that channel already has a holder is
686
+ * refused, naming the current holder and the channel (T-02-18) -- never the
687
+ * emulator's own fault. A DIFFERENT channel's holder is irrelevant to this
688
+ * decision -- claiming one channel never evicts or is refused by the
689
+ * other's holder. */
690
+ export function handleMonitorClaim(requestId, targetId, channel, state) {
559
691
  void requestId; // correlation only -- the claim's own identity is targetId itself
560
692
  const instance = resolveInstanceForMonitorTarget(targetId, state);
561
693
  if (!instance)
562
694
  return { ok: false, code: "bad_request" };
563
- const existing = instance.monitorClient;
695
+ const existing = instance.monitorClients[channel];
564
696
  if (!existing) {
565
- instance.monitorClient = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
697
+ instance.monitorClients[channel] = { grantId: targetId, claimedAt: Date.now(), pid: instance.pid };
566
698
  return { ok: true };
567
699
  }
568
700
  if (existing.grantId === targetId) {
569
- return { ok: true }; // idempotent repeat from the SAME grant -- no second holder
701
+ return { ok: true }; // idempotent repeat from the SAME grant on the SAME channel -- no second holder
570
702
  }
571
- return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid } };
703
+ return { ok: false, code: "monitor_owned", holder: { grantId: existing.grantId, claimedAt: existing.claimedAt, pid: existing.pid, channel } };
572
704
  }
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
575
- * silently accepted (spoofing a release is exactly T-02-01's own
576
- * disposition). An instance with no current holder at all tolerates the
705
+ /** Answers `monitor_release` (per-channel): clears ONLY the named
706
+ * channel's entry, ONLY when `targetId` names that channel's CURRENT
707
+ * holder -- a non-holder is refused, not silently accepted (spoofing a
708
+ * release is a deliberately refused case). A channel with no current
709
+ * holder at all tolerates the
577
710
  * release as a success, matching the container-side client's own documented
578
711
  * tolerance for releasing a socket the broker already cleared. */
579
- export function handleMonitorRelease(requestId, targetId, state) {
712
+ export function handleMonitorRelease(requestId, targetId, channel, state) {
580
713
  void requestId; // correlation only, matching handleMonitorClaim()'s own posture
581
714
  const instance = resolveInstanceForMonitorTarget(targetId, state);
582
715
  if (!instance)
583
716
  return { ok: false, code: "bad_request" };
584
- if (!instance.monitorClient)
717
+ const existing = instance.monitorClients[channel];
718
+ if (!existing)
585
719
  return { ok: true }; // already cleared -- tolerated, not an error
586
- if (instance.monitorClient.grantId !== targetId) {
720
+ if (existing.grantId !== targetId) {
587
721
  return { ok: false, code: "denied" };
588
722
  }
589
- clearMonitorClient(instance);
723
+ clearMonitorClient(instance, channel);
590
724
  return { ok: true };
591
725
  }
592
726
  /** Resolves a recycle target's emulator child pid from THIS broker's own
@@ -664,90 +798,34 @@ async function handleRecycleForRealBroker(targetId, state) {
664
798
  const reason = killStage === "identity_refused" ? "process identity did not match the recorded emulator binary -- the target was NOT signalled and is still running" : "";
665
799
  return { port: instance.port, pid: instance.pid, viceBin: instance.viceBin, killStage, epochBefore, outcome, reason };
666
800
  }
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({
801
+ /** The second concern of the fixed-order evaluation pass, RENAMED from the
802
+ * retired warm-floor maintenance function this replaces (the projection
803
+ * write is dropped, and the grant sweep does not appear -- the connection
804
+ * is the lease now, so there is nothing left to sweep for expiry). Unlike
805
+ * the function it
806
+ * replaces, this one never launches anything -- it wires only
807
+ * broker-launch.mjs's real promoteLaunchingInstances() against this
808
+ * broker's own state and the backend-aware readiness probe, so a
809
+ * `launching` instance (however it got there -- a cold acquire's own
810
+ * instance, or a crash-respawn) is promoted to `ready` the moment it
811
+ * answers. */
812
+ function promoteLaunchingForRealBroker(state, backend) {
813
+ return promoteLaunchingInstances({
689
814
  state,
690
- stateDir,
691
815
  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.
816
+ // Same backend-aware probe route as handleAcquire's, from the
817
+ // SAME resolved verdict this function already receives.
718
818
  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
819
  log: (line) => process.stderr.write(`${line}\n`),
733
820
  });
734
821
  }
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
822
  /** Releases a grant and identity-verified-kills its instance -- but ONLY
745
823
  * when the port's CURRENT occupant is proven to be the SAME process this
746
824
  * grant was actually issued for (its own recorded `pid`, set at grant time
747
825
  * by handleAcquire()'s single state.grants.set() call site), not merely
748
- * "whatever now holds this port number." This is Task 2's own closure of
749
- * CR-01's cross-session-kill blast radius (T-01.6.2.1-28): even after Task
750
- * 1 closes the specific concurrent-acquire race, this lookup was ALREADY
826
+ * "whatever now holds this port number." This closes a cross-session-kill
827
+ * blast radius: even after the specific concurrent-acquire race above is
828
+ * closed, this lookup was ALREADY
751
829
  * unsafe against any OTHER event that swaps a port's occupant without also
752
830
  * clearing the grant -- the clearest independent example being an ordinary
753
831
  * (non-deliberate) crash of a GRANTED instance that hits the give-up
@@ -771,7 +849,7 @@ export const _maintainWarmFloorForRealBroker = maintainWarmFloorForRealBroker;
771
849
  * grant's own recorded pid, and the current occupant's pid (or "none" when
772
850
  * the port is empty), worded distinctly from both the shutdown-complete
773
851
  * line (broker-kill.mts) and the grant-time-probe-failure line this same
774
- * file already emits (D-07's standing constraint that a lifecycle decision
852
+ * file already emits (the standing constraint that a lifecycle decision
775
853
  * must be reconstructable from the log after an incident).
776
854
  *
777
855
  * A legitimate recycle (broker-launch.mts's handleExit() recycle branch)
@@ -785,15 +863,16 @@ export function handleRelease(requestId, state) {
785
863
  const instance = state.instances.get(grant.port);
786
864
  if (instance && instance.pid === grant.pid) {
787
865
  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
866
+ // Plan 05: releasing clears monitor-client ownership (every channel) as
867
+ // a side effect -- redundant with the instance-map deletion two lines
868
+ // below (the WHOLE record, monitorClients included, is going away), but
869
+ // explicit for the same reason GrantRecord's own clearing is explicit
870
+ // here: the
792
871
  // instance-map deletion is a Task-2-era invariant this task must not
793
872
  // depend on silently continuing to hold.
794
873
  clearMonitorClient(instance);
795
874
  state.grants.delete(requestId);
796
- // CR-02: kill-never-recycle means this instance is gone for good, so its
875
+ // Kill-never-recycle means this instance is gone for good, so its
797
876
  // second (`-remotemonitor`) port must go back to the allocator with it.
798
877
  deleteInstanceRecord(state, grant.port);
799
878
  verifiedKill({ pid: instance.pid, expectedIdentity: instance.expectedIdentity }).catch(() => {
@@ -811,7 +890,7 @@ export function handleRelease(requestId, state) {
811
890
  }
812
891
  async function run(args) {
813
892
  const finalPath = join(args.stateDir, "broker.json");
814
- // Plan 05 (criterion K, D-17): the tracer/plan-04-era "refuse to overwrite
893
+ // An early tracer-era "refuse to overwrite
815
894
  // a record naming a currently-live pid" pre-check is GONE -- REPLACED by
816
895
  // the bind-before-write singleton guard below, not merely extended
817
896
  // alongside it (this phase's own plan-time note is explicit: the
@@ -824,7 +903,7 @@ async function run(args) {
824
903
  // port itself already held" -- and broker.json becomes a pure ARBITER of
825
904
  // that question's two possible causes, never a gate in its own right.
826
905
  //
827
- // D-25: the mandatory start-time banner, printed unconditionally and
906
+ // The mandatory start-time banner, printed unconditionally and
828
907
  // BEFORE anything else in this function runs -- an operator must be told
829
908
  // what a Ctrl-C costs before there is anything running for them to Ctrl-C.
830
909
  process.stderr.write(`${startupBanner()}\n`);
@@ -834,17 +913,17 @@ async function run(args) {
834
913
  const startedAt = new Date().toISOString(); // FIXED across every heartbeat refresh -- see writeBrokerRecordFile()'s callers below
835
914
  const pollMs = Number(process.env.VICE_BROKER_POLL_MS) || 500;
836
915
  const controlPort = resolveControlPort();
837
- // Criterion I / D-15: the unconditional startup reap runs BEFORE the
916
+ // The unconditional startup reap runs BEFORE the
838
917
  // control listener accepts and before anything is launched. A SIGKILLed
839
918
  // prior broker never ran a shutdown path, so this is the only place the
840
919
  // "every emulator this project's port band could be squatting is either
841
920
  // ours or a human's own work" guarantee can be enforced -- no marker file
842
921
  // is consulted, per this reap's own header comment in broker-kill.mts.
843
922
  //
844
- // NOTE (plan 05): this reap runs UNCONDITIONALLY, before the bind attempt
923
+ // NOTE: this reap runs UNCONDITIONALLY, before the bind attempt
845
924
  // below -- including for a process that goes on to LOSE the singleton
846
925
  // race a moment later (see the EADDRINUSE handling below). That ordering
847
- // is D-15's own, already established and tested by plan 04
926
+ // is deliberate and already established and tested
848
927
  // (broker-kill.test.ts's own structural source-order check); this task
849
928
  // does not change it. A losing second broker's own reap pass is an
850
929
  // accepted, pre-existing consequence of "the reap is unconditional" --
@@ -855,59 +934,111 @@ async function run(args) {
855
934
  nextEpochFor,
856
935
  writeEpochRecord,
857
936
  });
858
- // Plan 02-07 (D-01, D-03): resolved ONCE here, after the unconditional
937
+ // Resolved ONCE here, after the unconditional
859
938
  // startup reap and BEFORE the control listener binds -- never re-read per
860
939
  // launch, and never called from inside broker-launch.mts's `inFlight`
861
940
  // single-owner guard (this call sits entirely outside it; no launch is
862
941
  // even possible yet at this point in run()). `supervisorDir: args.stateDir`
863
- // is passed explicitly -- args.stateDir IS `.vice-supervisor` under this
942
+ // is passed explicitly -- args.stateDir IS `.c64-re-tools/supervisor` under this
864
943
  // broker's own repo root (see parseArgs() above), so this is the SAME
865
944
  // directory repo-root.ts's supervisorDir() would resolve to, without this
866
945
  // 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.
946
+ // (backend-detect.mts's own header comment explains why it cannot).
947
+ // There is nothing left to detect -- the resolved
948
+ // `backend` is always `"stock"`; what this call still does is resolve the
949
+ // binary's own identity for the log line below and initialise the
950
+ // capability cache backend-detect.mts's own record depends on.
872
951
  const backendResult = resolvedBackend({ supervisorDir: args.stateDir });
873
952
  const backend = backendResult.backend;
874
- process.stderr.write(`vice-broker: backend "${backend}" (source: ${backendResult.source}, binary: ${backendResult.binPath})\n`);
875
- // 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.
953
+ process.stderr.write(`vice-broker: backend "${backend}" (binary: ${backendResult.binPath})\n`);
954
+ // THE BROKER mints/verifies the
955
+ // Ghidra runs-root handle here -- after the unconditional startup reap
956
+ // above, and BEFORE the control listener below accepts a single
957
+ // connection -- so a container-side MCP server with no host tooling of
958
+ // its own still finds the handle in place the moment it can reach this
959
+ // broker at all. This is deliberately NOT the only call site:
960
+ // resolveGhidraProject() (ghidra-project.mts) calls the same function as
961
+ // an idempotent precondition, because two host-side routes never involve
962
+ // a broker at all -- the direct spawn of resources/host-tool.mjs from
963
+ // host-tool-client.ts:269-273 (the everyday route on a host with no
964
+ // devcontainer, and the route CI uses), and tests importing that
965
+ // artifact directly. Both callers write the identical relative-target
966
+ // link, so a race between them is a benign EEXIST, not a conflict (see
967
+ // ensureGhidraRunsHandle()'s own header). The negative rule: container-
968
+ // side code must NEVER mint this handle -- the link target is relative
969
+ // and correct only when written from the host's view of the workspace.
970
+ //
971
+ // Handled WITHOUT throwing: run() has no try/catch around this region and
972
+ // the broker must start regardless of the outcome here -- it serves
973
+ // twelve allowlisted tool ids and only one of them (ghidra.analyze) needs
974
+ // this handle. A refusal is surfaced as ONE stderr line naming the
975
+ // consequence; every other tool id is unaffected.
976
+ const ghidraHandleResult = ensureGhidraRunsHandle(args.repoRoot);
977
+ if (ghidraHandleResult.ok) {
978
+ process.stderr.write(`vice-broker: ghidra runs handle ${ghidraHandleResult.handle} -> ${ghidraHandleResult.target}\n`);
979
+ }
980
+ else {
981
+ 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`);
982
+ }
983
+ // 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
984
  let listener;
877
985
  try {
878
986
  listener = await startControlListener({
879
987
  host: controlHost,
880
988
  port: controlPort,
881
989
  token,
882
- onAcquire: (requestId) => handleAcquire(requestId, args.stateDir, state, {
990
+ onAcquire: (requestId, profile) => handleAcquire(requestId, args.stateDir, state, {
883
991
  backend,
884
- // Plan 03-04 (DIRECT-06, D-13): threaded down to
992
+ // Threaded down to
885
993
  // acquirePortAndLaunch()'s own gate (backend === "stock"); this
886
- // callback does NOT re-read VICE_BACKEND itself.
994
+ // callback does not re-read any environment variable itself.
887
995
  allocateRemoteMonitorPort: (s, exclude) => nextFreePort(s, { exclude }),
996
+ // The ALREADY-NARROWED
997
+ // profile broker-control.mts handed this callback. Nothing here
998
+ // re-validates it and nothing here reads a raw wire field --
999
+ // normaliseLaunchProfile() is the single narrowing site, and it ran
1000
+ // before this callback was ever invoked.
1001
+ profile,
888
1002
  }),
889
1003
  onRelease: (requestId) => handleRelease(requestId, state),
890
1004
  onRecycle: (targetId) => handleRecycleForRealBroker(targetId, state),
891
1005
  onStatus: () => handleStatus(state),
892
- onMonitorClaim: (requestId, targetId) => handleMonitorClaim(requestId, targetId, state),
893
- onMonitorRelease: (requestId, targetId) => handleMonitorRelease(requestId, targetId, state),
1006
+ // Its OWN callback, wired alongside
1007
+ // (never derived from) the other six above -- handed only
1008
+ // `args.repoRoot` and a stderr logger, never this broker's `state` map,
1009
+ // so it structurally cannot reach lease state through this closure.
1010
+ // Deliberately supplies no timeout, and that is
1011
+ // authoritative here, not an omission -- the per-tool budget table
1012
+ // inside runHostTool()/hostToolTimeoutMs() (host-tool.mts) is the ONE
1013
+ // place a budget is decided, and no wire field carries one across the
1014
+ // seam at all (the `deps.timeoutMs` this callback could pass is an
1015
+ // in-process test seam, not something a caller's request ever
1016
+ // supplies). A reader arriving here from the artifact this plan's
1017
+ // completeness case checks should find this comment as the answer,
1018
+ // not an apparent gap.
1019
+ onHostTool: (raw) => runHostTool(raw, {
1020
+ repoRoot: args.repoRoot,
1021
+ log: (line) => process.stderr.write(`${line}\n`),
1022
+ }),
1023
+ onMonitorClaim: (requestId, targetId, channel) => handleMonitorClaim(requestId, targetId, channel, state),
1024
+ onMonitorRelease: (requestId, targetId, channel) => handleMonitorRelease(requestId, targetId, channel, state),
894
1025
  onHostState: () => ({
895
1026
  pid: process.pid,
896
1027
  startedAt,
897
1028
  nodeVersion: process.version,
898
1029
  viceBin: resolveViceBinForHostState(),
899
- warmFloor: resolveWarmFloorForRecord(),
900
1030
  maxInstances: resolveCeilingForRecord(),
901
1031
  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).
1032
+ // The verdict THIS process resolved once, at
1033
+ // startup, above -- kept on the wire because text-tools.ts's own
1034
+ // broker-identity cross-check (out of this plan's scope) still reads
1035
+ // it. Never a second resolvedBackend() call.
905
1036
  backend,
906
1037
  }),
907
1038
  });
908
1039
  }
909
1040
  catch (e) {
910
- // Criterion K / D-17 / D-18: CR-01 closes here. A well-known TCP port
1041
+ // The singleton race closes here. A well-known TCP port
911
1042
  // cannot be bound twice, so EADDRINUSE is the kernel enforcing the
912
1043
  // singleton -- but the guarantee holds only while the control port
913
1044
  // keeps its default (two brokers deliberately configured onto
@@ -942,7 +1073,7 @@ async function run(args) {
942
1073
  process.exitCode = 1;
943
1074
  return;
944
1075
  }
945
- // C5: every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
1076
+ // Every catchable shutdown path (SIGTERM/SIGINT/SIGHUP, an uncaught
946
1077
  // exception, an unhandled rejection, normal exit) converges on ONE
947
1078
  // re-entrant-safe teardown that identity-verified-kills every instance
948
1079
  // this broker launched and clears the map unconditionally
@@ -950,12 +1081,22 @@ async function run(args) {
950
1081
  // nothing to tear down before that point.
951
1082
  registerShutdownHandlers({ state });
952
1083
  // A successful bind writes the record UNCONDITIONALLY, overwriting
953
- // 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
1084
+ // whatever was there -- the bind itself is the proof of singleton status.
1085
+ // The fourteen-field set (narrowed to thirteen once the warm floor was
1086
+ // retired, then widened back to fourteen to add node_exec_path): the
1087
+ // lease time-to-live field the bash original carried is gone -- the
1088
+ // connection is the lease now, so there is no separate expiry left to
1089
+ // track -- `warm_floor` is likewise gone (there is no warm floor left to
1090
+ // echo a configured value for) -- and every other config-echo field survives
957
1091
  // even though no consumer parses it beyond a status message, because a
958
1092
  // human reading this file by hand benefits from the full echo.
1093
+ //
1094
+ // node_exec_path is process.execPath, not something threaded in from
1095
+ // outside: exec() replaces the process image, so whatever interpreter the
1096
+ // launcher resolved and gated IS this process's own execPath by the time
1097
+ // this line runs -- the record tells the truth without either side having
1098
+ // to pass anything, and it stays truthful even when this broker was
1099
+ // started directly, bypassing the launcher entirely.
959
1100
  let record = {
960
1101
  version: 1,
961
1102
  written_by: WRITTEN_BY,
@@ -963,17 +1104,17 @@ async function run(args) {
963
1104
  started_at: startedAt,
964
1105
  heartbeat_at: new Date().toISOString(),
965
1106
  node_version: process.version,
1107
+ node_exec_path: process.execPath,
966
1108
  control_host: listener.host,
967
1109
  control_port: listener.port,
968
1110
  control_token: token, // never logged -- T-01.6.2-02
969
- warm_floor: resolveWarmFloorForRecord(),
970
1111
  max_instances: resolveCeilingForRecord(),
971
1112
  base_port: resolveBasePort(),
972
1113
  poll_ms: pollMs,
973
1114
  dry_run: args.dryRun,
974
1115
  };
975
1116
  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`);
1117
+ 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
1118
  const heartbeatMs = Number(process.env.VICE_BROKER_HEARTBEAT_MS) || 30000;
978
1119
  setInterval(() => {
979
1120
  // The refresh path goes through the SAME atomic tmp-then-rename choke
@@ -984,18 +1125,21 @@ async function run(args) {
984
1125
  writeBrokerRecordFile(args.stateDir, record);
985
1126
  }, heartbeatMs);
986
1127
  // 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.
1128
+ // serve pending acquires, then promote launching -> ready -- mirroring
1129
+ // the retiring bash daemon's own broker_once() ordering (the warm floor
1130
+ // this pass used to maintain as its second concern is RETIRED; see
1131
+ // runBrokerPass()'s own comment in broker-launch.mts for what the fixed
1132
+ // order still buys now that only serveAcquires() ever launches anything).
1133
+ // Ticks on VICE_BROKER_POLL_MS (default 500, the SAME env var name and
1134
+ // semantics the bash daemon used). serveAcquires now drains the
1135
+ // arrival-ordered pending-acquire structure this listener instance owns
1136
+ // (an early stubbed `serveAcquires: () => {}` comment reserved exactly
1137
+ // this room) -- an acquire queued because a launch was
1138
+ // already in flight is retried here, on the SAME pass that also promotes
1139
+ // any newly-ready instance, so a stalled pass shows up as a stale record
1140
+ // rather than a silently wrong one. Re-entrancy guarded: a pass that is
1141
+ // still running (e.g. a slow readiness probe against a genuinely slow
1142
+ // host) is never overlapped by the next tick.
999
1143
  let passInFlight = false;
1000
1144
  setInterval(() => {
1001
1145
  if (passInFlight)
@@ -1003,7 +1147,7 @@ async function run(args) {
1003
1147
  passInFlight = true;
1004
1148
  runBrokerPass({
1005
1149
  serveAcquires: () => drainPendingAcquires(listener.pendingAcquires),
1006
- maintainWarmFloor: () => maintainWarmFloorForRealBroker(args.stateDir, state, backend),
1150
+ promoteLaunching: () => promoteLaunchingForRealBroker(state, backend),
1007
1151
  })
1008
1152
  .catch((e) => {
1009
1153
  process.stderr.write(`vice-broker: evaluation pass failed: ${e.message}\n`);
@@ -1014,8 +1158,8 @@ async function run(args) {
1014
1158
  }, pollMs);
1015
1159
  }
1016
1160
  /** Parses argv, evaluates the container guard FIRST -- before any state
1017
- * directory is read or written and before anything is spawned (PD-03) --
1018
- * then runs the long-lived broker. Never calls process.exit(); always sets
1161
+ * directory is read or written and before anything is spawned -- then
1162
+ * runs the long-lived broker. Never calls process.exit(); always sets
1019
1163
  * process.exitCode so pending I/O flushes first. */
1020
1164
  export function main(argv = process.argv.slice(2)) {
1021
1165
  let args;