@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.
- package/README.md +5 -3
- package/dist/assets/config/config.example.yaml +44 -0
- package/dist/assets/deploy/cloudflare/worker.ts +6 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +24 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +421 -38
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +9 -1
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +18 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +19 -5
- package/dist/assets/deploy/secrets.manifest.json +18 -0
- package/dist/assets/package-lock.json +5 -3
- package/dist/assets/package.json +2 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +50 -7
- package/dist/assets/src/core/redact.ts +11 -1
- package/dist/assets/src/core/runEvents.ts +60 -6
- package/dist/assets/src/core/runFriction.ts +6 -4
- package/dist/assets/src/core/trace/workerTrace.ts +4 -0
- package/dist/assets/src/execution/binaryRead.ts +56 -14
- package/dist/assets/src/execution/residentRefresh.ts +151 -5
- package/dist/assets/src/execution/residentStepReport.ts +4 -0
- package/dist/assets/web/dist/.vite/manifest.json +19 -19
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CM5nWw-Z.js → ResidentDetailPage-CvwjhjlG.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BKBnuvp3.js → ResidentsIndexPage-DoN6XuNx.js} +1 -1
- package/dist/assets/web/dist/assets/RunRoutePage-FzLNexjF.js +12 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-w2jJP5xu.js → RunsIndexPage-DhSUbF9k.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-D3-k9DPz.js → ScheduledPage-Bq0Yg4nT.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BmFHnV8m.js → StatusDot-D8Wwt1KC.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DoThP2fW.js → Tooltip-DpDK7jWZ.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-YrRKtxsS.js → dist-B7BVkB7x.js} +1 -1
- package/dist/assets/web/dist/assets/{main-D6nzMf0k.js → main-BFkEOy3K.js} +2 -2
- package/dist/assets/web/dist/assets/main-DCH3Mezs.css +1 -0
- package/dist/cli.js +2704 -603
- package/package.json +2 -1
- package/dist/assets/web/dist/assets/RunRoutePage-Bu4CwEzt.js +0 -12
- 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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
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
|
-
|
|
3935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
5475
|
+
readCommandFor(resolved),
|
|
5211
5476
|
DEFAULT_EXEC_TIMEOUT_MS,
|
|
5212
|
-
capBytesFor(
|
|
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 >
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
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
|
|
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.
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
);
|