@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.
- package/npm/cloud.js +308 -52
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +174 -18
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-memory.js +73 -4
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +37 -7
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +163 -4
- package/npm/scenario-check.js +63 -19
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-issues.js +11 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +114 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-DSVZvEkB.js} +1 -1
- package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
- package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-DCHqRrwO.js → index-BPLKgwon.js} +9 -9
- package/ui/dist/assets/index-DKpgJLo3.css +1 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
package/npm/fleet-add-failure.js
CHANGED
|
@@ -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.
|
|
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
|
|
214
|
-
// signature (all printed pre-roster) always wins —
|
|
215
|
-
//
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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,
|
|
249
|
-
return category
|
|
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 };
|
package/npm/fleet-cleanup.js
CHANGED
|
@@ -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
|
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -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
|
|
19
|
-
// reachable
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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 (
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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;
|
package/npm/fleet-memory.js
CHANGED
|
@@ -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
|
-
*
|
|
327
|
-
|
|
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
|
-
|
|
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,
|