@coreplane/switchboard 1.211.0 → 1.213.0

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 (35) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/config/config.example.yaml +44 -0
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -0
  4. package/dist/assets/deploy/cloudflare-resident/Dockerfile +24 -0
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +421 -38
  6. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +9 -1
  7. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +18 -0
  8. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +19 -5
  9. package/dist/assets/deploy/secrets.manifest.json +18 -0
  10. package/dist/assets/package-lock.json +5 -3
  11. package/dist/assets/package.json +2 -1
  12. package/dist/assets/source.json +3 -3
  13. package/dist/assets/src/agents/registry.ts +50 -7
  14. package/dist/assets/src/core/redact.ts +11 -1
  15. package/dist/assets/src/core/runEvents.ts +60 -6
  16. package/dist/assets/src/core/runFriction.ts +6 -4
  17. package/dist/assets/src/core/trace/workerTrace.ts +4 -0
  18. package/dist/assets/src/execution/binaryRead.ts +56 -14
  19. package/dist/assets/src/execution/residentRefresh.ts +151 -5
  20. package/dist/assets/src/execution/residentStepReport.ts +4 -0
  21. package/dist/assets/web/dist/.vite/manifest.json +19 -19
  22. package/dist/assets/web/dist/assets/{ResidentDetailPage-CM5nWw-Z.js → ResidentDetailPage-CvwjhjlG.js} +1 -1
  23. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BKBnuvp3.js → ResidentsIndexPage-DoN6XuNx.js} +1 -1
  24. package/dist/assets/web/dist/assets/RunRoutePage-FzLNexjF.js +12 -0
  25. package/dist/assets/web/dist/assets/{RunsIndexPage-w2jJP5xu.js → RunsIndexPage-DhSUbF9k.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ScheduledPage-D3-k9DPz.js → ScheduledPage-Bq0Yg4nT.js} +1 -1
  27. package/dist/assets/web/dist/assets/{StatusDot-BmFHnV8m.js → StatusDot-D8Wwt1KC.js} +1 -1
  28. package/dist/assets/web/dist/assets/{Tooltip-DoThP2fW.js → Tooltip-DpDK7jWZ.js} +1 -1
  29. package/dist/assets/web/dist/assets/{dist-YrRKtxsS.js → dist-B7BVkB7x.js} +1 -1
  30. package/dist/assets/web/dist/assets/{main-D6nzMf0k.js → main-BFkEOy3K.js} +2 -2
  31. package/dist/assets/web/dist/assets/main-DCH3Mezs.css +1 -0
  32. package/dist/cli.js +2704 -603
  33. package/package.json +2 -1
  34. package/dist/assets/web/dist/assets/RunRoutePage-Bu4CwEzt.js +0 -12
  35. package/dist/assets/web/dist/assets/main-CuENKPdD.css +0 -1
@@ -96,11 +96,14 @@ import {
96
96
  } from "../../src/execution/residentDepCache.js";
97
97
  import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
98
98
  import {
99
- base64ByteLength,
100
- MAX_READ_BASE64_CHARS,
99
+ base64LengthOf,
100
+ chunkPlan,
101
101
  MAX_READ_BYTES,
102
+ parseByteSize,
103
+ readChunkCommandFor,
102
104
  readCommandFor,
103
105
  readEncodingOf,
106
+ statCommandFor,
104
107
  type Base64ReadAnswer,
105
108
  type ReadEncoding,
106
109
  } from "../../src/execution/binaryRead.js";
@@ -111,6 +114,7 @@ import {
111
114
  recoverCapturedOutput,
112
115
  } from "../../src/execution/residentExecWrap.js";
113
116
  import { shellQuote } from "../../src/execution/shellQuote.js";
117
+ import { envFromRequest } from "../../src/execution/sandboxEnv.js";
114
118
  import {
115
119
  CREDENTIAL_EXPIRY_MARGIN_MS,
116
120
  shouldRefreshThreadCredentials,
@@ -142,9 +146,15 @@ import {
142
146
  checkoutUpdateCommand,
143
147
  classifyRefreshFailure,
144
148
  restoreFailureDisposition,
149
+ isRuntimeUnreachableSignal,
145
150
  killStaleBuildProcessesCommand,
146
151
  planRefresh,
147
152
  RUNTIME_REPLACEMENT_WORDING,
153
+ RUNTIME_UNREACHABLE_DOWN_AT,
154
+ runtimeUnreachableReason,
155
+ runtimeUnreachableRung,
156
+ SDK_CONNECT_TIMEOUT_MS,
157
+ SDK_RUNTIME_RECORD_KEY,
148
158
  judgeRestoreProgress,
149
159
  planWakeDepsBudget,
150
160
  RESTORE_MAX_MS,
@@ -505,8 +515,10 @@ const DEGRADED_STREAK_KEY = "resident:degradedStreak";
505
515
  * re-open the parked-degraded hole this fixes. A refresh step killed from
506
516
  * outside (`refresh-interrupted`, `classifyRefreshFailure`) is never recorded
507
517
  * as `degraded` at all: the instance throws it to the engine, whose retry
508
- * re-enters the step. */
509
- const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted):/;
518
+ * re-enters the step. A control port that did not answer
519
+ * (`runtime-unreachable: …`, item 64) is the third: no command ran, the
520
+ * ladder over its own persisted count owns the recovery. */
521
+ const NON_EVIDENCE_REASON = /^(?:stale-mid-flight|restore-interrupted|runtime-unreachable):/;
510
522
  /** When the disk-full recovery last stopped the container (docs/reference/specs/resident-repos.md item 54):
511
523
  * feeds `planDiskFullRecovery`'s cooldown so a working set that refills the
512
524
  * disk is named, not recycled in a loop. */
@@ -545,9 +557,11 @@ const ATTACH_MUTEX_WAIT_MS = 60_000;
545
557
  * an admin would, discarding the unusable snapshots and reprovisioning from
546
558
  * GitHub. Provision-failure downs never auto-rebuild — they would loop
547
559
  * against the same broken build. With the 10-minute cron, N=3 ≈ 30 minutes
548
- * down before the automatic escape hatch fires. */
560
+ * down before the automatic escape hatch fires. `runtime-unreachable` is the
561
+ * ladder's last rung (item 64): a recreated container did not answer either,
562
+ * so the rebuild — destroy plus reprovision — is the only exit left. */
549
563
  const AUTO_REBUILD_AFTER_STRIKES = 3;
550
- const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot)/;
564
+ const REHYDRATION_FAILURE_RE = /^(r2-restore-failed|snapshot-stamp-mismatch|no-snapshot|runtime-unreachable)/;
551
565
 
552
566
  /** /exec budget: the shared 5-minute default (`BASH_TIMEOUT_MS`);
553
567
  * a caller may raise it per call via the body's `timeoutMs` up to the shared
@@ -711,6 +725,39 @@ function isRuntimeReplacement(err: unknown): boolean {
711
725
  function runtimeReplacedErr(err: RuntimeReplacedError): ThreadErr {
712
726
  return { error: err.message, status: 409, reason: "runtime-replaced" };
713
727
  }
728
+
729
+ /** The container's control port never answered: `exec` rejected with the
730
+ * DOMException of the SDK's connect abort (`DEFAULT_CONNECT_TIMEOUT_MS`,
731
+ * 30 s), raised inside its wake path — `RuntimeBootstrapProbe.probe` →
732
+ * `ContainerControlConnection.fetchUpgradeAttempt`, the WebSocket upgrade to
733
+ * port 3000 — before any process could start. Not a replacement (the runtime
734
+ * did not change; it is silent), not a command failure (nothing ran), not
735
+ * evidence about the repository. Carries the persisted consecutive count the
736
+ * ladder decides on (`runtimeUnreachableRung`, docs/reference/specs/resident-repos.md
737
+ * item 64); the message is the named reason, never the SDK's bare
738
+ * `The operation was aborted` — which is what the incident this names sat
739
+ * behind as `degraded(refresh-failed: The operation was aborted)` for forty
740
+ * minutes while nothing escalated. */
741
+ class RuntimeUnreachableError extends Error {
742
+ constructor(
743
+ readonly count: number,
744
+ readonly cause: unknown,
745
+ ) {
746
+ super(runtimeUnreachableReason(count));
747
+ this.name = "RuntimeUnreachableError";
748
+ }
749
+ }
750
+
751
+ /** Does this exec rejection mean the control port never answered? The pure
752
+ * signal (`isRuntimeUnreachableSignal`: the `AbortError` name, or the
753
+ * DOMException's message when a wrapper copied only that) over the error and
754
+ * its cause chain. Asked only AFTER `isRuntimeReplacement` — a replaced
755
+ * runtime is a different fact — and only of a spawn-phase error: a
756
+ * command's own output is a `StepError` with an exit code and never gets here. */
757
+ function isRuntimeUnreachable(err: unknown): boolean {
758
+ for (const link of selfAndCauses(err)) if (isRuntimeUnreachableSignal(link)) return true;
759
+ return false;
760
+ }
714
761
  /** Trailing slice of one string for an error reason. Command RESULTS are not
715
762
  * described here — `describeStepFailure` owns that, because choosing between
716
763
  * the two streams is what lost a diagnosis (residentStepReport.ts). */
@@ -1269,6 +1316,17 @@ const FACTS_KEY = "resident:facts";
1269
1316
  const SNAPSHOT_KEY = "resident:snapshot";
1270
1317
  const DEADLINE_AT_KEY = "resident:provisionDeadlineAt";
1271
1318
  const REBUILD_STRIKES_KEY = "resident:rebuildStrikes"; // watchdog auto-rebuild counter
1319
+ /** Consecutive connects the container's control port did not answer (item 64): the ladder's count. */
1320
+ const RUNTIME_UNREACHABLE_KEY = "resident:runtimeUnreachable";
1321
+
1322
+ /** The row under RUNTIME_UNREACHABLE_KEY: the count and both instants, so the
1323
+ * fleet watch sees how long the runtime has been silent. Cleared by any exec
1324
+ * whose process spawned. */
1325
+ interface RuntimeUnreachableRow {
1326
+ count: number;
1327
+ firstAt: string;
1328
+ lastAt: string;
1329
+ }
1272
1330
 
1273
1331
  /** Thread bindings live under their own prefix, keyed by threadKey. */
1274
1332
  const THREAD_KEY_PREFIX = "thread:";
@@ -1692,7 +1750,7 @@ export class ResidentDO extends Sandbox<Env> {
1692
1750
  private async run(
1693
1751
  argv: readonly string[],
1694
1752
  opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> } = {},
1695
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
1753
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
1696
1754
  const timeout = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
1697
1755
  const launch = {
1698
1756
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
@@ -1710,7 +1768,17 @@ export class ResidentDO extends Sandbox<Env> {
1710
1768
  try {
1711
1769
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
1712
1770
  } catch (err) {
1713
- if (!isRuntimeReplacement(err)) throw err;
1771
+ if (!isRuntimeReplacement(err)) {
1772
+ // The control port never answered the SDK's connect (its 30 s abort,
1773
+ // raised inside the wake path): no process started and nothing about
1774
+ // the repository is known. Count it in storage — the ladder of item 64
1775
+ // reads the count — and name it, so no reason ever carries the bare
1776
+ // `The operation was aborted`.
1777
+ if (isRuntimeUnreachable(err)) {
1778
+ throw new RuntimeUnreachableError((await this.noteRuntimeUnreachable()).count, err);
1779
+ }
1780
+ throw err;
1781
+ }
1714
1782
  // Forward-looking gate, structurally unreachable today: in the pinned SDK
1715
1783
  // (@cloudflare/sandbox@0.13.0-next.751.1) every `reason:"runtime_replaced"`
1716
1784
  // site hardcodes `retryable:false`, so a replacement currently always
@@ -1725,9 +1793,20 @@ export class ResidentDO extends Sandbox<Env> {
1725
1793
  );
1726
1794
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
1727
1795
  }
1796
+ // The spawn is the proof the control port answers: a persisted count of
1797
+ // unanswered connects ends here, whatever the command goes on to do.
1798
+ await this.clearRuntimeUnreachable();
1728
1799
  try {
1729
1800
  const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
1730
- return { stdout: out.stdout, stderr: out.stderr, exitCode: out.exitCode, timedOut: out.timedOut };
1801
+ // `truncated` is the SDK saying the process log stream was cut past its
1802
+ // own retention — the output here is a prefix, whatever our caps say.
1803
+ return {
1804
+ stdout: out.stdout,
1805
+ stderr: out.stderr,
1806
+ exitCode: out.exitCode,
1807
+ timedOut: out.timedOut,
1808
+ truncated: out.truncated,
1809
+ };
1731
1810
  } catch (err) {
1732
1811
  if (isRuntimeReplacement(err)) {
1733
1812
  this.swapIncarnation(); // the container this incarnation's memos described is gone
@@ -2393,10 +2472,20 @@ export class ResidentDO extends Sandbox<Env> {
2393
2472
  [DEADLINE_AT_KEY]: systemClock() + provisioningTimeoutMs,
2394
2473
  });
2395
2474
  await this.ctx.storage.delete([FACTS_KEY, SNAPSHOT_KEY]); // defensive: no stale facts from a past life
2396
- this.deleteSchedules(PROVISIONING_CALLBACK);
2397
- this.deleteSchedules(PROVISION_RUN_CALLBACK);
2398
- await this.schedule(Math.max(1, Math.ceil(provisioningTimeoutMs / 1000)), PROVISIONING_CALLBACK, resource);
2399
- await this.schedule(1, PROVISION_RUN_CALLBACK, resource);
2475
+ try {
2476
+ this.deleteSchedules(PROVISIONING_CALLBACK);
2477
+ this.deleteSchedules(PROVISION_RUN_CALLBACK);
2478
+ await this.schedule(Math.max(1, Math.ceil(provisioningTimeoutMs / 1000)), PROVISIONING_CALLBACK, resource);
2479
+ await this.schedule(1, PROVISION_RUN_CALLBACK, resource);
2480
+ } catch (err) {
2481
+ // Nothing armed, so nothing may say `onboarding`: the row goes back to
2482
+ // what it was, the way the onboard route frees the registry slot. An
2483
+ // `onboarding` with no schedule behind it used to sit until the
2484
+ // watchdog's provision-timeout (seen live after an offboard in the same
2485
+ // isolate).
2486
+ await this.ctx.storage.delete([RESOURCE_KEY, STATE_KEY, REASON_KEY, UPDATED_KEY, DEADLINE_AT_KEY]);
2487
+ throw err;
2488
+ }
2400
2489
  return { state: "onboarding", reason: "" };
2401
2490
  }
2402
2491
 
@@ -2997,6 +3086,15 @@ export class ResidentDO extends Sandbox<Env> {
2997
3086
  * class: `disk-full: …`, never serviceable, and the one failure the
2998
3087
  * resident can act on itself (recoverFromDiskFull). */
2999
3088
  private async classifyCycleError(err: unknown): Promise<RefreshFailure> {
3089
+ // The control port never answered (item 64): the count decides, and a disk
3090
+ // probe would only cost another 30 s abort against the same silent port.
3091
+ if (err instanceof RuntimeUnreachableError) {
3092
+ return classifyRefreshFailure({
3093
+ step: "refresh",
3094
+ message: err.message,
3095
+ runtimeUnreachable: { count: err.count },
3096
+ });
3097
+ }
3000
3098
  return err instanceof StepError
3001
3099
  ? await this.classifyFailure(err.step, err.message)
3002
3100
  : await this.classifyFailure("refresh", errMsg(err));
@@ -3018,6 +3116,145 @@ export class ResidentDO extends Sandbox<Env> {
3018
3116
  if (failure.diskFull) await this.recoverFromDiskFull(failure.reason, selfInFlight);
3019
3117
  }
3020
3118
 
3119
+ // -- the runtime that never answers (docs/reference/specs/resident-repos.md item 64) ------
3120
+ //
3121
+ // Every `sandbox.exec` of the incident this section names rejected after
3122
+ // exactly 30 s with the SDK's connect abort: the WebSocket upgrade to the
3123
+ // container's control port was never answered, for forty minutes, while the
3124
+ // refresh instance recorded `degraded(refresh-failed: The operation was
3125
+ // aborted)` every bucket and nothing escalated — an admin `stop-container`
3126
+ // (a SIGTERM the runtime ignored) did not help either. The recovery is a
3127
+ // ladder over a persisted count of consecutive unanswered connects: re-arm,
3128
+ // stop, destroy and restore from the snapshot, then down with a reason the
3129
+ // watchdog's auto-rebuild strikes apply to. The count lives in storage
3130
+ // because the isolate does not: a Worker deploy or an eviction between
3131
+ // attempts would otherwise restart the ladder at one.
3132
+
3133
+ /** The persisted count of consecutive connects the control port did not
3134
+ * answer, or null while it answers. */
3135
+ private async runtimeUnreachableRow(): Promise<RuntimeUnreachableRow | null> {
3136
+ return (await this.ctx.storage.get<RuntimeUnreachableRow>(RUNTIME_UNREACHABLE_KEY)) ?? null;
3137
+ }
3138
+
3139
+ /** One more unanswered connect: the count up by one, `firstAt` kept, `lastAt`
3140
+ * now — and one log line naming the attempt (the SDK's own line is the bare
3141
+ * AbortError with a stack). */
3142
+ private async noteRuntimeUnreachable(): Promise<RuntimeUnreachableRow> {
3143
+ const prev = await this.runtimeUnreachableRow();
3144
+ const now = new Date(systemClock()).toISOString();
3145
+ const row: RuntimeUnreachableRow = { count: (prev?.count ?? 0) + 1, firstAt: prev?.firstAt ?? now, lastAt: now };
3146
+ await this.ctx.storage.put(RUNTIME_UNREACHABLE_KEY, row);
3147
+ this.runtimeUnreachableSeen = true;
3148
+ console.log(
3149
+ `runtime-unreachable: the control port did not answer within ${SDK_CONNECT_TIMEOUT_MS / 1000} s — attempt ${row.count} of ${RUNTIME_UNREACHABLE_DOWN_AT} (first at ${row.firstAt})`,
3150
+ );
3151
+ return row;
3152
+ }
3153
+
3154
+ /** Whether a row may exist, so the hot path pays one storage read per
3155
+ * isolate and a delete only for a row that is there. Storage stays the
3156
+ * truth; this only says whether it is worth asking. */
3157
+ private runtimeUnreachableSeen: boolean | undefined;
3158
+
3159
+ /** A spawned process is the proof the control port answers: the row goes,
3160
+ * and the log says the silence ended. */
3161
+ private async clearRuntimeUnreachable(): Promise<void> {
3162
+ if (this.runtimeUnreachableSeen === undefined) {
3163
+ this.runtimeUnreachableSeen = (await this.runtimeUnreachableRow()) !== null;
3164
+ }
3165
+ if (!this.runtimeUnreachableSeen) return;
3166
+ const row = await this.runtimeUnreachableRow();
3167
+ await this.ctx.storage.delete(RUNTIME_UNREACHABLE_KEY);
3168
+ this.runtimeUnreachableSeen = false;
3169
+ if (row) {
3170
+ console.log(
3171
+ `runtime-unreachable: cleared — the control port answered again after ${row.count} unanswered attempt(s) since ${row.firstAt}`,
3172
+ );
3173
+ }
3174
+ }
3175
+
3176
+ /** The ladder (`runtimeUnreachableRung`) over the count `run()` persisted,
3177
+ * applied where a refresh step's exec found the port silent. Every rung
3178
+ * records its reason (`lastRefreshError` and the state) and logs one line
3179
+ * naming the rung and the count; the first three then throw the step back
3180
+ * to the engine, whose retry re-enters the same idempotent method thirty
3181
+ * seconds on, doubling — within one instance's six attempts the ladder runs
3182
+ * from the first unanswered connect to `down`, and a count that outlives the
3183
+ * instance carries into the next bucket's. The state is `degraded` under
3184
+ * every rung but the last: a `restoring` marker with no restore running
3185
+ * would hold the cron's instance creation off until the stale bound, so the
3186
+ * retry's wake path flips `restoring` itself when it starts the restore. */
3187
+ private async escalateRuntimeUnreachable(
3188
+ instance: string,
3189
+ step: string,
3190
+ err: RuntimeUnreachableError,
3191
+ ): Promise<{ status: "failed"; reason: string }> {
3192
+ const rung = runtimeUnreachableRung(err.count);
3193
+ const reason = runtimeUnreachableReason(err.count, rung);
3194
+ console.log(
3195
+ `refresh instance ${instance}: ${step} runtime-unreachable — rung ${rung} at attempt ${err.count} of ${RUNTIME_UNREACHABLE_DOWN_AT}`,
3196
+ );
3197
+ await this.recordRefreshError(reason);
3198
+ switch (rung) {
3199
+ case "re-arm":
3200
+ await this.setResidentState("degraded", reason);
3201
+ throw err;
3202
+ case "stop":
3203
+ // SIGTERM (`stop()` signals and returns; it cannot kill), the
3204
+ // incarnation swapped: a runtime that still honours signals restarts
3205
+ // under the retry's exec on a fresh disk, and the wake path restores.
3206
+ this.swapIncarnation(); // deliberate incarnation swap
3207
+ await this.stop().catch((stopErr) => console.log(`runtime-unreachable: stop failed: ${errMsg(stopErr)}`));
3208
+ await this.setResidentState("degraded", reason);
3209
+ throw err;
3210
+ case "recreate":
3211
+ await this.recreateContainer(reason);
3212
+ throw err;
3213
+ case "down":
3214
+ // A fresh VM did not answer either. Down with a strike-eligible reason
3215
+ // (REHYDRATION_FAILURE_RE) — the watchdog rebuilds after its passes —
3216
+ // and the VM destroyed, so the rebuild's provisioning starts on a new one.
3217
+ this.swapIncarnation(); // deliberate incarnation swap
3218
+ await this.destroy().catch((destroyErr) =>
3219
+ console.log(`runtime-unreachable: destroy failed: ${errMsg(destroyErr)}`),
3220
+ );
3221
+ await this.clearInstanceLease(instance);
3222
+ return { status: "failed", reason: (await this.goDown(reason)).reason };
3223
+ }
3224
+ }
3225
+
3226
+ /** Destroy the VM and keep everything else: the snapshots, the entry
3227
+ * backups, the registry record, the bindings. `destroy()` is the SDK's
3228
+ * SIGKILL of the whole container (`ctx.container.destroy()`), where `stop()`
3229
+ * is a SIGTERM the runtime may ignore — a control server that no longer
3230
+ * answers its port may not answer signals either, which is what the
3231
+ * incident's admin `stop-container` showed. The disk goes with the VM; the
3232
+ * next exec's wake path finds no runtime, flips `restoring` and restores
3233
+ * mirror, checkout and deps from R2 — the cheap recovery (minutes), where a
3234
+ * rebuild (destroy plus reprovision from the code host) is the expensive
3235
+ * one. The state stays `degraded` with the reason naming the pending
3236
+ * restore, for the reason `escalateRuntimeUnreachable` gives. */
3237
+ private async recreateContainer(reason: string): Promise<void> {
3238
+ console.log(
3239
+ `runtime-unreachable: destroying the container — snapshots kept; the next exec restores from R2 (${reason.slice(0, 200)})`,
3240
+ );
3241
+ this.swapIncarnation(); // deliberate incarnation swap
3242
+ await this.forgetRuntimeIdentity();
3243
+ await this.destroy().catch((err) => console.log(`runtime-unreachable: destroy failed: ${errMsg(err)}`));
3244
+ await this.setResidentState("degraded", reason);
3245
+ }
3246
+
3247
+ /** Forget the SDK's stored runtime identity (`SDK_RUNTIME_RECORD_KEY`)
3248
+ * before a destroy. The SDK's own `stop()` and `destroy()` delete it
3249
+ * (`invalidate`) — this is the guard for the path where they do not get
3250
+ * that far, and it makes the destroy prompt: with no identity stored the
3251
+ * SDK skips the runtime cleanup it would otherwise attempt against the
3252
+ * silent port. Never a recovery on its own: the incident's `stop-container`
3253
+ * had already deleted the record and the next connect aborted the same way. */
3254
+ private async forgetRuntimeIdentity(): Promise<void> {
3255
+ await this.ctx.storage.delete(SDK_RUNTIME_RECORD_KEY);
3256
+ }
3257
+
3021
3258
  // -- the refresh cycle as a Workflow instance (item 7) --------------------------
3022
3259
  //
3023
3260
  // `ResidentRefresh` (the Workflow entrypoint, refresh.ts) calls these
@@ -3213,6 +3450,20 @@ export class ResidentDO extends Sandbox<Env> {
3213
3450
  console.log(`refresh instance ${instance}: ${step} ${err.message}`);
3214
3451
  throw err;
3215
3452
  }
3453
+ if (err instanceof RuntimeUnreachableError) {
3454
+ // The control port never answered (item 64): the ladder decides — the
3455
+ // step is thrown back for the engine's retry under the first three
3456
+ // rungs, or the resident is down under the last.
3457
+ let result: { status: "failed"; reason: string };
3458
+ try {
3459
+ result = await this.escalateRuntimeUnreachable(instance, step, err);
3460
+ } catch (rethrown) {
3461
+ outcome = `runtime-unreachable (attempt ${err.count} of ${RUNTIME_UNREACHABLE_DOWN_AT}, rung ${runtimeUnreachableRung(err.count)}) — the engine retries`;
3462
+ throw rethrown;
3463
+ }
3464
+ outcome = `failed (${result.reason})`;
3465
+ return { ...result, startedAt, trace: trace.steps() };
3466
+ }
3216
3467
  const failure = await this.classifyCycleError(err);
3217
3468
  if (failure.interrupted) {
3218
3469
  outcome = `interrupted (${failure.reason}) — the engine retries`;
@@ -3409,6 +3660,10 @@ export class ResidentDO extends Sandbox<Env> {
3409
3660
  }): Promise<InstanceStepAnswer<{ result: { evicted: string[]; kept: number } | null; error: string | null }>> {
3410
3661
  return this.runInstanceStep(input.instance, "sweep", async (cycle) => {
3411
3662
  cycle.count();
3663
+ // A runtime that does not answer has nothing to sweep, and every probe
3664
+ // would cost the SDK's 30 s abort (item 64); the fetch step already
3665
+ // recorded the verdict this instance.
3666
+ if (await this.runtimeUnreachableRow()) return { status: "stopped", why: "runtime-unreachable" };
3412
3667
  return this.housekeeping("sweep", () => this.sweepWorktrees(input.resource));
3413
3668
  });
3414
3669
  }
@@ -3422,6 +3677,8 @@ export class ResidentDO extends Sandbox<Env> {
3422
3677
  }): Promise<InstanceStepAnswer<{ result: { measured: boolean } | null; error: string | null }>> {
3423
3678
  return this.runInstanceStep(input.instance, "measure", async (cycle) => {
3424
3679
  cycle.count();
3680
+ // Same gate as the sweep: a silent control port cannot answer a `df`.
3681
+ if (await this.runtimeUnreachableRow()) return { status: "stopped", why: "runtime-unreachable" };
3425
3682
  return this.housekeeping("measure", async () => ({ measured: (await this.measureDisk()) !== null }));
3426
3683
  });
3427
3684
  }
@@ -3931,8 +4188,13 @@ export class ResidentDO extends Sandbox<Env> {
3931
4188
  timeoutMs: number,
3932
4189
  capBytes?: number,
3933
4190
  capFiles?: { out: string; err: string },
3934
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
3935
- const injected = { GIT_TERMINAL_PROMPT: "0" };
4191
+ env?: Record<string, string>,
4192
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
4193
+ // The caller's variables (an /exec body's `env`, docs/reference/specs/
4194
+ // harness-pi.md item 4) under the Worker's own: a caller never overrides
4195
+ // what the Worker injects. `su` without `-` keeps this environment for the
4196
+ // thread user's shell.
4197
+ const injected = { ...(env ?? {}), GIT_TERMINAL_PROMPT: "0" };
3936
4198
  validateEnvNames(injected);
3937
4199
  const body = capBytes
3938
4200
  ? capWrappedCommand(worktreePath, command, capBytes, capFiles)
@@ -3954,10 +4216,11 @@ export class ResidentDO extends Sandbox<Env> {
3954
4216
  command: string,
3955
4217
  timeoutMs: number,
3956
4218
  charCap: number,
3957
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
4219
+ env?: Record<string, string>,
4220
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
3958
4221
  const capBytes = capBytesFor(charCap);
3959
4222
  const files = execCapFiles();
3960
- const r = await this.threadRun(user, worktreePath, command, timeoutMs, capBytes, files);
4223
+ const r = await this.threadRun(user, worktreePath, command, timeoutMs, capBytes, files, env);
3961
4224
  if (!r.timedOut) return r;
3962
4225
  try {
3963
4226
  const rec = await this.threadRun(
@@ -5119,12 +5382,13 @@ export class ResidentDO extends Sandbox<Env> {
5119
5382
  command: string,
5120
5383
  timeoutMs: number,
5121
5384
  traceparent?: string,
5385
+ env?: Record<string, string>,
5122
5386
  ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
5123
5387
  const queuedAt = systemClock();
5124
5388
  let startedAt = queuedAt;
5125
5389
  const res = await this.withThreadBusy(threadKey, () => {
5126
5390
  startedAt = systemClock();
5127
- return this.execThreadImpl(threadKey, command, timeoutMs);
5391
+ return this.execThreadImpl(threadKey, command, timeoutMs, env);
5128
5392
  });
5129
5393
  // The command as the resident's own `resident.exec` root (docs/reference/specs/tracing.md
5130
5394
  // item 22): started when the command did, the wait for the thread's turn an attr.
@@ -5139,6 +5403,7 @@ export class ResidentDO extends Sandbox<Env> {
5139
5403
  threadKey: string,
5140
5404
  command: string,
5141
5405
  timeoutMs: number,
5406
+ env?: Record<string, string>,
5142
5407
  ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
5143
5408
  const pre = await this.threadPreflight(threadKey);
5144
5409
  if ("error" in pre) return pre;
@@ -5156,12 +5421,12 @@ export class ResidentDO extends Sandbox<Env> {
5156
5421
 
5157
5422
  let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
5158
5423
  try {
5159
- r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP);
5424
+ r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
5160
5425
  } catch (err) {
5161
5426
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5162
5427
  throw err;
5163
5428
  }
5164
- const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
5429
+ const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
5165
5430
  const notes: string[] = [];
5166
5431
  if (r.timedOut)
5167
5432
  notes.push(
@@ -5201,31 +5466,68 @@ export class ResidentDO extends Sandbox<Env> {
5201
5466
  const resolved = confineThreadPath(pre.binding.worktreePath, path);
5202
5467
  if (!resolved)
5203
5468
  return { error: `path-escape: ${JSON.stringify(path)} does not stay inside the thread worktree`, status: 400 };
5204
- const cap = encoding === "base64" ? MAX_READ_BASE64_CHARS : READ_CONTENT_CAP;
5469
+ if (encoding === "base64") return this.readThreadBytes(pre.binding, resolved);
5205
5470
  let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
5206
5471
  try {
5207
5472
  r = await this.threadRun(
5208
5473
  pre.binding.user,
5209
5474
  pre.binding.worktreePath,
5210
- readCommandFor(encoding, resolved),
5475
+ readCommandFor(resolved),
5211
5476
  DEFAULT_EXEC_TIMEOUT_MS,
5212
- capBytesFor(cap),
5477
+ capBytesFor(READ_CONTENT_CAP),
5213
5478
  );
5214
5479
  } catch (err) {
5215
5480
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5216
5481
  throw err;
5217
5482
  }
5218
5483
  if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
5219
- const truncated = r.stdout.length > cap;
5220
- if (encoding === "base64") {
5221
- // Padding hides up to two bytes inside the cap's char count, so the
5222
- // decoded size is checked too — the cap is bytes, not characters.
5223
- const content = r.stdout.trimEnd();
5224
- return truncated || base64ByteLength(content) > MAX_READ_BYTES
5225
- ? { encoding, tooLarge: true }
5226
- : { encoding, content };
5484
+ const truncated = r.stdout.length > READ_CONTENT_CAP || r.truncated === true;
5485
+ return { content: truncated ? r.stdout.slice(0, READ_CONTENT_CAP) : r.stdout, truncated };
5486
+ }
5487
+
5488
+ /** The bytes of a confined file as base64, as the thread user, in chunks
5489
+ * (src/execution/binaryRead.ts): one command's stdout crosses the SDK's
5490
+ * process log stream, which cuts a stream past a retention limit far below
5491
+ * the binary cap and says so only through `truncated` — a 12 MB file once
5492
+ * came back as 1.7 MB and was handed on as complete. So: `stat` first (the
5493
+ * cap is judged on the size, before any read), then chunks small enough
5494
+ * that no stream is ever cut; a chunk the SDK still flags, or one whose
5495
+ * length is not what the size promised, fails the read by name. The answer
5496
+ * carries the size for the client to check the decoded bytes against. */
5497
+ private async readThreadBytes(
5498
+ binding: { user: string; worktreePath: string },
5499
+ resolved: string,
5500
+ ): Promise<Base64ReadAnswer | ThreadErr> {
5501
+ const run = (command: string, capChars: number) =>
5502
+ this.threadRun(binding.user, binding.worktreePath, command, DEFAULT_EXEC_TIMEOUT_MS, capBytesFor(capChars));
5503
+ try {
5504
+ const stat = await run(statCommandFor(resolved), 64);
5505
+ if (stat.exitCode !== 0 || stat.timedOut)
5506
+ return { error: `read-failed: ${describeStepFailure(stat)}`, status: 404 };
5507
+ const size = parseByteSize(stat.stdout);
5508
+ if (size === null)
5509
+ return { error: `read-failed: stat answered ${JSON.stringify(stat.stdout.slice(0, 64))}`, status: 500 };
5510
+ if (size > MAX_READ_BYTES) return { encoding: "base64", tooLarge: true };
5511
+ const chunks = chunkPlan(size);
5512
+ const parts: string[] = [];
5513
+ for (const [i, chunk] of chunks.entries()) {
5514
+ const expected = base64LengthOf(chunk.length);
5515
+ const r = await run(readChunkCommandFor(resolved, chunk), expected);
5516
+ if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
5517
+ const piece = r.stdout.trimEnd();
5518
+ if (r.truncated === true || piece.length !== expected) {
5519
+ return {
5520
+ error: `read-inconsistent: chunk ${i + 1} of ${chunks.length} arrived as ${piece.length} of ${expected} base64 chars${r.truncated ? " (the SDK cut the output stream)" : ""}`,
5521
+ status: 409,
5522
+ };
5523
+ }
5524
+ parts.push(piece);
5525
+ }
5526
+ return { encoding: "base64", content: parts.join(""), size };
5527
+ } catch (err) {
5528
+ if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5529
+ throw err;
5227
5530
  }
5228
- return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
5229
5531
  }
5230
5532
 
5231
5533
  /** POST /write: content travels via the SDK file API into the thread's
@@ -5652,7 +5954,7 @@ export class ResidentDO extends Sandbox<Env> {
5652
5954
  timedOut: r.timedOut,
5653
5955
  });
5654
5956
  const ok = r.exitCode === 0 && !r.timedOut;
5655
- const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
5957
+ const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
5656
5958
  const notes: string[] = [];
5657
5959
  if (r.timedOut) notes.push(`command timed out after ${OP_EXEC_TIMEOUT_MS}ms`);
5658
5960
  if (truncated) notes.push(`output truncated to ${EXEC_OUTPUT_CAP} chars per stream`);
@@ -5963,10 +6265,12 @@ export class ResidentDO extends Sandbox<Env> {
5963
6265
  inFlightKey("hydration"),
5964
6266
  LIFECYCLE_KEY,
5965
6267
  REFRESH_INSTANCE_KEY,
6268
+ RUNTIME_UNREACHABLE_KEY,
5966
6269
  ]);
5967
6270
  const facts = map.get(FACTS_KEY) as RepoFacts | undefined;
5968
6271
  const snap = map.get(SNAPSHOT_KEY) as SnapshotRecord | undefined;
5969
6272
  const disk = (map.get(DISK_KEY) as DiskSample | undefined) ?? null;
6273
+ const unreachable = (map.get(RUNTIME_UNREACHABLE_KEY) as RuntimeUnreachableRow | undefined) ?? null;
5970
6274
  const refreshRow = (map.get(REFRESH_INSTANCE_KEY) as RefreshInstanceRow | undefined) ?? {
5971
6275
  instance: null,
5972
6276
  skipped: null,
@@ -6049,6 +6353,9 @@ export class ResidentDO extends Sandbox<Env> {
6049
6353
  // Item 55: the last disk sample (`residentDiskBudget.ts` DiskSample), or
6050
6354
  // null before the first measurement of this incarnation.
6051
6355
  disk,
6356
+ // Item 64: consecutive connects the control port did not answer, with
6357
+ // the rung that count is on; null while the port answers.
6358
+ runtimeUnreachable: unreachable ? { ...unreachable, rung: runtimeUnreachableRung(unreachable.count) } : null,
6052
6359
  };
6053
6360
  }
6054
6361
 
@@ -6087,6 +6394,50 @@ export class ResidentDO extends Sandbox<Env> {
6087
6394
  }
6088
6395
  }
6089
6396
 
6397
+ /** Admin `recreate-container` (item 13; item 64's rung 3 on demand): destroy
6398
+ * the VM, keep every snapshot, and start the restore now — the operator's
6399
+ * recovery for a resident whose runtime never answers, minutes where the
6400
+ * rebuild is half an hour. Refused while the engine owns the state
6401
+ * (onboarding/refreshing/restoring — two cycles must never race one disk)
6402
+ * and on a `down` resident, whose one exit is `/rebuild`. The restore runs
6403
+ * in the background through the ordinary wake path (`ensureHydrated`:
6404
+ * `restoring`, mirror, checkout, deps, `warm`, `lastRestore`); the caller
6405
+ * polls `/debug info`. A failure the wake path did not record itself is
6406
+ * recorded here, so the marker never strands `restoring`. */
6407
+ async debugRecreateContainer(): Promise<
6408
+ { recreated: true; restoreStartedAt: string } | { recreated: false; error: string; status: number }
6409
+ > {
6410
+ const from = await this.getStatus();
6411
+ if (from.state === "onboarding" || from.state === "refreshing" || from.state === "restoring") {
6412
+ return {
6413
+ recreated: false,
6414
+ status: 409,
6415
+ error: `recreate-refused: the engine is mid-flight (state ${from.state}) — retry once it settles (warm/degraded)`,
6416
+ };
6417
+ }
6418
+ if (from.state === "down") {
6419
+ return {
6420
+ recreated: false,
6421
+ status: 409,
6422
+ error: `recreate-refused: the resident is down (${from.reason}) — POST /rebuild is its exit`,
6423
+ };
6424
+ }
6425
+ await this.recreateContainer(
6426
+ "runtime-unreachable: the container was recreated by an operator (recreate-container), snapshots kept — the restore from the snapshot is starting",
6427
+ );
6428
+ const restoreStartedAt = new Date(systemClock()).toISOString();
6429
+ this.ctx.waitUntil(
6430
+ this.ensureHydrated().catch(async (err) => {
6431
+ if (err instanceof ResidentDownError) return; // the wake path recorded it
6432
+ const failure = await this.classifyCycleError(err);
6433
+ console.log(`recreate-container: the restore failed — ${failure.reason.slice(0, 400)}`);
6434
+ await this.recordRefreshError(failure.reason);
6435
+ if ((await this.getStatus()).state === "restoring") await this.setResidentState("degraded", failure.reason);
6436
+ }),
6437
+ );
6438
+ return { recreated: true, restoreStartedAt };
6439
+ }
6440
+
6090
6441
  /** Fault injection for the watchdog's auto-rebuild path: persist
6091
6442
  * `down` with a rehydration-flavored reason (what a real goDown does), so
6092
6443
  * repeated watchdog passes can strike it up to the auto-rebuild without
@@ -6174,6 +6525,16 @@ export class ResidentDO extends Sandbox<Env> {
6174
6525
  }
6175
6526
  }
6176
6527
  await this.ctx.storage.delete(REBUILD_STRIKES_KEY);
6528
+ // From scratch means a fresh container too: the SDK's runtime identity
6529
+ // forgotten and the VM destroyed (SIGKILL, a fresh disk) before
6530
+ // provisioning clones onto it. A rebuild that reprovisioned onto the
6531
+ // running container inherited its wedged runtime once — every exec of the
6532
+ // new provisioning met the same unanswered control port (item 64).
6533
+ this.swapIncarnation(); // deliberate incarnation swap
6534
+ await this.forgetRuntimeIdentity();
6535
+ await this.destroy().catch((err) =>
6536
+ console.log(`rebuild: destroy failed (provisioning starts anyway): ${errMsg(err)}`),
6537
+ );
6177
6538
  await this.initResident(resource, provisioningTimeoutMs);
6178
6539
  return { ...plan, backupObjectsDeleted, state: "onboarding" as const };
6179
6540
  }
@@ -6245,10 +6606,17 @@ export class ResidentDO extends Sandbox<Env> {
6245
6606
  errors.push(`destroy failed: ${errMsg(err)}`);
6246
6607
  }
6247
6608
  // Retired DO: clear the alarm the Container base may have armed for its
6248
- // schedules, then wipe storage so nothing ever wakes this object again.
6609
+ // schedules, then delete every stored key — ours and the SDK's (its runtime
6610
+ // identity among them) — so nothing ever wakes this object again. Keys, not
6611
+ // `deleteAll()`: on a SQLite-backed object that also drops the SDK's
6612
+ // `container_schedules` table, which only its constructor creates, and an
6613
+ // onboard served by this same isolate then fails arming with
6614
+ // `no such table` after writing `onboarding` (seen live). The table stays,
6615
+ // empty — its rows are the two schedules cancelled above.
6249
6616
  this.swapIncarnation(); // retired object, retired memos
6250
6617
  await this.ctx.storage.deleteAlarm();
6251
- await this.ctx.storage.deleteAll();
6618
+ const keys = [...(await this.ctx.storage.list()).keys()];
6619
+ for (let i = 0; i < keys.length; i += 128) await this.ctx.storage.delete(keys.slice(i, i + 128));
6252
6620
  return { schedulesCancelled: true, containerStopped, storageCleared: true, backupObjectsDeleted, errors };
6253
6621
  }
6254
6622
  }
@@ -6687,7 +7055,10 @@ async function handleOnboard(env: Env, body: Record<string, unknown>): Promise<R
6687
7055
  {
6688
7056
  error:
6689
7057
  `not-in-installation: the GitHub App cannot mint a token scoped to ${resource.resource} — ` +
6690
- `install the App on the repository first (${errMsg(err)})`,
7058
+ `the repository is not in the App installation's repository list, or does not exist under that ` +
7059
+ `exact name (GitHub's token API answers the same 422 for both). An org admin adds it under the ` +
7060
+ `App's installation settings (Settings → GitHub Apps → Configure → Repository access), ` +
7061
+ `then retry (${errMsg(err)})`,
6691
7062
  },
6692
7063
  403,
6693
7064
  );
@@ -7135,7 +7506,12 @@ async function handleExec(env: Env, body: Record<string, unknown>, traceparent?:
7135
7506
  // a string) runs at the 5-minute default. A clamp, not a 400: an out-of-range
7136
7507
  // ask still runs, at the nearest bound.
7137
7508
  const timeoutMs = clampBashTimeout(body.timeoutMs);
7138
- return streamThreadExec(ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent));
7509
+ // A caller's extra environment for this one command (docs/reference/specs/
7510
+ // harness-pi.md item 4) — the run bearer the pi harness hands its process —
7511
+ // read from the body alone through the one validated reader the sandbox
7512
+ // Worker uses, and handed to the exec's env option, never onto the command.
7513
+ const execEnv = envFromRequest({ body });
7514
+ return streamThreadExec(ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv));
7139
7515
  }
7140
7516
 
7141
7517
  /** Stream one pending result with the thread-sandbox Worker's heartbeat
@@ -7375,6 +7751,13 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
7375
7751
  });
7376
7752
  case "stop-container":
7377
7753
  return json(await stub.debugStopContainer());
7754
+ case "recreate-container": {
7755
+ // Item 64's rung 3 on demand: destroy the VM, keep the snapshots, start
7756
+ // the restore now. `in` narrowing, as handleRebuild (the RPC stub's
7757
+ // Disposable intersection defeats the boolean discriminant).
7758
+ const r = await stub.debugRecreateContainer();
7759
+ return "error" in r ? json({ error: r.error }, r.status) : json(r, 202);
7760
+ }
7378
7761
  case "force-onboarding":
7379
7762
  return json(await stub.debugForceOnboarding());
7380
7763
  case "force-down": {
@@ -7420,7 +7803,7 @@ async function handleDebug(env: Env, body: Record<string, unknown>): Promise<Res
7420
7803
  default:
7421
7804
  return json(
7422
7805
  {
7423
- error: `unknown op ${JSON.stringify(op)} (ops: info, schedules, refresh-now, stop-container, force-onboarding, force-down, mint-token, run-watchdog, set-test-overrides, threads, sweep-now, reclaim-now, measure-disk, purge-bindings, backdate-thread, lifecycle)`,
7806
+ error: `unknown op ${JSON.stringify(op)} (ops: info, schedules, refresh-now, stop-container, recreate-container, force-onboarding, force-down, mint-token, run-watchdog, set-test-overrides, threads, sweep-now, reclaim-now, measure-disk, purge-bindings, backdate-thread, lifecycle)`,
7424
7807
  },
7425
7808
  400,
7426
7809
  );