@codeyam-editor/codeyam-editor 0.1.12 → 0.1.13-staging.g1b6c798

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 CHANGED
@@ -114,7 +114,7 @@ const { addToRoster, readRoster } = require("./fleet-roster");
114
114
  // Tells a docker container-name conflict (a concurrent run already started this
115
115
  // container) apart from a real `docker compose up -d` failure, so the abort path
116
116
  // can converge instead of escalating to instance deletion.
117
- const { classifyComposeUpFailure } = require("./compose-conflict");
117
+ const { classifyComposeUpFailure, isGcloudTransportFailure } = require("./compose-conflict");
118
118
  // The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
119
119
  // and for the advance's provisioning pre-flight, which checks the same set.
120
120
  const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
@@ -881,8 +881,8 @@ function parseEditorComposeState(stdout) {
881
881
  }
882
882
 
883
883
  /** Decide whether `cloud:up`'s abort handler may delete the instance it is
884
- * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
885
- * → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
884
+ * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive,
885
+ * remoteCommandNeverRan } → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
886
886
  * `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
887
887
  * exhaustively testable without a VM.
888
888
  *
@@ -904,9 +904,20 @@ function parseEditorComposeState(stdout) {
904
904
  * SAFE: a failing probe is how we reach the abort handler in the first place,
905
905
  * so it can never count as evidence the VM is dead. An orphan costs one
906
906
  * `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
907
- * any unpushed work on the VM. */
908
- function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
907
+ * any unpushed work on the VM.
908
+ * - `remoteCommandNeverRan` — the error that brought us here was a transport
909
+ * failure: the gcloud client never reached the VM (an OS Login quota 429 on
910
+ * 2026-10-01), so the step it was running never executed. Same class as
911
+ * `probeInconclusive` — absence of evidence — and it must fail SAFE the same
912
+ * way. On that date the probes truthfully reported "container down, not
913
+ * rostered", but only BECAUSE compose-up never ran; deleting on that reading
914
+ * destroyed VM-8 after ~19 minutes of finished build. A run that never managed
915
+ * to execute its step has learned nothing about the VM, so it has not earned
916
+ * the right to delete one. Defense in depth: the compose-up retry should keep
917
+ * this from being reached at all. */
918
+ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive, remoteCommandNeverRan } = {}) {
909
919
  if (!createdThisRun) return "keep";
920
+ if (remoteCommandNeverRan) return "keep";
910
921
  if (probeInconclusive) return "keep";
911
922
  if (containerUp) return "keep";
912
923
  if (rostered) return "keep";
@@ -1268,13 +1279,24 @@ function provisionFailureMessage(verb, instanceName, result) {
1268
1279
  );
1269
1280
  }
1270
1281
 
1271
- // A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
1272
- // for an ssh failure) and a signature match against known transient strings.
1282
+ // A bounded, pure classifier with two independent branches:
1283
+ // - status === 255 (gcloud-ssh's exit for an ssh failure) plus a signature
1284
+ // match against known transient SSH-transport strings;
1285
+ // - a gcloud CLIENT-SIDE API failure (an OS Login quota 429), at any status.
1286
+ // gcloud does not exit 255 for these, so the status code is evidence for the
1287
+ // first branch, never a precondition for reading the text — gating on it is
1288
+ // what kept the 2026-10-01 VM-8 quota 429 out of the retry path.
1273
1289
  function isTransientSshFailure(result) {
1274
- if (!result || result.status !== 255) {
1290
+ if (!result || result.status === 0 || result.status == null) {
1275
1291
  return false;
1276
1292
  }
1277
1293
  const output = String(result.stderr || "") + String(result.stdout || "");
1294
+ if (isGcloudTransportFailure(output)) {
1295
+ return true;
1296
+ }
1297
+ if (result.status !== 255) {
1298
+ return false;
1299
+ }
1278
1300
  const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
1279
1301
  return transientRegex.test(output);
1280
1302
  }
@@ -2471,16 +2493,34 @@ async function ensureEditorContainerUp(cfg) {
2471
2493
  // stream and capture, so the operator's view is preserved by echoing the
2472
2494
  // captured streams immediately below instead of live. Do NOT revert this to
2473
2495
  // "inherit"; classification depends on it.
2474
- const up = runGcloud(
2475
- buildGcloudArgs("ssh-probe", cfg, {
2476
- command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
2477
- }),
2478
- { stdio: ["ignore", "pipe", "pipe"] },
2496
+ //
2497
+ // Through the retry wrapper: a transport failure (an OS Login quota 429 on
2498
+ // 2026-10-01) means `up -d` never ran, so the honest response is to try again.
2499
+ // Retrying is safe without a did-it-run probe — `up -d` is idempotent here, and
2500
+ // a retry that races a container into existence lands on the name-conflict
2501
+ // verdict below, which converges.
2502
+ const up = await runGcloudSshWithRetry(
2503
+ "ssh-probe",
2504
+ cfg,
2505
+ { command: `cd ~/projects/${repoName} && ${composeCmd} up -d` },
2506
+ { stdio: ["ignore", "pipe", "pipe"], label: "docker compose up -d" },
2479
2507
  );
2480
2508
  if (up.stdout) process.stderr.write(up.stdout);
2481
2509
  if (up.stderr) process.stderr.write(up.stderr);
2482
2510
  if (up.status !== 0) {
2483
2511
  const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
2512
+ if (verdict === "transport-error") {
2513
+ // Retries exhausted and gcloud still never reached the VM. Say so, and mark
2514
+ // the error so the abort handler leaves the VM alone: nothing about the VM
2515
+ // has been learned, so it cannot be judged broken.
2516
+ const err = new Error(
2517
+ "could not reach the VM to run docker compose up -d: gcloud failed before connecting " +
2518
+ "(an OS Login API quota 429 — too many concurrent fleet ssh calls). The remote command " +
2519
+ "never ran; the VM itself was not shown to be broken.",
2520
+ );
2521
+ err.remoteCommandNeverRan = true;
2522
+ throw err;
2523
+ }
2484
2524
  if (verdict !== "name-conflict") {
2485
2525
  throw new Error("docker compose up -d failed on the VM");
2486
2526
  }
@@ -4773,12 +4813,18 @@ async function cmdUp(args, projectDir = rootDir) {
4773
4813
  // VM is in right now, then let the pure decision rule — see
4774
4814
  // abortTeardownDecision's doc comment for why each input is load-bearing.
4775
4815
  const state = probeAbortTeardownState(cfg, createdThisRun);
4776
- const verdict = abortTeardownDecision({ createdThisRun, ...state });
4816
+ // An error that never reached the VM proves nothing about it — see the
4817
+ // `remoteCommandNeverRan` paragraph of abortTeardownDecision.
4818
+ const remoteCommandNeverRan =
4819
+ Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
4820
+ const verdict = abortTeardownDecision({ createdThisRun, ...state, remoteCommandNeverRan });
4777
4821
  if (createdThisRun && verdict === "keep") {
4778
4822
  // Deliberately loud and specific. The old blanket "tearing down the VM
4779
4823
  // cloud:up created this run (no orphan left behind)" was actively
4780
4824
  // misleading here — it asserted no orphan while deleting a working VM.
4781
- const why = state.probeInconclusive
4825
+ const why = remoteCommandNeverRan
4826
+ ? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
4827
+ : state.probeInconclusive
4782
4828
  ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4783
4829
  : state.containerUp
4784
4830
  ? "its editor container is RUNNING — another run brought it up"
@@ -31,12 +31,44 @@
31
31
  // whole captured stream rather than line- or anchor-based.
32
32
  const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
33
33
 
34
+ // The stable fragments of a gcloud CLIENT-SIDE API failure — gcloud's own HTTP
35
+ // client failed before any ssh was attempted, so the remote command never ran.
36
+ // Observed verbatim in the 2026-10-01 VM-8 add log, when an OS Login
37
+ // loginProfile read hit the `ReadRequestsPerMinutePerUser` quota (limit 60):
38
+ //
39
+ // ERROR: (gcloud.compute.ssh) HttpError accessing <https://oslogin.googleapis.com/…
40
+ // /loginProfile?…>: response: <{'status': 429}>, content <{"error": {"code": 429,
41
+ // "message": "Quota exceeded for quota metric …", "status": "RESOURCE_EXHAUSTED",
42
+ // "details": [{"reason": "RATE_LIMIT_EXCEEDED"}]}}
43
+ //
44
+ // Same discipline as the conflict fragments, for the same reason — Google
45
+ // rephrases these: require gcloud's `HttpError accessing` wrapper AND at least
46
+ // one quota token, never a single token, so an app-level message that merely
47
+ // contains `429` is not swept in.
48
+ const GCLOUD_HTTP_ERROR_FRAGMENT = /HttpError accessing/;
49
+ const GCLOUD_QUOTA_FRAGMENTS = [/'status':\s*429\b/, /RESOURCE_EXHAUSTED/, /RATE_LIMIT_EXCEEDED/];
50
+
51
+ /** True when `text` carries a gcloud client-side API quota failure, i.e. the
52
+ * gcloud client never reached the VM. Shared by {@link classifyComposeUpFailure}
53
+ * and `cloud.js`'s transient-ssh predicate and abort handler, so the three
54
+ * cannot drift apart on what counts as "never reached the VM". */
55
+ function isGcloudTransportFailure(text) {
56
+ const s = String(text == null ? '' : text);
57
+ return GCLOUD_HTTP_ERROR_FRAGMENT.test(s) && GCLOUD_QUOTA_FRAGMENTS.some((re) => re.test(s));
58
+ }
59
+
34
60
  /** Classify the outcome of `docker compose up -d`.
35
61
  *
36
62
  * Returns one of:
37
63
  * - `'ok'` — the command succeeded; there is no failure to classify. Guards
38
64
  * against a caller that classifies unconditionally, so a zero status can
39
65
  * never be read as a conflict.
66
+ * - `'transport-error'` — the gcloud ssh client never reached the VM (an OS
67
+ * Login quota 429 on 2026-10-01), so `up -d` did not run and nothing has been
68
+ * learned about the box. Reading this as `'failed'` is what drove the abort
69
+ * handler into deleting VM-8 after it had already built its editor image.
70
+ * The caller retries; it must never treat this as a provisioning failure.
71
+ * Checked first so a transport failure can never be read as a daemon message.
40
72
  * - `'name-conflict'` — the daemon refused because a container of that name
41
73
  * already exists, i.e. a concurrent run already started it. The caller
42
74
  * re-probes for a running container and converges instead of throwing.
@@ -50,10 +82,11 @@ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already
50
82
  function classifyComposeUpFailure({ status, stderr } = {}) {
51
83
  if (status === 0) return 'ok';
52
84
  const text = String(stderr == null ? '' : stderr);
85
+ if (isGcloudTransportFailure(text)) return 'transport-error';
53
86
  if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
54
87
  return 'name-conflict';
55
88
  }
56
89
  return 'failed';
57
90
  }
58
91
 
59
- module.exports = { classifyComposeUpFailure };
92
+ module.exports = { classifyComposeUpFailure, isGcloudTransportFailure };
@@ -99,6 +99,15 @@ function lastNonEmptyLines(text, maxLines = 50) {
99
99
  return lines.join('\n');
100
100
  }
101
101
 
102
+ // How many trailing lines the CLASSIFIER reads — deliberately wider than the
103
+ // 50-line tail the card displays. A verbose error body can crowd the signature
104
+ // that explains it out of a narrow window: VM-8's 2026-10-01 add died on an
105
+ // OS Login 429 whose JSON error body alone ran to 35 lines, pushing cloud:up's
106
+ // own teardown line to the edge of the 50-line tail. Reading more costs nothing
107
+ // and has no false-negative mode, unlike filtering "noisy" lines, which would
108
+ // have to know every error shape in advance.
109
+ const CLASSIFY_TAIL_LINES = 200;
110
+
102
111
  // True iff the provision transcript reached the early-roster marker (`CY_ROSTER
103
112
  // <n>`) — i.e. the instance exists and the container gate passed, so the VM is
104
113
  // rostered. Reuses parseRosterMarkers (npm/fleet-early-roster.js) so the
@@ -139,14 +148,17 @@ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending'
139
148
  // answering — the box exists but never came up.
140
149
  // - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
141
150
  // marker is the tail's own honest deadline signal).
151
+ // - `teardownReported` the transcript carries cloud:up's own
152
+ // clean-teardown report (cleanTeardownReported): it deleted the VM it
153
+ // created, so the instance is gone by the provisioner's own account.
142
154
  //
143
155
  // Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
144
156
  // corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
145
157
  // never be escalated to red by absence of evidence.
146
- function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
158
+ function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, teardownReported } = {}) {
147
159
  const code = exitCode == null ? NaN : Number(exitCode);
148
160
  if (!Number.isInteger(code) || code === 0) return false;
149
- return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
161
+ return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown || !!teardownReported;
150
162
  }
151
163
 
152
164
  // Resolve the two independent health signals into the single tri-state
@@ -697,6 +709,26 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
697
709
  cloudUpFailedRecovery({ cleanTeardown: cleanTeardownReported(log) }));
698
710
  }
699
711
 
712
+ // 11b. torn-down — cloud:up reported, in its own words, that it deleted the VM it
713
+ // created this run, but no named cause above matched. That report is positive
714
+ // proof of death — the standard addFailureCorroborated demands — so the outcome
715
+ // is PROVEN, not unconfirmed. VM-8, 2026-10-01: an OS Login 429 killed the
716
+ // container step, cloud:up tore the instance down, and the transcript said so
717
+ // plainly; with no arm reading that line as evidence it fell to the `verifying`
718
+ // residue, whose calm hold could never release for a VM that never rostered,
719
+ // and the card spun "Add outcome unconfirmed" for 49 minutes and counting.
720
+ //
721
+ // Terminal and NOT retryable: nothing is left to reconcile or destroy, but the
722
+ // cause may still be present, so re-adding is the operator's call, not a
723
+ // one-click action. Tested after every named-cause arm so a specific cause
724
+ // always wins and keeps its actionable recovery (cloud-up-failed already reads
725
+ // the same teardown fact into its copy), and BEFORE interrupted / converging /
726
+ // verifying, none of which may describe a VM the provisioner reports deleted.
727
+ if (cleanTeardownReported(log)) {
728
+ return buildFailureVerdict('torn-down', 'Provisioning failed — VM deleted',
729
+ cloudUpFailedRecovery({ cleanTeardown: true }));
730
+ }
731
+
700
732
  // 12. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
701
733
  // Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
702
734
  if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
@@ -766,7 +798,8 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
766
798
  // addFailureCorroborated). The box is provably dead or provably never
767
799
  // came up, so the loud, terminal escalation is warranted and unchanged.
768
800
  // Still never invites an unproven retry.
769
- if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown })) {
801
+ if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown,
802
+ teardownReported: cleanTeardownReported(log) })) {
770
803
  return buildFailureVerdict('unknown', 'Add failed',
771
804
  '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.');
772
805
  }
@@ -818,8 +851,56 @@ function canRetryAdd({ n, job } = {}) {
818
851
  // escalation, which this predicate narrows but never softens. Kept pure (like
819
852
  // canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
820
853
  // live dashboard.
821
- function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
822
- return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
854
+ //
855
+ // The hold also has an ABSOLUTE AGE CEILING, because `wiringDeadlineBlown` alone is
856
+ // not a reachable release for every add: the failed marker is dropped by the
857
+ // add's wire tail, and an add that never rostered never had one. VM-8
858
+ // (2026-10-01) sat in `verifying` for 49 minutes with no possible exit. So:
859
+ //
860
+ // ageMs ms since the add settled (job.finishedAt). Past the ceiling the hold
861
+ // releases. Missing / non-finite HOLDS — an unknown age may never
862
+ // escalate a card on its own, the same tri-state rule
863
+ // addFailureCorroborated applies to every fact.
864
+ // rostered is the VM in the roster? An explicit `false` means the add never
865
+ // reached CY_ROSTER, so there is no ingress convergence left to wait
866
+ // for and the shorter ceiling applies. Unknown takes the longer one.
867
+ //
868
+ // `wiringDeadlineBlown` stays the fast path: a genuinely failed wiring still
869
+ // releases immediately rather than waiting out the clock.
870
+ const CALM_HOLD_CEILING_MS = 30 * 60 * 1000; // well past the 8-minute wiring deadline plus LB propagation
871
+ const NEVER_ROSTERED_HOLD_CEILING_MS = 10 * 60 * 1000; // no wiring to wait for — only a late transcript flush
872
+
873
+ function holdConvergingBadge({ category, wiringDeadlineBlown, ageMs, rostered } = {}) {
874
+ if (!CALM_ADD_CATEGORIES.has(category) || wiringDeadlineBlown) return false;
875
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs)) return true;
876
+ const ceiling = rostered === false ? NEVER_ROSTERED_HOLD_CEILING_MS : CALM_HOLD_CEILING_MS;
877
+ return ageMs < ceiling;
878
+ }
879
+
880
+ // Age in ms of a settled job, from its `finishedAt` ISO stamp, for
881
+ // holdConvergingBadge's ceiling. Returns undefined when the stamp is missing or
882
+ // unparseable, which holds — never a fabricated age.
883
+ function settledAgeMs(job, nowMs = Date.now()) {
884
+ const t = job && job.finishedAt ? Date.parse(job.finishedAt) : NaN;
885
+ return Number.isFinite(t) ? Math.max(0, nowMs - t) : undefined;
886
+ }
887
+
888
+ // The `instanceAlive` fact for addFailureCorroborated, read from a RECORDED GCP
889
+ // existence verdict ({ exists, at } — what the vanish reconcile's describe
890
+ // returned, and when) rather than a fresh probe, so the failure path stays
891
+ // synchronous. Two rules keep it from escalating a live VM:
892
+ //
893
+ // - only a boolean verdict counts; the describe's inconclusive `null`, or no
894
+ // record at all, returns null (corroborates nothing);
895
+ // - a verdict taken BEFORE `sinceIso` (the add's startedAt) describes a
896
+ // previous instance under the same number, not this add's, so it returns
897
+ // null too. An unparseable or missing `sinceIso` cannot date the record
898
+ // against anything, and the verdict is used as-is.
899
+ function instanceAliveSince(record, sinceIso) {
900
+ if (!record || typeof record.exists !== 'boolean') return null;
901
+ const since = sinceIso ? Date.parse(sinceIso) : NaN;
902
+ if (Number.isFinite(since) && !(record.at >= since)) return null;
903
+ return record.exists;
823
904
  }
824
905
 
825
906
  // Refusal copy for a Retry on a card whose branch was deleted from origin.
@@ -884,4 +965,16 @@ function streamedLibraryRetryMessage(n, recovery) {
884
965
  return `VM-${n} cannot be retried until the code is fixed: ${recovery || 'a streamed bootstrap library was never shipped to the VM.'}`;
885
966
  }
886
967
 
887
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
968
+ // Refusal copy for a one-click "Retry add" on a `torn-down` card. The generic
969
+ // refusal's "may have left a half-provisioned instance — reconcile or destroy it"
970
+ // is false here: cloud:up reported deleting the VM it created. The retry is
971
+ // refused only because the unclassified cause may still be present, so the copy
972
+ // says nothing is left behind and hands the re-add to the operator. Pure; no I/O.
973
+ function tornDownRetryMessage(n) {
974
+ return (
975
+ `VM-${n} was deleted by cloud:up when its add failed, so there is nothing to reconcile or destroy. ` +
976
+ 'Read the transcript for the cause, then use Add VM again once it is resolved.'
977
+ );
978
+ }
979
+
980
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, tornDownRetryMessage, settledAgeMs, instanceAliveSince, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE, CLASSIFY_TAIL_LINES };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-api-budget.js — the RATE dimension of heavy-job admission: how many
4
+ // Google API calls per minute the fleet's in-flight work is projected to spend,
5
+ // and whether admitting one more add would push that past the per-user budget.
6
+ //
7
+ // Background (fleet--a-batch-of-adds-blows-a-per-user-google-api-quota): every
8
+ // `gcloud compute ssh` against an OS-Login VM spends at least one
9
+ // `oslogin.googleapis.com` loginProfile read; the limit is 60 per minute per
10
+ // USER, and all fleet automation runs as one service account — so the whole
11
+ // fleet shares one 60/min budget. On 2026-10-01 it ran out while the job cap of
12
+ // 4 held the entire time: a cap counts jobs, and a quota is a rate. A 429 landed
13
+ // mid-provision and tore down an add that had already built its editor image.
14
+ //
15
+ // This module is the accountant for that rate. It is a PROJECTION from known
16
+ // per-add shapes, not a measured counter: counting real calls would mean
17
+ // intercepting every gcloud invocation across detached shells — the same
18
+ // invisibility that produced this class of bug twice. Every function is pure.
19
+
20
+ /** Defaults for the API-rate dimension of add admission.
21
+ *
22
+ * - `budgetPerMin` — the real limit: `oslogin.googleapis.com` read requests per
23
+ * minute per user.
24
+ * - `reservedPerMin` — headroom kept back for traffic admission cannot see: the
25
+ * dashboard's own monitoring gathers (bounded in concurrency by hostOpLimiter,
26
+ * not in rate) and the tunnel watchdog.
27
+ * - `perAddCallsPerMin` — what one in-flight add is charged. Deliberately its
28
+ * PEAK, not its average: the fast first minute of the container gate (6 reads)
29
+ * overlapping cloud:up's own ssh steps or the post-roster burst. Charging the
30
+ * peak is wrong in the safe direction — it queues a little early rather than
31
+ * 429ing a provision.
32
+ *
33
+ * At these defaults (60 - 20) / 10 = 4 concurrent adds, the same as the job cap,
34
+ * so an operator who raises MAX_JOB_CONCURRENCY meets this budget instead of the
35
+ * quota. */
36
+ const API_BUDGET_DEFAULTS = Object.freeze({ budgetPerMin: 60, reservedPerMin: 20, perAddCallsPerMin: 10 });
37
+
38
+ // A positive finite number, else 0 — so garbage degrades toward admitting work,
39
+ // never toward a phantom load that would freeze admission forever.
40
+ function nonNegative(value) {
41
+ const n = Number(value);
42
+ return Number.isFinite(n) && n > 0 ? n : 0;
43
+ }
44
+
45
+ /** Project the fleet's Google API call rate (calls/min) from the heavy work in
46
+ * flight. `inFlight` is `heavyChargeCount`'s answer — running adds PLUS detached
47
+ * wiring tails — so this dimension inherits the tail visibility the job cap
48
+ * already has instead of re-deriving it. */
49
+ function projectApiCallsPerMin({
50
+ inFlight = 0,
51
+ perAddCallsPerMin = API_BUDGET_DEFAULTS.perAddCallsPerMin,
52
+ reservedPerMin = API_BUDGET_DEFAULTS.reservedPerMin,
53
+ } = {}) {
54
+ return nonNegative(reservedPerMin) + nonNegative(inFlight) * nonNegative(perAddCallsPerMin);
55
+ }
56
+
57
+ /** True when admitting one more add (costing `apiCallsPerAdd`) would project past
58
+ * the budget. False whenever the budget or the projection is absent or
59
+ * non-numeric, or the budget is non-positive — an unset or misconfigured budget
60
+ * degrades to "no rate gate", the same rule `decideAddSlot` applies to a bad
61
+ * cap. */
62
+ function wouldExceedApiBudget({
63
+ apiCallBudgetPerMin,
64
+ projectedApiCallsPerMin,
65
+ apiCallsPerAdd = API_BUDGET_DEFAULTS.perAddCallsPerMin,
66
+ } = {}) {
67
+ if (apiCallBudgetPerMin == null || projectedApiCallsPerMin == null) return false;
68
+ const budget = Number(apiCallBudgetPerMin);
69
+ const projected = Number(projectedApiCallsPerMin);
70
+ if (!Number.isFinite(budget) || budget <= 0 || !Number.isFinite(projected)) return false;
71
+ return projected + nonNegative(apiCallsPerAdd) > budget;
72
+ }
73
+
74
+ /** Whether the RATE dimension, not the job cap, is what holds the next add back
75
+ * — so a queue paced by the API budget can say so instead of reading as an
76
+ * unexplained stall. Takes the same inputs as `decideAddSlot`. A non-positive
77
+ * cap means "no job cap", so the slot is free and only the rate can hold. */
78
+ function heldByApiBudget(inputs = {}) {
79
+ const cap = Number(inputs.maxConcurrency);
80
+ const capFree = !(cap > 0) || Number(inputs.activeCount) < cap;
81
+ return capFree && wouldExceedApiBudget(inputs);
82
+ }
83
+
84
+ /** The text Google returns when a per-user API quota is spent. `RESOURCE_EXHAUSTED`
85
+ * is the gRPC status name, `Quota exceeded` / `rateLimitExceeded` the REST
86
+ * wording. A bare "429" is deliberately NOT matched alone, since port numbers
87
+ * and VM indexes contain it — only an HTTP-status shape of it is. */
88
+ const API_QUOTA_PATTERN = /RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded|RATE_LIMIT_EXCEEDED|HTTP(?:Error)? 429|status(?:Code)?[ =:]+429/i;
89
+
90
+ /** First line of `text` naming a Google API quota exhaustion, trimmed and capped
91
+ * at 300 chars, or null. Matched against job output so a 429 reads as a named
92
+ * condition instead of an "unconfirmed" card with the cause in a transcript. */
93
+ function detectApiQuotaExhaustion(text) {
94
+ if (typeof text !== 'string' || !text) return null;
95
+ const line = text.split(/\r?\n/).find((l) => API_QUOTA_PATTERN.test(l));
96
+ return line ? line.trim().slice(0, 300) : null;
97
+ }
98
+
99
+ module.exports = {
100
+ API_BUDGET_DEFAULTS,
101
+ projectApiCallsPerMin,
102
+ wouldExceedApiBudget,
103
+ heldByApiBudget,
104
+ detectApiQuotaExhaustion,
105
+ };
@@ -117,10 +117,13 @@ function canClearBadge({ rostered, job } = {}) {
117
117
  //
118
118
  // Only the LABEL changes. `job.state` stays 'error', so canRetryAdd,
119
119
  // canClearBadge and shouldReconcileFailedBadge keep gating on it unchanged.
120
- function jobBadgeLabel({ job, wiringDeadlineBlown } = {}) {
120
+ //
121
+ // `ageMs` / `rostered` are holdConvergingBadge's age-ceiling inputs, passed
122
+ // through unchanged for the same reason.
123
+ function jobBadgeLabel({ job, wiringDeadlineBlown, ageMs, rostered } = {}) {
121
124
  if (!job) return '';
122
125
  if (job.action === 'add' && job.state === 'error') {
123
- return faf.holdConvergingBadge({ category: job.category, wiringDeadlineBlown })
126
+ return faf.holdConvergingBadge({ category: job.category, wiringDeadlineBlown, ageMs, rostered })
124
127
  ? 'add unconfirmed'
125
128
  : 'add failed';
126
129
  }
@@ -12,6 +12,7 @@ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } =
12
12
  const { normalizeVmSlug } = require("./vm-urls");
13
13
  const { normalizeAccessList } = require("./fleet-vm-access");
14
14
  const { normalizeRepoUrl } = require("./fleet-env");
15
+ const { API_BUDGET_DEFAULTS, wouldExceedApiBudget } = require("./fleet-api-budget");
15
16
  const { posix: posixPath } = require("node:path");
16
17
 
17
18
  /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
@@ -396,11 +397,26 @@ function classifyContainerWait({ everSeenUp = false, instanceGone = false, elaps
396
397
  * queue + posting a `queued` job entry on "queue", or starting addVm on
397
398
  * "start". Decoupling the decision from the I/O makes it unit-testable
398
399
  * against arbitrary (cap, active) tuples — the test signal the OOM bug
399
- * needed and the queue never had. */
400
- function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
400
+ * needed and the queue never had.
401
+ *
402
+ * A second, optional dimension is a RATE, not a count: the per-user Google API
403
+ * budget (oslogin loginProfile reads, 60/min for the one service account the
404
+ * whole fleet runs as). When `apiCallBudgetPerMin` AND `projectedApiCallsPerMin`
405
+ * are both supplied, an add that would push the projection past the budget by
406
+ * its own `apiCallsPerAdd` is queued even with job slots free — the 2026-10-01
407
+ * shape, where a cap of 4 held while the fleet 429'd a provision mid-flight.
408
+ * Omit either and the decision is exactly the count-only one above. */
409
+ function decideAddSlot({
410
+ maxConcurrency,
411
+ activeCount = 0,
412
+ apiCallBudgetPerMin,
413
+ projectedApiCallsPerMin,
414
+ apiCallsPerAdd = API_BUDGET_DEFAULTS.perAddCallsPerMin,
415
+ } = {}) {
401
416
  const cap = Number(maxConcurrency);
402
- if (!Number.isFinite(cap) || cap <= 0) return "start";
403
- return Number(activeCount) < cap ? "start" : "queue";
417
+ const underCap = !Number.isFinite(cap) || cap <= 0 || Number(activeCount) < cap;
418
+ if (!underCap) return "queue";
419
+ return wouldExceedApiBudget({ apiCallBudgetPerMin, projectedApiCallsPerMin, apiCallsPerAdd }) ? "queue" : "start";
404
420
  }
405
421
 
406
422
  /** How many concurrency slots the fleet's heavy work is ACTUALLY occupying —
@@ -1479,6 +1495,109 @@ function localVmReapCmd({ port, previewPort } = {}) {
1479
1495
  ].join("; ");
1480
1496
  }
1481
1497
 
1498
+ /** Poll cadence for the Add's container gate, as `[{ untilSec, everySec }, …]`
1499
+ * bands over elapsed time; the last band (no `untilSec`) runs to the deadline.
1500
+ * A cold image build takes 15-25 min, so the old flat 10s cadence asked "is it
1501
+ * up yet?" far finer than the event it waits for — and each ask is a
1502
+ * `gcloud compute ssh`, i.e. an oslogin read against the fleet-wide 60/min
1503
+ * per-user quota. Fast for the first minute (a warm image is up in seconds),
1504
+ * then widening: ~6 reads in minute one, 2/min to minute five, 1/min after —
1505
+ * roughly 32 reads over a full 28-min wait instead of 168. */
1506
+ const CONTAINER_GATE_SCHEDULE = Object.freeze([
1507
+ Object.freeze({ untilSec: 60, everySec: 10 }),
1508
+ Object.freeze({ untilSec: 300, everySec: 30 }),
1509
+ Object.freeze({ everySec: 60 }),
1510
+ ]);
1511
+
1512
+ /** Build the Add script's container gate: wait for the editor container to report
1513
+ * `Up`, up to `deadlineSec` of REAL elapsed time (default 1680s, the historical
1514
+ * ~28 min), mirroring `classifyContainerWait`:
1515
+ * - any single `Up` is terminal success (`st=1`);
1516
+ * - a clean cloud:up exit is the normal `--no-tunnel` success path — keep
1517
+ * probing; a NON-ZERO exit gets one final container re-check and otherwise
1518
+ * fails fast with the cloud:up log tail instead of probing a dead provision;
1519
+ * - an empty/errored probe (transient gcloud-ssh blip) is retried, never read
1520
+ * as "not up";
1521
+ * - past the deadline: a final re-check, then a vanished instance is told
1522
+ * apart from a build that never came up, and cloud:up is reaped.
1523
+ *
1524
+ * Only the POLLING COST differs from the inline loop this replaced: probes follow
1525
+ * `schedule`, and the waits between them are 5s `kill -0` slices — free, no API
1526
+ * call — so a cloud:up exit (success under `--no-tunnel`, or a failure) is still
1527
+ * noticed within ~5s rather than a full band interval later. Elapsed time is read
1528
+ * from the clock, not counted in ticks, so a wider cadence cannot move the
1529
+ * deadline.
1530
+ *
1531
+ * Each probe's stderr is kept (it used to be discarded) and scanned for a Google
1532
+ * API quota refusal; a hit prints one `CY_API_QUOTA` line on the job's stdout so
1533
+ * the dashboard can name it. `probeCmd` must already be shell-quoted. Pure. */
1534
+ function containerGateCmd({
1535
+ gcloud = "gcloud",
1536
+ inst,
1537
+ zone,
1538
+ probeCmd,
1539
+ logPath,
1540
+ deadlineSec = 1680,
1541
+ schedule = CONTAINER_GATE_SCHEDULE,
1542
+ pidVar = "UP_PID",
1543
+ } = {}) {
1544
+ const errPath = `${logPath}.gate-err`;
1545
+ const bands = (Array.isArray(schedule) && schedule.length ? schedule : CONTAINER_GATE_SCHEDULE);
1546
+ // The last band is the fallback whatever its `untilSec` says, so the chain
1547
+ // always ends in an `else` and every elapsed value gets a cadence.
1548
+ const cadence = bands.map((b, i) => {
1549
+ if (i === bands.length - 1) return i === 0 ? ` __cy_gate_every=${b.everySec}` : ` else __cy_gate_every=${b.everySec}`;
1550
+ return ` ${i === 0 ? "if" : "elif"} [ "$__cy_gate_elapsed" -lt ${b.untilSec} ]; then __cy_gate_every=${b.everySec}`;
1551
+ });
1552
+ if (bands.length > 1) cadence.push(` fi`);
1553
+ const minutes = Math.round(deadlineSec / 60);
1554
+ // cloud:up's own ssh steps can be the call that 429s; its log is private, so on
1555
+ // every failure exit republish the first quota line onto the job's stdout.
1556
+ const quotaFromLog = `grep -m1 -iE 'RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded' ${logPath} 2>/dev/null | sed 's/^/CY_API_QUOTA cloud:up: /' || true`;
1557
+ return [
1558
+ `__cy_gate_start=$(date +%s); st=0; cloud_up_exited=0`,
1559
+ `__cy_gate_probe() {`,
1560
+ ` up=$(${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${probeCmd} 2>${errPath} | grep -c Up || true)`,
1561
+ ` __cy_quota=$(grep -m1 -iE 'RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded' ${errPath} 2>/dev/null || true)`,
1562
+ ` if [ -n "$__cy_quota" ]; then echo "CY_API_QUOTA container gate probe for ${inst}: $__cy_quota"; fi`,
1563
+ `}`,
1564
+ `while :; do`,
1565
+ ` __cy_gate_probe`,
1566
+ ` if [ "$up" = "1" ]; then st=1; break; fi`,
1567
+ ` if [ "$cloud_up_exited" = "0" ] && ! kill -0 $${pidVar} 2>/dev/null; then`,
1568
+ ` cloud_up_exited=1; wait $${pidVar} 2>/dev/null; up_rc=$?`,
1569
+ ` if [ "$up_rc" != "0" ]; then`,
1570
+ ` __cy_gate_probe`,
1571
+ ` if [ "$up" != "1" ]; then`,
1572
+ ` echo "cloud:up FAILED (exit $up_rc) before ${inst}'s container came up — provisioning aborted (see ${logPath}):"`,
1573
+ ` tail -20 ${logPath} 2>/dev/null | grep -vE 'WARNING|NumPy|cloud\\.google\\.com/iap' || true`,
1574
+ ` ${quotaFromLog}`,
1575
+ ` exit 1`,
1576
+ ` fi`,
1577
+ ` st=1; break`,
1578
+ ` fi`,
1579
+ ` echo "cloud:up exited cleanly (expected under --no-tunnel once the container is up); continuing to probe ${inst} until it reports Up or the deadline..."`,
1580
+ ` fi`,
1581
+ ` __cy_gate_elapsed=$(( $(date +%s) - __cy_gate_start ))`,
1582
+ ` if [ "$__cy_gate_elapsed" -ge ${deadlineSec} ]; then break; fi`,
1583
+ ...cadence,
1584
+ ` __cy_gate_left=$(( ${deadlineSec} - __cy_gate_elapsed ))`,
1585
+ ` if [ "$__cy_gate_every" -gt "$__cy_gate_left" ]; then __cy_gate_every=$__cy_gate_left; fi`,
1586
+ ` __cy_gate_slept=0`,
1587
+ ` while [ "$__cy_gate_slept" -lt "$__cy_gate_every" ]; do`,
1588
+ ` if [ "$cloud_up_exited" = "0" ] && ! kill -0 $${pidVar} 2>/dev/null; then break; fi`,
1589
+ ` sleep 5; __cy_gate_slept=$((__cy_gate_slept + 5))`,
1590
+ ` done`,
1591
+ `done`,
1592
+ `if [ "$st" != "1" ]; then __cy_gate_probe; [ "$up" = "1" ] && st=1; fi`,
1593
+ `if [ "$st" != "1" ]; then`,
1594
+ ` if ! ${gcloud} compute instances describe ${inst} --zone=${zone} --format='value(name)' >/dev/null 2>&1; then echo "instance ${inst} is gone — provisioning failed before the container started (see ${logPath})"; else echo "container never came up for ${inst} within ~${minutes} min (see ${logPath})"; fi`,
1595
+ ` ${quotaFromLog}`,
1596
+ ` kill $${pidVar} 2>/dev/null; ${cloudUpSweepCmd({ inst })}; exit 1`,
1597
+ `fi`,
1598
+ ].join("\n");
1599
+ }
1600
+
1482
1601
  /** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
1483
1602
  * container-healthy instead of reaping it mid-flight.
1484
1603
  *
@@ -1568,6 +1687,8 @@ module.exports = {
1568
1687
  classifyContainerWait,
1569
1688
  decideAddSlot,
1570
1689
  heavyChargeCount,
1690
+ CONTAINER_GATE_SCHEDULE,
1691
+ containerGateCmd,
1571
1692
  HEAVY_ACTIONS,
1572
1693
  queuedStatusForAction,
1573
1694
  pushHistory,
@@ -98,6 +98,9 @@ function escapeHtmlAttribute(value) {
98
98
  // Preview. Callers (via scenario-check.js) pass a concrete color when the
99
99
  // UI has detected a background it wants the capture to paint behind the
100
100
  // iframe, e.g. `var(--bg-deep)` from the editor shell.
101
+ // The iframe is named `codeyam-preview` (CODEYAM_PREVIEW_FRAME_NAME in
102
+ // ui/src/helpers/previewMockPolicy.ts) so a framed editor page knows it is
103
+ // inside codeyam's own capture, not a hosting IDE's pane.
101
104
  function buildIframeHarness(url, { background = "transparent" } = {}) {
102
105
  const escapedUrl = escapeHtmlAttribute(url);
103
106
  const bg = String(background);
@@ -124,7 +127,7 @@ function buildIframeHarness(url, { background = "transparent" } = {}) {
124
127
  </style>
125
128
  </head>
126
129
  <body>
127
- <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
130
+ <iframe id="scenario-frame" name="codeyam-preview" title="Scenario Preview" src="${escapedUrl}"></iframe>
128
131
  </body>
129
132
  </html>`;
130
133
  }