@codeyam-editor/codeyam-editor 0.1.6 → 0.1.8

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 (37) hide show
  1. package/npm/cloud.js +308 -52
  2. package/npm/compose-conflict.js +59 -0
  3. package/npm/fleet-add-failure.js +174 -18
  4. package/npm/fleet-cleanup.js +129 -0
  5. package/npm/fleet-job-badge.js +28 -12
  6. package/npm/fleet-memory.js +73 -4
  7. package/npm/fleet-probe-transport.js +155 -0
  8. package/npm/fleet-queue.js +141 -0
  9. package/npm/fleet-revision.js +104 -0
  10. package/npm/fleet-running-plan.js +37 -7
  11. package/npm/fleet-vm-access.js +162 -0
  12. package/npm/ingress-invoke.js +46 -2
  13. package/npm/launch-config.js +163 -4
  14. package/npm/scenario-check.js +63 -19
  15. package/npm/scenario-handlers.js +18 -0
  16. package/npm/scenario-issues.js +11 -0
  17. package/npm/scenario-mocks.js +245 -39
  18. package/npm/scenario-playwright.js +114 -22
  19. package/package.json +5 -5
  20. package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-DSVZvEkB.js} +1 -1
  21. package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
  22. package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
  23. package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
  24. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
  25. package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
  26. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
  27. package/ui/dist/assets/{index-DCHqRrwO.js → index-BPLKgwon.js} +9 -9
  28. package/ui/dist/assets/index-DKpgJLo3.css +1 -0
  29. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  30. package/ui/dist/index.html +2 -2
  31. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
  32. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  33. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
  34. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
  35. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
  36. package/ui/dist/assets/index-BygCiJSb.css +0 -1
  37. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
@@ -96,6 +96,81 @@ function transcriptRostered(logTail) {
96
96
  return parseRosterMarkers(String(logTail || '')).length > 0;
97
97
  }
98
98
 
99
+ // The categories that describe an add whose outcome is NOT yet proven bad — the
100
+ // calm, in-flight side of the classifier. `converging` is a rostered box whose
101
+ // ingress is still wiring; `verifying` is the residue: no failure signature
102
+ // matched and nothing durable proves the VM is dead; `access-pending` is a VM that
103
+ // is up and rostered but whose IAP policy has no accessors YET, because its
104
+ // detached wiring tail — which applies the grant last — is still running. All
105
+ // three render a calm card and none may use the word "failed". Consumed by
106
+ // holdConvergingBadge and by deriveStatus (server.js) to pick the matching calm
107
+ // status.
108
+ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending']);
109
+
110
+ // Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
111
+ // positive proof of death, rather than merely an absence of proof of life?
112
+ //
113
+ // This is the conjunction that gates the red `unknown` verdict. Before it, the
114
+ // classifier's fallthrough treated "no recognized success signature" as failure,
115
+ // so "we don't know" rendered as a terminal red "Add failed" telling the operator
116
+ // to destroy a VM whose container was Up and whose editor answered /api/health
117
+ // 200 (VM-4, 2026-07-31). Red now requires BOTH halves:
118
+ //
119
+ // 1. A non-zero exit code. A *missing* code is not proof of death — today it
120
+ // usually means the wrapper was signal-killed while its provision ran on,
121
+ // which is exactly the case that was misreading as failure. `null`,
122
+ // `undefined`, a non-integer, and 0 all fail this half.
123
+ // 2. At least one durable corroborating fact:
124
+ // - `instanceAlive === false` GCP says the instance is gone.
125
+ // - `containerHealthy === false` the in-VM editor was probed and is not
126
+ // answering — the box exists but never came up.
127
+ // - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
128
+ // marker is the tail's own honest deadline signal).
129
+ //
130
+ // Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
131
+ // corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
132
+ // never be escalated to red by absence of evidence.
133
+ function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
134
+ const code = exitCode == null ? NaN : Number(exitCode);
135
+ if (!Number.isInteger(code) || code === 0) return false;
136
+ return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
137
+ }
138
+
139
+ // Resolve the two independent health signals into the single tri-state
140
+ // `containerHealthy` fact addFailureCorroborated consumes. Pure so the precedence
141
+ // — which decides whether a VM can be escalated to red — is unit-tested; the
142
+ // server (addFailureFacts) supplies the live signals.
143
+ //
144
+ // reachable the editor answered over the LB.
145
+ // inVmHealthy the STRAND_PROBE /api/health read from INSIDE the container.
146
+ //
147
+ // LB reachability wins when present: it is the stronger witness (it proves the
148
+ // whole URL→ingress→backend path, not just the process). Otherwise the in-VM
149
+ // witness answers — this is the still-wiring case, where the LB necessarily
150
+ // cannot see a VM whose ingress is what we are waiting on, yet the editor itself
151
+ // is demonstrably up.
152
+ //
153
+ // Returns `null` when NEITHER signal was gathered. That is the load-bearing case:
154
+ // an unprobed VM must corroborate nothing, so it can never be escalated to red by
155
+ // absence of evidence. `false` is reserved for a VM that was genuinely probed and
156
+ // did not answer.
157
+ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
158
+ if (reachable) return true;
159
+ if (inVmHealthy != null) return !!inVmHealthy;
160
+ return null;
161
+ }
162
+
163
+ // Lift the `Next valid action:` remedy out of a BLOCKED reason so a classified
164
+ // card's recovery line stays the SAME text the producing check already emitted,
165
+ // instead of a second copy of the same instructions that can silently drift from
166
+ // it. Returns '' when the text carries no remedy, in which case the caller's
167
+ // static copy stands on its own.
168
+ function nextValidActionRemedy(text) {
169
+ const lines = String(text || '').split('\n');
170
+ const i = lines.findIndex((l) => /^\s*Next valid action:/.test(l));
171
+ return i === -1 ? '' : lines.slice(i).join('\n').trim();
172
+ }
173
+
99
174
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
100
175
  // can never drift from the category.
101
176
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -108,7 +183,19 @@ function buildFailureVerdict(category, headline, recovery, extra) {
108
183
  //
109
184
  // logTail the last lines of the provision transcript (string; '' when the
110
185
  // log was unreadable — must never throw)
111
- // exitCode the provision's exit code (number; used for signal-kill detection)
186
+ // exitCode the provision's exit code (number; used for signal-kill detection
187
+ // and as the first half of the proof-of-death conjunction)
188
+ //
189
+ // The remaining inputs are the durable facts the server gathers (the module
190
+ // stays pure — it never probes). All are tri-state; omit any that was not
191
+ // probed and it corroborates nothing. See addFailureCorroborated.
192
+ //
193
+ // instanceAlive does GCP still list the instance?
194
+ // containerHealthy did the in-VM editor answer /api/health?
195
+ // wiringDeadlineBlown did the add-time wire tail drop its failed marker?
196
+ // wiringInFlight is that tail still RUNNING? Only the IAP read-back arm
197
+ // reads it, to tell "no accessors yet" from "no accessors,
198
+ // ever" — the grant is the tail's last step.
112
199
  //
113
200
  // Returns { category, headline, recovery, retryable, files? }.
114
201
  //
@@ -118,7 +205,7 @@ function buildFailureVerdict(category, headline, recovery, extra) {
118
205
  // signature is therefore tested BEFORE the generic cloud-up-failed signature, or
119
206
  // a retryable drift would misclassify as a non-retryable cloud:up failure and the
120
207
  // operator would lose the safe one-click recovery.
121
- function classifyAddFailure({ logTail, exitCode } = {}) {
208
+ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
122
209
  const log = String(logTail || '');
123
210
 
124
211
  // 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
@@ -200,7 +287,37 @@ function classifyAddFailure({ logTail, exitCode } = {}) {
200
287
  'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
201
288
  }
202
289
 
203
- // 8. converging — the add reached the early-roster marker (`CY_ROSTER`): the
290
+ // 8. The post-add IAP read-back verdicts. Unlike every signature above, this one
291
+ // is not produced by the provision transcript at all — it is the BLOCKED
292
+ // reason iapReadbackOutcome hands to annotateAddFailure, which is what
293
+ // stops a read-back failure from being the one add failure that reaches the
294
+ // card with no category at all (and so no differentiated headline, no
295
+ // recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
296
+ // calm). Tested AFTER all seven hard-failure signatures so a proven failure
297
+ // always wins if both texts are ever present, and BEFORE converging/verifying
298
+ // because it is strictly more specific than either.
299
+ if (/IAP policy authorizes no human/i.test(log)) {
300
+ const remedy = nextValidActionRemedy(log);
301
+ // 8a. access-pending — no accessors YET. The grant is the LAST step of the
302
+ // detached wire-add-tail, so while that tail is alive an empty policy is
303
+ // the expected mid-flight reading, not a verdict. Calm: this is the state
304
+ // that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
305
+ // before their grant step had even run (2026-08-11).
306
+ if (wiringInFlight) {
307
+ return buildFailureVerdict('access-pending', 'Access not granted yet',
308
+ 'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
309
+ }
310
+ // 8b. iap-unauthorized — the wiring is done and the policy STILL authorizes
311
+ // nobody, so the public URL returns "Error code 51" for every human. This
312
+ // is the 2026-08-09 case the read-back exists to catch (one step granted
313
+ // the operator, the next revoked him) and it is a genuine hard failure.
314
+ // Not retryable: the VM exists and is fine — re-adding it would duplicate
315
+ // a live box when what is missing is one grant.
316
+ return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
317
+ `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
318
+ }
319
+
320
+ // 9. converging — the add reached the early-roster marker (`CY_ROSTER`): the
204
321
  // container gate passed, the instance is real, and the VM is rostered, yet it
205
322
  // exited non-zero WITHOUT matching any specific hard-failure signature above.
206
323
  // By construction of the provision script, every genuine hard failure (drift,
@@ -210,17 +327,42 @@ function classifyAddFailure({ logTail, exitCode } = {}) {
210
327
  // wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
211
328
  // honest in-flight state instead of the scary red `unknown`. Not retryable:
212
329
  // the VM exists and its detached wiring owns finishing it, so a one-click
213
- // retry could duplicate/orphan a live box. Tested LAST so a hard-failure
214
- // signature (all printed pre-roster) always wins — `converging` can never
215
- // shadow a proven failure.
330
+ // retry could duplicate/orphan a live box. Tested after every hard-failure
331
+ // signature (all printed pre-roster) so a proven failure always wins —
332
+ // `converging` can never shadow one.
216
333
  if (transcriptRostered(log)) {
217
334
  return buildFailureVerdict('converging', 'Add still converging',
218
335
  'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
219
336
  }
220
337
 
221
- // 9. unknown — fallback. Never invite an unproven retry.
222
- return buildFailureVerdict('unknown', 'Add failed',
223
- 'The add failed for an unclassified reason — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
338
+ // 10. The residue: no failure signature above matched. The polarity here is
339
+ // INVERTED from the original design. It used to read "no recognized success
340
+ // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
341
+ // VM whose container was Up and whose editor answered /api/health 200 got a
342
+ // terminal red "Add failed" card advising the operator to destroy it (VM-4,
343
+ // 2026-07-31; the wrapper had been killed mid-flight, so its transcript was
344
+ // truncated before CY_ROSTER and it never reached the `converging` arm above).
345
+ //
346
+ // Red now requires positive proof of death, and everything else is in-flight:
347
+ //
348
+ // 10a. unknown — a non-zero exit CORROBORATED by a durable fact (see
349
+ // addFailureCorroborated). The box is provably dead or provably never
350
+ // came up, so the loud, terminal escalation is warranted and unchanged.
351
+ // Still never invites an unproven retry.
352
+ if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown })) {
353
+ return buildFailureVerdict('unknown', 'Add failed',
354
+ 'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
355
+ }
356
+
357
+ // 10b. verifying — the honest default. No failure signature, and nothing
358
+ // durable proves this VM is dead: it may still be provisioning or
359
+ // wiring, and the ingress can keep propagating for minutes after the
360
+ // wrapper is gone. Calm copy, transcript still one click away. NOT
361
+ // retryable — nothing is proven about the box either way, so a one-click
362
+ // retry could still duplicate a live GCP instance (the ADD_FAILURE_RETRYABLE
363
+ // grounds are unchanged by this inversion).
364
+ return buildFailureVerdict('verifying', 'Add outcome unconfirmed',
365
+ 'No failure signature was found in the transcript and nothing proves this VM is dead, so the add may still be converging — the ingress keeps propagating for minutes after the provision wrapper exits. Inspect the transcript below; reconcile this VM only if it is still unresolved past the wiring deadline.');
224
366
  }
225
367
 
226
368
  // Pure guard for the operator "Retry add" action: given a VM number and its
@@ -238,15 +380,29 @@ function canRetryAdd({ n, job } = {}) {
238
380
  }
239
381
 
240
382
  // Pure badge-hold predicate for deriveStatus (server.js): hold the calm in-flight
241
- // `add-converging` card — instead of the red add-failed — ONLY when the add was
242
- // classified `converging` AND its detached first-wiring is still in flight. Once
243
- // the wire-add-tail fails/deadlines (`addTailFailed`) or is gone (`addTailInflight`
244
- // false), or the category is anything else, return false so the caller falls
245
- // through to the red add-failed status — the operator-chosen escalation. Kept pure
246
- // (like canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
383
+ // card — instead of the red add-failed — while the add is still converging.
384
+ //
385
+ // The hold used to require `addTailInflight`, i.e. that the detached wire-add-tail
386
+ // PROCESS was still alive. That premise was wrong: the tail's exit says nothing
387
+ // about LB convergence, because the EXTERNAL_MANAGED backend services keep
388
+ // propagating for minutes afterwards. VM-4's backends were still being created at
389
+ // 18:17-18:20Z, long after its job had been declared failed at 18:15Z — the tail
390
+ // had exited, so the calm hold released and the card went red on a healthy VM.
391
+ //
392
+ // The hold is now driven by the question that actually matters: is this add still
393
+ // converging, and has its wiring deadline genuinely blown? `wiringDeadlineBlown`
394
+ // (the failed marker the tail drops when it gives up) stays the release signal —
395
+ // tail liveness is no longer a requirement for calm, so the calm state survives
396
+ // the multi-minute propagation window instead of ending at process exit.
397
+ //
398
+ // Both calm categories qualify (see CALM_ADD_CATEGORIES): a rostered box that is
399
+ // still wiring, and the unproven residue. Any proven-failure category returns
400
+ // false and falls through to the red add-failed status — the operator-chosen
401
+ // escalation, which this predicate narrows but never softens. Kept pure (like
402
+ // canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
247
403
  // live dashboard.
248
- function holdConvergingBadge({ category, addTailInflight, addTailFailed } = {}) {
249
- return category === 'converging' && !!addTailInflight && !addTailFailed;
404
+ function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
405
+ return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
250
406
  }
251
407
 
252
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, ADD_FAILURE_RETRYABLE };
408
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
@@ -337,6 +337,132 @@ function reattachNextStep(verdict) {
337
337
  return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
338
338
  }
339
339
 
340
+ // May the PERIODIC stranded-reconcile pass drive a recover/roster probe for this
341
+ // VM, or is a provision already running that a second cloud:up would collide
342
+ // with? This is the guard the boot re-attach path has always had and the
343
+ // stranded path never did — the 2026-08-07 asymmetry that destroyed VM-4/VM-5.
344
+ //
345
+ // On that day an `add` job badge read `error` while its detached `cloud:up`
346
+ // child was alive and healthy (a source-build bootstrap runs ~40 minutes;
347
+ // `npm install` alone took 25m). `pollStrandedInstances` builds `activeAddNs`
348
+ // from BADGE state and deliberately excludes an `error` add so the bounded
349
+ // bootstrap-recovery budget can retry on a timer — so the VM read
350
+ // running-but-unrostered, crossed STRANDED_ADOPT_THRESHOLD in ~3 minutes, and
351
+ // the pass launched a SECOND cloud:up on top of the live one. The two raced on
352
+ // `docker compose up -d`, the loser hit `Conflict. The container name
353
+ // "/codeyam-editor-editor-1" is already in use`, and its abort handler deleted
354
+ // the instance. The badge is a derived, lossy signal that was wrong for 30
355
+ // minutes straight; LIVENESS is the ground truth, so this gate reads exactly
356
+ // what `reattachAdd` reads — the exit marker plus a `kill -0` of the recorded
357
+ // pid — and composes `reattachVerdict`/`reattachNextStep` rather than forking a
358
+ // parallel matrix.
359
+ //
360
+ // 'watch' no marker + a live child → the provision is genuinely in flight.
361
+ // Re-attach and watch (mirroring
362
+ // runStartupReap); NEVER provision.
363
+ // 'defer' no marker, no live child, and no pid was EVER recorded, inside the
364
+ // ambiguity window → we cannot tell whether a child is
365
+ // running. Do nothing this tick.
366
+ // 'probe' everything else → existing behavior: drive the gcloud
367
+ // probe (reconcileViaGcloud →
368
+ // reapVerdict).
369
+ //
370
+ // The 'defer' rung exists because `childAlive` is FALSE-NEGATIVE-PRONE on one
371
+ // specific path: when `strandedReconcileMeta` finds no sidecar it reconstructs
372
+ // cfg from launch history and persists `{ cfg }` with NO pid, so `isPidAlive`
373
+ // reads false for a VM whose child may well be alive — and, worse, that pid-less
374
+ // sidecar is what every later tick reads back, blinding the guard permanently.
375
+ // `pidRecorded` separates "we knew the pid and the process is gone" (real
376
+ // evidence → probe) from "we never had a pid" (absence of evidence → defer).
377
+ // Erring toward NOT provisioning is the asymmetric-cost choice: waiting one more
378
+ // 60s tick costs nothing, being wrong costs a destroyed VM.
379
+ //
380
+ // The window is what keeps 'defer' from becoming a permanent refusal that would
381
+ // break stranded adoption outright: once more than `ambiguityWindowMs` has
382
+ // elapsed since we first saw the pid-less sidecar, no plausible provision child
383
+ // is still running, so the probe proceeds and the self-heal path is preserved.
384
+ // An unknown/unstamped elapsed is treated as "just noticed" (defer), again the
385
+ // safe direction; a caller that supplies no window at all opts out entirely and
386
+ // keeps the old probe-always behavior.
387
+ //
388
+ // Pure, so the whole matrix is testable without a VM: server.js maps the verdict
389
+ // to its side effect.
390
+ function strandedRecoverGate({
391
+ markerPresent,
392
+ exitCode,
393
+ childAlive,
394
+ pidRecorded,
395
+ sinceMs,
396
+ ambiguityWindowMs,
397
+ } = {}) {
398
+ // A live child is a live child — reuse the existing classifier so 'running'
399
+ // keeps meaning exactly what the boot path means by it.
400
+ if (reattachNextStep(reattachVerdict({ markerPresent, exitCode, childAlive })) === 'watch') {
401
+ return 'watch';
402
+ }
403
+ // A marker is durable proof the child reached its EXIT trap: nothing is in
404
+ // flight, so the probe is safe. (Which probe outcome follows — roster,
405
+ // recover-bootstrap, surface-error — stays reapVerdict's call, not ours.)
406
+ if (markerPresent) return 'probe';
407
+ // We knew the pid and `kill -0` says it is gone: real evidence, so probe.
408
+ if (pidRecorded) return 'probe';
409
+ const window = Number(ambiguityWindowMs);
410
+ if (!Number.isFinite(window) || window <= 0) return 'probe'; // caller opted out
411
+ const elapsed = Number(sinceMs);
412
+ if (!Number.isFinite(elapsed)) return 'defer'; // unstamped — treat as just-noticed
413
+ return elapsed < window ? 'defer' : 'probe';
414
+ }
415
+
416
+ // Is an `add` genuinely IN FLIGHT for this VM right now?
417
+ //
418
+ // Extracted from pollVanishedInstances, where the inline expression was
419
+ // `addPending.has(n) || !!(state.jobs[n] && state.jobs[n].action === 'add')` —
420
+ // whose second clause is also true for a TERMINAL (`done` / `error`) add job.
421
+ // That permanently marks the VM busy, so `reconcileRosteredVm` can never return
422
+ // 'cleanup' and a rostered VM whose instance is gone can never be auto-reaped.
423
+ // Live evidence from the same 2026-08-07 incident: VM-5's instance was deleted
424
+ // at ~12:46 and its card logged `vanish-reconcile: VM-5 unreachable …` once a
425
+ // minute for 50 consecutive polls, only clearing after a manual `clear-badge`
426
+ // deleted the terminal job record.
427
+ //
428
+ // The on-disk sidecar clause is already correct and is preserved: a sidecar is
429
+ // removed on the add's success path, so its presence really does mean in-flight.
430
+ // The job clause now filters on job STATE the same way `activeAddNs` and
431
+ // `pollStartupProbes` do. Pure so the caller's computation is directly
432
+ // assertable — the bug was never in `reconcileRosteredVm`'s contract, it was in
433
+ // what this expression fed it.
434
+ const IN_FLIGHT_ADD_STATES = ['running', 'queued', 'recovering'];
435
+
436
+ function hasInFlightAdd({ sidecarPending, job } = {}) {
437
+ if (sidecarPending) return true;
438
+ if (!job || job.action !== 'add') return false;
439
+ return IN_FLIGHT_ADD_STATES.includes(job.state);
440
+ }
441
+
442
+ // WHICH of reconcileRosteredVm's two distinct 'leave' causes fired, as a tag the
443
+ // caller renders. Pure so the distinction is directly assertable.
444
+ //
445
+ // 'leave' is returned both when the GCP probe was INCONCLUSIVE (exists null /
446
+ // undefined — absence of evidence, never act) and when the instance is
447
+ // CONFIRMED gone but the VM is busy. The old single log line conflated them into
448
+ // a self-contradiction — it announced "instance-gone unconfirmed (exists=false)"
449
+ // when exists=false IS the confirmation — which cost hours of code-reading
450
+ // during the 2026-08-07 incident to work out which branch was actually firing.
451
+ //
452
+ // 'probe-inconclusive' exists is null/undefined — retry next tick
453
+ // 'live-session' instance confirmed gone but a session is live
454
+ // 'in-flight-add' instance confirmed gone but an add is in flight
455
+ // null not a 'leave' case at all
456
+ //
457
+ // Mirrors reconcileRosteredVm's own precedence (exists-uncertainty first, then
458
+ // hasSession, then hasPendingAdd) so the tag can never disagree with the verdict.
459
+ function vanishLeaveReason({ exists, hasSession, hasPendingAdd } = {}) {
460
+ if (exists === null || exists === undefined) return 'probe-inconclusive';
461
+ if (hasSession) return 'live-session';
462
+ if (hasPendingAdd) return 'in-flight-add';
463
+ return null;
464
+ }
465
+
340
466
  // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
341
467
  // recoveries ALREADY persisted on the sidecar, return the next attempt number,
342
468
  // whether the reap should proceed (re-run cloud:up) or treat the VM as
@@ -537,4 +663,7 @@ module.exports = {
537
663
  isSignalKillExit,
538
664
  reattachVerdict,
539
665
  reattachNextStep,
666
+ strandedRecoverGate,
667
+ hasInFlightAdd,
668
+ vanishLeaveReason,
540
669
  };
@@ -15,21 +15,37 @@
15
15
  // Should the VM's last terminal-error job badge be reconciled against live
16
16
  // health — i.e. presented as "recovered" rather than a live red error?
17
17
  //
18
- // job the carried job record ({ state, action, ... }) or null
19
- // reachable the VM's editor answered this poll (true = healthy / responding)
20
- // drifted a project-dir-drift signal is present (reachable-but-unwired /
21
- // half-provisioned add — genuinely needs-recovery, owned by the
22
- // companion reconcile-inflight-jobs plan; never falsely cleared here)
23
- // offBranch the VM's live client branch differs from its target branch
24
- // degraded the job completed but a wiring sub-step is flagged degraded
18
+ // job the carried job record ({ state, action, ... }) or null
19
+ // reachable the VM's editor answered this poll over the LB (true = healthy)
20
+ // inVmHealthy the in-VM editor answered /api/health from INSIDE the container
21
+ // (the STRAND_PROBE `HEALTH=1` witness gathered over docker-exec)
22
+ // drifted a project-dir-drift signal is present (reachable-but-unwired /
23
+ // half-provisioned add — genuinely needs-recovery, owned by the
24
+ // companion reconcile-inflight-jobs plan; never falsely cleared here)
25
+ // offBranch the VM's live client branch differs from its target branch
26
+ // degraded the job completed but a wiring sub-step is flagged degraded
25
27
  //
26
28
  // Returns true only when the job is a terminal error AND the VM is demonstrably
27
- // fine (reachable, not drifted, on-branch, not degraded). An unreachable /
28
- // drifted / off-branch VM keeps its error surfaced — a real problem must stay
29
- // visible.
30
- function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
29
+ // fine (healthy, not drifted, on-branch, not degraded). A drifted / off-branch /
30
+ // unwitnessed VM keeps its error surfaced — a real problem must stay visible.
31
+ //
32
+ // HEALTH IS WITNESSED TWO WAYS, and either suffices. `reachable` alone made the
33
+ // self-heal unable to fire in exactly the case that needs it most: on the GCE
34
+ // operator host `reachable` is derived from the LB probe, which needs the very
35
+ // ingress the add is still wiring. That is a circular dependency — the badge
36
+ // needs reachable, reachable needs ingress, and ingress is what we are waiting
37
+ // for — so a still-wiring VM sat at unreachableStreak 9 with a red add-error it
38
+ // could not clear by construction (VM-4, 2026-07-31). The dashboard already held
39
+ // the disconfirming evidence: gatherLauncherStrand runs STRAND_PROBE over
40
+ // docker-exec for exactly these unreachable-with-streak VMs and reads HEALTH=1
41
+ // from the in-VM /api/health.
42
+ //
43
+ // This widens ONE input; it does not relax the verdict. Every other guard is
44
+ // untouched, and the "absence of evidence never auto-acts" posture holds — an
45
+ // unprobed VM has neither witness and stays unreconciled.
46
+ function shouldReconcileFailedBadge({ job, reachable, inVmHealthy, drifted, offBranch, degraded } = {}) {
31
47
  if (!job || job.state !== 'error') return false;
32
- if (!reachable) return false;
48
+ if (!reachable && !inVmHealthy) return false;
33
49
  if (drifted) return false;
34
50
  if (offBranch) return false;
35
51
  if (degraded) return false;
@@ -290,8 +290,16 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
290
290
  * Degrades to 'cgroup=unavailable' rather than printing zeros off-cgroup, so a
291
291
  * laptop log cannot be misread as a cgroup at 0MB.
292
292
  *
293
+ * `detachedWiringCount` — when supplied — appends `tails=N`, which is what makes
294
+ * the RSS/cgroup gap ATTRIBUTABLE rather than merely visible. On 2026-08-11 the
295
+ * cgroup read 2154MB against a 96MB RSS and the whole diagnosis was "those ~2GB
296
+ * are this process's own transient add children, not a leak". `RSS=96MB
297
+ * cgroup=2154MB tails=4` reads as a batch draining on its own; `RSS=1300MB
298
+ * cgroup=1400MB tails=0` reads as a genuine leak. Omitted (the pre-existing
299
+ * callers, and any host with no tail accounting) prints exactly as before.
300
+ *
293
301
  * Pure; no I/O. */
294
- function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
302
+ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh, detachedWiringCount } = {}) {
295
303
  const mb = (b) => (Number(b) / 1024 / 1024).toFixed(0);
296
304
  const current = Number(memoryCurrent);
297
305
  const high = Number(memoryHigh);
@@ -308,6 +316,8 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
308
316
  } else {
309
317
  parts.push('cgroup=unavailable');
310
318
  }
319
+ const tails = Number(detachedWiringCount);
320
+ if (Number.isFinite(tails) && tails >= 0) parts.push(`tails=${tails}`);
311
321
  return parts.join(' ');
312
322
  }
313
323
 
@@ -323,11 +333,26 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
323
333
  * protecting. The dashboard's own self-update counts, being a heavy job by any
324
334
  * other name.
325
335
  *
326
- * Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate. */
327
- function hasJobInFlight({ jobs, dashboardUpdate } = {}) {
336
+ * `detachedWiringCount` is the THIRD source of busy, and the one this guard was
337
+ * blind to: an Add settles at container-healthy while its detached wire-add-tail
338
+ * keeps spawning gcloud children for another 5-10 min in the SAME cgroup. Judging
339
+ * "safe to restart" off `state.jobs` alone reported no job in flight while that
340
+ * work was live — on 2026-08-11 the escalation crossed the restart threshold at
341
+ * 86% of memory.high and the only thing that held it back was an unrelated job
342
+ * happening to still be `running`. Had it settled thirty seconds earlier the
343
+ * dashboard would have restarted itself mid-batch and orphaned four adds, which
344
+ * is precisely the failure class the detached-provision work exists to prevent.
345
+ * Omitting the key behaves exactly as before, so every current caller is
346
+ * unaffected.
347
+ *
348
+ * Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate and the
349
+ * live-tail count. */
350
+ function hasJobInFlight({ jobs, dashboardUpdate, detachedWiringCount } = {}) {
328
351
  const busy = (j) => !!j && (j.state === 'running' || j.state === 'recovering');
329
352
  const anyJob = Object.values((jobs && typeof jobs === 'object') ? jobs : {}).some(busy);
330
- return anyJob || !!(dashboardUpdate && dashboardUpdate.state === 'running');
353
+ const tails = Number(detachedWiringCount);
354
+ const anyTail = Number.isFinite(tails) && tails > 0;
355
+ return anyJob || anyTail || !!(dashboardUpdate && dashboardUpdate.state === 'running');
331
356
  }
332
357
 
333
358
  /** Should this escalation level be announced to the journal?
@@ -356,6 +381,49 @@ function shouldAnnounceMemoryLevel({ level, lastLevel } = {}) {
356
381
  return !QUIET_MEMORY_LEVELS.includes(level);
357
382
  }
358
383
 
384
+ /** Resolve the directory holding THIS process's memory.current / memory.high,
385
+ * or null when no cgroup-derived reading is available.
386
+ *
387
+ * The cgroup-v2 hierarchy ROOT exposes no memory.current and no memory.high —
388
+ * only a leaf cgroup does. Reading `/sys/fs/cgroup/memory.current` directly is
389
+ * therefore ENOENT on every systemd host, and that is exactly what made the
390
+ * 2026-07-30 memory remedy inert: both reads failed, every cgroup number
391
+ * degraded to null, decideMemoryRestart answered 'no-cgroup-limit' on every
392
+ * tick forever, and the drain silently fell back to the RSS threshold the
393
+ * incident had already proven cannot fire before the event loop stalls.
394
+ *
395
+ * Takes the CONTENTS of /proc/self/cgroup rather than reading it, so the path
396
+ * derivation — the part that was wrong — is unit-testable on a macOS dev box
397
+ * with no cgroup filesystem at all. That untestability is why a root-vs-leaf
398
+ * path error shipped green.
399
+ *
400
+ * Returns null for every shape that has no leaf memory file to read:
401
+ * • the unified line `0::/` — the process IS in the root cgroup, which
402
+ * genuinely has no memory files; this must NOT look like a valid path;
403
+ * • cgroup v1 (`N:controller:/path` lines, no `0::` entry);
404
+ * • empty, unreadable, or malformed input (a non-Linux host has no
405
+ * /proc/self/cgroup at all, and the caller passes the null through).
406
+ *
407
+ * Pure; no I/O. */
408
+ function resolveCgroupMemoryDir({ procSelfCgroup, root } = {}) {
409
+ if (typeof procSelfCgroup !== 'string') return null;
410
+ const base = (typeof root === 'string' && root)
411
+ ? root.replace(/\/+$/, '')
412
+ : '/sys/fs/cgroup';
413
+ for (const line of procSelfCgroup.split('\n')) {
414
+ const trimmed = line.trim();
415
+ // cgroup v2 unified hierarchy is the single `0::<path>` entry. v1 lines
416
+ // (`N:controller:/path`) never match, which is the intended null path.
417
+ if (!trimmed.startsWith('0::')) continue;
418
+ const rel = trimmed.slice(3);
419
+ if (!rel.startsWith('/')) return null;
420
+ const clean = rel.replace(/\/+$/, '');
421
+ if (!clean) return null;
422
+ return base + clean;
423
+ }
424
+ return null;
425
+ }
426
+
359
427
  /** Keep only the restart timestamps still inside the rolling window.
360
428
  *
361
429
  * Split out from decideMemoryRestart so the "how many restarts have I already
@@ -386,6 +454,7 @@ module.exports = {
386
454
  formatMemoryText,
387
455
  hasJobInFlight,
388
456
  shouldAnnounceMemoryLevel,
457
+ resolveCgroupMemoryDir,
389
458
  pruneRestartWindow,
390
459
  buildStateSnapshot,
391
460
  decodeStateSnapshot,