@coreplane/switchboard 1.234.0 → 1.235.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 (28) hide show
  1. package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +125 -78
  3. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +82 -13
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/source.json +3 -3
  7. package/dist/assets/src/core/runEvents.ts +7 -0
  8. package/dist/assets/src/execution/residentCleanliness.ts +24 -0
  9. package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
  10. package/dist/assets/src/execution/sandboxErrors.ts +5 -0
  11. package/dist/assets/src/execution/sandboxIdle.ts +220 -0
  12. package/dist/assets/web/dist/.vite/manifest.json +26 -25
  13. package/dist/assets/web/dist/assets/{ResidentDetailPage-C_WNBUao.js → ResidentDetailPage-DSpsye5e.js} +1 -1
  14. package/dist/assets/web/dist/assets/{ResidentsIndexPage-D1sTyaZ_.js → ResidentsIndexPage-CT8fI1M3.js} +1 -1
  15. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +9 -0
  16. package/dist/assets/web/dist/assets/RunRoutePage-oN9GkVEA.js +6 -0
  17. package/dist/assets/web/dist/assets/{RunsIndexPage-H6GkSv0a.js → RunsIndexPage-sjXr8iCV.js} +1 -1
  18. package/dist/assets/web/dist/assets/{ScheduledPage-BLcPzm1S.js → ScheduledPage-Tz__zLzi.js} +1 -1
  19. package/dist/assets/web/dist/assets/{StatusDot-Ct6AtHhS.js → StatusDot-D14iJMy7.js} +1 -1
  20. package/dist/assets/web/dist/assets/{Tooltip-qol5Boof.js → Tooltip-B0Ob5MQ4.js} +1 -1
  21. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +1 -0
  22. package/dist/assets/web/dist/assets/{dist-DwtJj-ou.js → dist-pOdUzj6D.js} +1 -1
  23. package/dist/assets/web/dist/assets/{main-CRtlGxRm.js → main-D3lG-yQ9.js} +2 -2
  24. package/dist/cli.js +84 -25
  25. package/package.json +1 -1
  26. package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +0 -5
  27. package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +0 -10
  28. package/dist/assets/web/dist/assets/UnitRoutePage-CIhTEwN7.js +0 -1
@@ -1,7 +1,7 @@
1
1
  // Residency garbage collection — the PURE decision logic, kept free of
2
2
  // the Sandbox SDK and DO storage so it runs under plain-Node vitest
3
3
  // (gc.test.ts). worker.ts feeds it what it observed (mirror refs, the GitHub
4
- // pulls list, tree cleanliness, live views) and acts on the answers.
4
+ // pulls list, ops in flight, live views) and acts on the answers.
5
5
  //
6
6
  // Two mechanisms:
7
7
  // 1. Event-triggered reclamation: after every refresh-cycle `fetch --prune`,
@@ -79,9 +79,6 @@ export interface ReclaimInput {
79
79
  isDefaultRef: boolean;
80
80
  /** Thread exec/read/write currently running on this binding. */
81
81
  busy: number;
82
- /** Tree cleanliness as the thread user; `null` = the runtime is down, so
83
- * the tree is already gone with the disk and there is nothing to preserve. */
84
- clean: boolean | null;
85
82
  }
86
83
 
87
84
  /** The PR that decided the fate (for the audit trail): the open one, else the
@@ -97,13 +94,14 @@ export type ReclaimWhy =
97
94
  | "fate-unknown"
98
95
  | "busy"
99
96
  | "re-attached"
100
- | "dirty"
101
97
  | Extract<RefFate, "gone" | "merged" | "closed">;
102
98
 
103
99
  /** Evict this binding now? A finished ref (gone/merged/closed) is reclaimed
104
- * only when nothing runs on it and its tree is provably clean — a merged PR
105
- * can still have unpushed local commits, and destroying work on a signal is
106
- * exactly what this must never do. Keeps are named so the pass is auditable. */
100
+ * when nothing runs on it — and nothing else is asked. What its tree holds
101
+ * is never a reason to keep it: a run starts from a clean tree, so dirt in a
102
+ * tree no run is using protects nothing (the next attach wipes it); the
103
+ * Worker measures and records what it removes instead. Keeps are named so
104
+ * the pass is auditable. */
107
105
  export function reclaimDecision(input: ReclaimInput): { reclaim: boolean; why: ReclaimWhy } {
108
106
  if (input.isDefaultRef) return { reclaim: false, why: "default-ref" };
109
107
  switch (input.fate) {
@@ -115,7 +113,6 @@ export function reclaimDecision(input: ReclaimInput): { reclaim: boolean; why: R
115
113
  return { reclaim: false, why: "fate-unknown" };
116
114
  }
117
115
  if (input.busy > 0) return { reclaim: false, why: "busy" };
118
- if (input.clean === false) return { reclaim: false, why: "dirty" };
119
116
  return { reclaim: true, why: input.fate };
120
117
  }
121
118
 
@@ -116,10 +116,13 @@ import {
116
116
  type ThreadDepsMechanism,
117
117
  } from "../../src/execution/residentDepCache.js";
118
118
  import {
119
- leftBehindOf,
119
+ evictedTreeOf,
120
+ evictedTreeSentence,
120
121
  parseWorktreeCleanliness,
121
122
  worktreeCleanlinessScript,
123
+ type EvictedTree,
122
124
  type LeftBehind,
125
+ type WorktreeCleanliness,
123
126
  } from "../../src/execution/residentCleanliness.js";
124
127
  import {
125
128
  base64LengthOf,
@@ -1023,8 +1026,17 @@ interface ThreadBinding {
1023
1026
  evicted?: boolean;
1024
1027
  evictedAt?: string;
1025
1028
  /** Why the last eviction happened (the audit trail): `ttl`, `clean-idle`,
1026
- * `detach`, or a reclamation fate — `merged #N` / `closed #N` / `gone`. */
1029
+ * `detach`, `disk-pressure`, or a reclamation fate — `merged #N` /
1030
+ * `closed #N` / `gone`. */
1027
1031
  evictedWhy?: string;
1032
+ /** What the tree held when that eviction removed it (item 17: never a
1033
+ * reason to keep the tree, never discarded silently): the tracked changes
1034
+ * and unpushed commits measured as the thread user. Absent when the tree
1035
+ * was clean, already gone with the disk, or not measured (a force detach). */
1036
+ evictedLeftBehind?: LeftBehind;
1037
+ /** Why the tree could not be measured before that eviction (git could not
1038
+ * read it) — so an unreadable tree is never recorded as clean. */
1039
+ evictedUnmeasured?: string;
1028
1040
  /** How deps were last materialized (evidence that the per-branch reconciliation ran). */
1029
1041
  deps?: ThreadDepsMechanism;
1030
1042
  /** Commit the worktree was last attached at (the ref's tip in the mirror
@@ -3786,15 +3798,16 @@ export class ResidentDO extends Sandbox<Env> {
3786
3798
  });
3787
3799
  }
3788
3800
 
3789
- /** Idle = no live binding attached within IDLE_AFTER_S AND (when the
3790
- * runtime is up) no live tree is dirty. Bindings are storage; dirtiness
3791
- * needs the container — if it is already asleep there is nothing to lose. */
3801
+ /** Idle = no live binding attached within IDLE_AFTER_S and nothing in
3802
+ * flight. What a live tree holds is not asked (item 17): a dirty tree no
3803
+ * run is using protects nothing — the next attach wipes it — so it is
3804
+ * never a reason to keep the container awake; the sweep's release records
3805
+ * what it held. Bindings are storage, so this needs no container. */
3792
3806
  private async isIdle(): Promise<boolean> {
3793
3807
  const live = await this.liveBindings();
3794
3808
  const recent = systemClock() - IDLE_AFTER_S * 1000;
3795
3809
  if (live.some((b) => Date.parse(b.lastAttachAt) >= recent)) return false;
3796
- if (this.inFlightCount() > 0) return false;
3797
- return this.liveTreesClean(live);
3810
+ return this.inFlightCount() === 0;
3798
3811
  }
3799
3812
 
3800
3813
  /** Bindings that hold a pool user and a tree on disk (not evicted). */
@@ -3803,14 +3816,13 @@ export class ResidentDO extends Sandbox<Env> {
3803
3816
  return [...all.values()].filter((b) => !b.evicted && b.user);
3804
3817
  }
3805
3818
 
3806
- /** True when no live tree holds work a lost disk would destroy: every one
3807
- * is clean (as its thread user), or the runtime is already down — a sleep
3808
- * has destroyed the disk, so there is nothing left to lose. */
3819
+ /** The disk-full recycle's tree guard (item 54), its one reader: true when
3820
+ * every live tree is clean as its thread user, or the runtime is already
3821
+ * down — a sleep has destroyed the disk, so there is nothing left to lose. */
3809
3822
  private async liveTreesClean(live: ThreadBinding[]): Promise<boolean> {
3810
3823
  if (!(await this.isRuntimeActive().catch(() => false))) return true;
3811
3824
  // Concurrent: each check touches only its own (disjoint) tree, and one
3812
- // spawn each — the idle gate does not pay a serial
3813
- // 3-probe round-trip per live binding.
3825
+ // spawn each — no serial 3-probe round-trip per live binding.
3814
3826
  const checks = await Promise.all(live.map((b) => this.worktreeCleanliness(b)));
3815
3827
  return checks.every((c) => c.clean); // any dirty or unknown → not clean
3816
3828
  }
@@ -3948,10 +3960,11 @@ export class ResidentDO extends Sandbox<Env> {
3948
3960
  * already on disk is `reuse` (0 — a dirty/stale recreate frees the old one
3949
3961
  * first). A ref not yet in the mirror (fetched under the lock, moments later)
3950
3962
  * is projected as `hardlink`, the common case. When it does not fit, the
3951
- * coldest clean idle trees go first (`orderEvictionCandidates`: never the
3963
+ * coldest idle trees go first (`orderEvictionCandidates`: never the
3952
3964
  * requesting thread, a busy tree, the default branch, or one attached within
3953
- * DISK_EVICT_MIN_IDLE_MS; cleanliness checked as the thread user, dirty or
3954
- * unreadable kept — the sweep's rules), `df` re-probed after each; still
3965
+ * DISK_EVICT_MIN_IDLE_MS), whatever they hold — item 17: dirt never keeps a
3966
+ * tree; each is measured as its thread user for the eviction's record —
3967
+ * `df` re-probed after each; still
3955
3968
  * short → `503 {reason:"disk-pressure"}` with the whole math in `error`, the
3956
3969
  * same shape as `mirror-busy`, so the bot falls back cold legibly. Never a
3957
3970
  * lifecycle flip: the checkout is intact and every existing tree keeps
@@ -4021,12 +4034,10 @@ export class ResidentDO extends Sandbox<Env> {
4021
4034
  if (verdict.fits) break;
4022
4035
  const binding = live.find((b) => b.threadKey === c.threadKey);
4023
4036
  if (!binding) continue;
4024
- const clean = await this.worktreeCleanliness(binding);
4025
- if (!clean.clean) {
4026
- kept.push({ threadKey: c.threadKey, why: "dirty" });
4027
- continue;
4028
- }
4029
- // Same re-read guards as the sweep: the clean check awaited.
4037
+ // What the tree holds goes on the eviction's record (item 17), never
4038
+ // into a keep; the runtime is up — this attach is running on it.
4039
+ const tree = await this.measureTreeBeforeEviction(binding);
4040
+ // Same re-read guards as the sweep: the measurement awaited.
4030
4041
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(c.threadKey));
4031
4042
  if (
4032
4043
  !current ||
@@ -4037,7 +4048,7 @@ export class ResidentDO extends Sandbox<Env> {
4037
4048
  kept.push({ threadKey: c.threadKey, why: "busy" });
4038
4049
  continue;
4039
4050
  }
4040
- if (!(await this.evictBinding(current, true, "disk-pressure", DISK_PRESSURE_REASON))) {
4051
+ if (!(await this.evictBinding(current, true, "disk-pressure", DISK_PRESSURE_REASON, tree))) {
4041
4052
  kept.push({ threadKey: c.threadKey, why: "other" });
4042
4053
  continue;
4043
4054
  }
@@ -6022,13 +6033,21 @@ export class ResidentDO extends Sandbox<Env> {
6022
6033
 
6023
6034
  /** Remove a thread's worktree (when the runtime is up — a slept container
6024
6035
  * already lost it) and release its pool user; the binding is KEPT, marked
6025
- * evicted, so the ref stays sticky and the next attach recreates the tree
6026
- * with it. Shared by the inactivity sweep and /detach. */
6036
+ * evicted with the cause (`evictedWhy`) and with what the tree held
6037
+ * (`evictedLeftBehind`, or `evictedUnmeasured` when git could not read it
6038
+ * — item 17: never a reason to keep the tree, never discarded silently), so
6039
+ * the ref stays sticky and the next attach recreates the tree with it.
6040
+ * Shared by the inactivity sweep, the reclamation pass, the disk-pressure
6041
+ * path and /detach. */
6027
6042
  private async evictBinding(
6028
6043
  binding: ThreadBinding,
6029
6044
  runtimeActive: boolean,
6030
6045
  logCtx: string,
6031
6046
  why: string,
6047
+ /** What the caller measured in the tree first (`measureTreeBeforeEviction`,
6048
+ * as the thread user with the runtime up); absent when the tree was clean,
6049
+ * already gone with the disk, or not measured (a force detach). */
6050
+ tree?: EvictedTree,
6032
6051
  ): Promise<boolean> {
6033
6052
  const threadDir = parentDir(binding.worktreePath);
6034
6053
  if (runtimeActive && threadDir.startsWith(`${THREADS_DIR}/`)) {
@@ -6064,7 +6083,10 @@ export class ResidentDO extends Sandbox<Env> {
6064
6083
  evicted: true,
6065
6084
  evictedAt: new Date(systemClock()).toISOString(),
6066
6085
  evictedWhy: why,
6086
+ evictedLeftBehind: tree && "leftBehind" in tree ? tree.leftBehind : undefined,
6087
+ evictedUnmeasured: tree && "unmeasured" in tree ? tree.unmeasured : undefined,
6067
6088
  } satisfies ThreadBinding);
6089
+ if (tree) console.log(`${logCtx}: ${binding.threadKey} evicted (${why}) — ${evictedTreeSentence(tree)}`);
6068
6090
  return true;
6069
6091
  }
6070
6092
 
@@ -6105,17 +6127,13 @@ export class ResidentDO extends Sandbox<Env> {
6105
6127
  if (left > 0) return { released: false, reason: busyAfterKillReason(left), user: binding.user };
6106
6128
  }
6107
6129
  const active = await this.isRuntimeActive().catch(() => false);
6108
- // What the tree still holds, for the answer — never a reason to keep it.
6109
- // Not measured on a force release: a read-only tree holds nothing, and a
6110
- // hard stop's tree is whatever the killed command left. A probe that
6111
- // fails names nothing (never a guess) and is a log line.
6112
- let leftBehind: LeftBehind | undefined;
6113
- if (!force && active) {
6114
- const measured = await this.worktreeCleanliness(binding);
6115
- leftBehind = leftBehindOf(measured);
6116
- if (!measured.clean && leftBehind === undefined)
6117
- console.log(`detach: ${threadKey}: the tree could not be measured before its release (${measured.reason})`);
6118
- }
6130
+ // What the tree still holds, for the answer and the eviction's record —
6131
+ // never a reason to keep it. Not measured on a force release: a read-only
6132
+ // tree holds nothing, and a hard stop's tree is whatever the killed
6133
+ // command left. A probe that fails names nothing in the answer (never a
6134
+ // guess); the record and the log say it could not be measured.
6135
+ let tree: EvictedTree | undefined;
6136
+ if (!force && active) tree = await this.measureTreeBeforeEviction(binding);
6119
6137
  // Re-check right before removal: the measurement above awaited (the DO
6120
6138
  // yields at each await), so an exec that arrived mid-detach would otherwise
6121
6139
  // have its tree removed under it.
@@ -6136,16 +6154,12 @@ export class ResidentDO extends Sandbox<Env> {
6136
6154
  // Same as the sweep: `active` was read before the measurement's awaits; a
6137
6155
  // container that woke meanwhile must get the rm, not an orphaned tree.
6138
6156
  const activeNow = await this.isRuntimeActive().catch(() => false);
6139
- if (!(await this.evictBinding(current, activeNow, `detach`, "detach"))) {
6157
+ if (!(await this.evictBinding(current, activeNow, `detach`, "detach", tree))) {
6140
6158
  return { released: false, reason: "re-attached during eviction — kept", user };
6141
6159
  }
6142
- if (leftBehind) {
6143
- console.log(
6144
- `detach: ${threadKey} released — left behind ${leftBehind.uncommittedChanges} uncommitted change(s) and ${leftBehind.unpushedCommits} unpushed commit(s), discarded with the tree`,
6145
- );
6146
- }
6147
6160
  // Item 55: the tree is gone; the gauge catches up at the next refresh
6148
6161
  // instance's `measure` step, and the admission's `df` sees the space now.
6162
+ const leftBehind = tree && "leftBehind" in tree ? tree.leftBehind : undefined;
6149
6163
  return { released: true, user, ...(leftBehind !== undefined ? { leftBehind } : {}) };
6150
6164
  }
6151
6165
 
@@ -6183,20 +6197,22 @@ export class ResidentDO extends Sandbox<Env> {
6183
6197
  }
6184
6198
  }
6185
6199
 
6186
- /** Is this thread's tree safe to destroy? The git probes run AS THE THREAD
6187
- * USER (su), never as root: the worktree is thread-owned, so root git in
6188
- * it would be refused by safe.directory and would be the exact repo-local-
6189
- * config execution vector safe.directory exists to block. Unknown (git
6190
- * failed) counts as NOT clean — never destroy work on a guess. A tree
6191
- * that no longer exists (disk recycled by a sleep/wake) has nothing to
6192
- * preserve: releasable, so a post-wake binding does not hold a pool user
6193
- * for 7 days on behalf of files that are already gone.
6200
+ /** What this thread's tree holds: the tracked changes and unpushed commits,
6201
+ * or that git could not read it. The git probes run AS THE THREAD USER
6202
+ * (su), never as root: the worktree is thread-owned, so root git in it
6203
+ * would be refused by safe.directory and would be the exact repo-local-
6204
+ * config execution vector safe.directory exists to block. Read by
6205
+ * `measureTreeBeforeEviction` for the eviction's record — never a reason
6206
+ * to keep a tree (item 17) — and by the disk-full recycle's guard (item
6207
+ * 54), where unknown (git failed) counts as NOT clean. A tree that no
6208
+ * longer exists (disk recycled by a sleep/wake) is clean with nothing
6209
+ * measured: there was nothing to discard.
6194
6210
  *
6195
6211
  * ONE spawn: the presence test and both git probes fold
6196
6212
  * into the pure `worktreeCleanlinessScript` (test -d as root, both git
6197
6213
  * commands inside a single privilege-dropped `su`, tagged lines out);
6198
6214
  * `parseWorktreeCleanliness` encodes the exact decision table above. */
6199
- private async worktreeCleanliness(binding: ThreadBinding): Promise<{ clean: boolean; reason?: string }> {
6215
+ private async worktreeCleanliness(binding: ThreadBinding): Promise<WorktreeCleanliness> {
6200
6216
  const injected = { GIT_TERMINAL_PROMPT: "0" }; // same injection as threadRun — fail fast, never prompt
6201
6217
  validateEnvNames(injected);
6202
6218
  const r = await this.run(["sh", "-c", worktreeCleanlinessScript(binding.worktreePath, binding.user)], {
@@ -6206,6 +6222,14 @@ export class ResidentDO extends Sandbox<Env> {
6206
6222
  return parseWorktreeCleanliness(r);
6207
6223
  }
6208
6224
 
6225
+ /** What a tree holds right before its eviction, for the record and the log
6226
+ * (item 17): the one-spawn probe as the thread user, read once into
6227
+ * `evictedTreeOf` — never into a keep decision. Callers skip it when the
6228
+ * runtime is down: the disk, and the tree with it, is already gone. */
6229
+ private async measureTreeBeforeEviction(binding: ThreadBinding): Promise<EvictedTree | undefined> {
6230
+ return evictedTreeOf(await this.worktreeCleanliness(binding));
6231
+ }
6232
+
6209
6233
  /** Anything that must not be interrupted by a container stop or counted
6210
6234
  * as idle: thread exec/read/write, disposable /op runs, and attaches past
6211
6235
  * their own image check (mid clone/install under the mirror lock). */
@@ -6278,8 +6302,17 @@ export class ResidentDO extends Sandbox<Env> {
6278
6302
  // attach), and an eviction decided on a stale "inactive" would skip the
6279
6303
  // rm and orphan a real tree.
6280
6304
  const activeNow = await this.isRuntimeActive().catch(() => false);
6305
+ // What the tree holds goes on the eviction's record (item 17), measured
6306
+ // while the runtime is up; a slept container has no tree to measure.
6307
+ const tree = activeNow ? await this.measureTreeBeforeEviction(current) : undefined;
6281
6308
  if (
6282
- await this.evictBinding(current, activeNow, `worktree-sweep ${resource}`, last >= cutoff ? "clean-idle" : "ttl")
6309
+ await this.evictBinding(
6310
+ current,
6311
+ activeNow,
6312
+ `worktree-sweep ${resource}`,
6313
+ last >= cutoff ? "clean-idle" : "ttl",
6314
+ tree,
6315
+ )
6283
6316
  )
6284
6317
  evicted.push(binding.threadKey);
6285
6318
  else kept++;
@@ -6537,9 +6570,11 @@ export class ResidentDO extends Sandbox<Env> {
6537
6570
  * mirror (the refresh cycle's `fetch --prune` just ran) or its PR was
6538
6571
  * merged/closed. Runs inside the refresh cycle — a poll on the existing
6539
6572
  * cadence, since the GitHub App has webhooks off — and via /debug
6540
- * reclaim-now. Never touches the default branch, a busy thread, or a dirty
6541
- * tree (reclaimDecision); every keep is named. The eviction itself is the
6542
- * sweep's `evictBinding` with the same re-read guards. */
6573
+ * reclaim-now. Never touches the default branch or a busy thread
6574
+ * (reclaimDecision); every keep is named. What the tree holds is never a
6575
+ * reason to keep it (item 17) — it is measured once as the thread user and
6576
+ * recorded by the eviction, the sweep's `evictBinding` with the same
6577
+ * re-read guards. */
6543
6578
  async reclaimFinishedRefs(
6544
6579
  resource: string,
6545
6580
  defaultRef: string,
@@ -6599,21 +6634,16 @@ export class ResidentDO extends Sandbox<Env> {
6599
6634
  // by name), so its fate is never looked up.
6600
6635
  const { fate, detail } = (!isDefaultRef && fates.get(binding.ref)) || { fate: "unknown" as RefFate, detail: "" };
6601
6636
  const busy = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6602
- // The clean check runs as the thread user and only when it can decide
6603
- // anything: a finished ref, nothing running, runtime up (down → the tree
6604
- // is already gone with the disk → null).
6605
- const finished = fate === "gone" || fate === "merged" || fate === "closed";
6606
- const clean = !active
6607
- ? null
6608
- : finished && !isDefaultRef && busy === 0
6609
- ? (await this.worktreeCleanliness(binding)).clean
6610
- : null;
6611
- const decision = reclaimDecision({ fate, isDefaultRef, busy, clean });
6637
+ const decision = reclaimDecision({ fate, isDefaultRef, busy });
6612
6638
  if (!decision.reclaim) {
6613
6639
  kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: decision.why });
6614
6640
  continue;
6615
6641
  }
6616
- // Same guards as the sweep: the clean check awaited, so re-read the
6642
+ // What the tree holds goes on the eviction's record (item 17), never
6643
+ // into the decision: measured as the thread user while the runtime is
6644
+ // up; down → the tree is already gone with the disk.
6645
+ const tree = active ? await this.measureTreeBeforeEviction(binding) : undefined;
6646
+ // Same guards as the sweep: the measurement awaited, so re-read the
6617
6647
  // binding (a re-attach means a fresh tree) and the op counter.
6618
6648
  const busyNow = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6619
6649
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(binding.threadKey));
@@ -6627,7 +6657,7 @@ export class ResidentDO extends Sandbox<Env> {
6627
6657
  }
6628
6658
  const activeNow = await this.isRuntimeActive().catch(() => false);
6629
6659
  const why = `${decision.why}${detail}`;
6630
- if (await this.evictBinding(current, activeNow, `reclaim ${resource}`, why)) {
6660
+ if (await this.evictBinding(current, activeNow, `reclaim ${resource}`, why, tree)) {
6631
6661
  reclaimed.push({ threadKey: binding.threadKey, ref: binding.ref, why });
6632
6662
  console.log(`reclaim ${resource}: evicted ${binding.threadKey} on ${binding.ref} — ${why}`);
6633
6663
  } else kept.push({ threadKey: binding.threadKey, ref: binding.ref, why: "re-attached" });
@@ -6738,18 +6768,35 @@ export class ResidentDO extends Sandbox<Env> {
6738
6768
  // left out; nothing here is secret (credential files are never persisted).
6739
6769
  const threads = [...bindings.values()]
6740
6770
  .sort((a, b) => b.lastAttachAt.localeCompare(a.lastAttachAt))
6741
- .map(({ threadKey, ref, sha, user, deps, boundAt, lastAttachAt, evicted, evictedAt, evictedWhy }) => ({
6742
- threadKey,
6743
- ref,
6744
- sha: sha ?? null,
6745
- user,
6746
- deps: deps ?? null,
6747
- boundAt,
6748
- lastAttachAt,
6749
- evicted: evicted ?? false,
6750
- evictedAt: evictedAt ?? null,
6751
- evictedWhy: evictedWhy ?? null,
6752
- }));
6771
+ .map(
6772
+ ({
6773
+ threadKey,
6774
+ ref,
6775
+ sha,
6776
+ user,
6777
+ deps,
6778
+ boundAt,
6779
+ lastAttachAt,
6780
+ evicted,
6781
+ evictedAt,
6782
+ evictedWhy,
6783
+ evictedLeftBehind,
6784
+ evictedUnmeasured,
6785
+ }) => ({
6786
+ threadKey,
6787
+ ref,
6788
+ sha: sha ?? null,
6789
+ user,
6790
+ deps: deps ?? null,
6791
+ boundAt,
6792
+ lastAttachAt,
6793
+ evicted: evicted ?? false,
6794
+ evictedAt: evictedAt ?? null,
6795
+ evictedWhy: evictedWhy ?? null,
6796
+ evictedLeftBehind: evictedLeftBehind ?? null,
6797
+ evictedUnmeasured: evictedUnmeasured ?? null,
6798
+ }),
6799
+ );
6753
6800
  return {
6754
6801
  resource: map.get(RESOURCE_KEY) ?? null,
6755
6802
  state: map.get(STATE_KEY) ?? "down",
@@ -43,6 +43,7 @@ import {
43
43
  recycledMidCommandMessage,
44
44
  } from "../../src/execution/sandboxLifecycle.js";
45
45
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
+ import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
46
47
  import { RUNTIME_REPLACEMENT_WORDING, isRuntimeUnreachableSignal } from "../../src/execution/residentRefresh.js";
47
48
  import {
48
49
  fleetBusyAnswer,
@@ -166,16 +167,66 @@ interface FileRefusal {
166
167
  reason?: string;
167
168
  }
168
169
 
170
+ /** Durable Object storage key of the idle ledger's served-time. */
171
+ const IDLE_LEDGER_KEY = "switchboard.idle.lastServedAt";
172
+ /** The scheduled-callback name of the idle sweep (a method below). */
173
+ const IDLE_SWEEP_CALLBACK = "idleSweep";
174
+
169
175
  export class SwitchboardSandbox extends Sandbox<Env> {
170
- // Idle lifetime of a thread's container (the SDK's own default is 10 min).
171
- // Idle means idle: the SDK renews the activity timeout every second while a
172
- // command's stream is open (its control connection's busy poll), so a
173
- // running command never counts toward it and the shell-level `timeout` is
174
- // the one deadline a command can hit (docs/reference/specs/execution.md item 2).
175
- // 5 minutes frees the slot sooner while a prompt follow-up still reuses the
176
- // warm workspace.
176
+ // The SDK's idle setting (its own default is 10 min), kept so its alarm
177
+ // loop calls `onActivityExpired` on this cadence — but on the 0.13 line that
178
+ // hook is a question to the runtime ("anything still running?"), not a
179
+ // deadline, and the answer kept twenty-five containers awake for 16 hours.
180
+ // The deadline itself is the guard's (docs/reference/specs/execution.md
181
+ // item 22): 5 minutes after the last request this object served, the
182
+ // container is destroyed, whatever runs inside; the shell-level `timeout`
183
+ // stays the one deadline a command can hit (item 2), since a request in
184
+ // flight is service.
177
185
  sleepAfter = SANDBOX_SLEEP_AFTER;
178
186
 
187
+ private readonly idle: IdleGuard;
188
+
189
+ constructor(...args: ConstructorParameters<typeof Sandbox<Env>>) {
190
+ super(...args);
191
+ this.idle = new IdleGuard(this.idleHost());
192
+ // Every wake, including the one the SDK's own alarm causes on a leaked
193
+ // container: the baseline is read and a sweep armed before any request.
194
+ this.ctx.blockConcurrencyWhile(() => this.idle.wake());
195
+ }
196
+
197
+ /** The guard's view of this object: the platform's running flag, the SDK's
198
+ * schedule table and clean destroy, the platform's kill, and storage. */
199
+ private idleHost(): IdleGuardHost {
200
+ return {
201
+ now: systemClock,
202
+ containerRunning: () => this.ctx.container?.running,
203
+ sweepScheduled: async () => (await this.listSchedules(IDLE_SWEEP_CALLBACK)).length > 0,
204
+ scheduleSweep: async (delayMs) => {
205
+ await this.schedule(Math.ceil(delayMs / 1000), IDLE_SWEEP_CALLBACK);
206
+ },
207
+ destroySandbox: () => this.destroy(),
208
+ killContainer: async () => {
209
+ await this.ctx.container?.destroy();
210
+ },
211
+ loadLastServedAt: () => this.ctx.storage.get<number>(IDLE_LEDGER_KEY),
212
+ saveLastServedAt: (at) => this.ctx.storage.put(IDLE_LEDGER_KEY, at),
213
+ log: (event) => console.log(JSON.stringify({ ...event, thread: this.ctx.id.name ?? this.ctx.id.toString() })),
214
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
215
+ };
216
+ }
217
+
218
+ /** The idle sweep, scheduled by the guard through the SDK's schedule table
219
+ * and re-armed by it while a container is running. */
220
+ async idleSweep(): Promise<void> {
221
+ await this.idle.sweep();
222
+ }
223
+
224
+ /** The SDK's activity expiry, answered by the guard's verdict instead of the
225
+ * runtime's process probes. */
226
+ override async onActivityExpired(): Promise<void> {
227
+ await this.idle.expired();
228
+ }
229
+
179
230
  /** One command as one supervised process (docs/reference/specs/execution.md
180
231
  * item 4): `timeout -k 10 <secs> bash -c 'mkdir -p /workspace && cd
181
232
  * /workspace && <command>'`, the caller's env on the process alone (item 5),
@@ -188,6 +239,16 @@ export class SwitchboardSandbox extends Sandbox<Env> {
188
239
  command: string,
189
240
  execTimeoutSecs: number,
190
241
  envVars: Record<string, string>,
242
+ ): Promise<ExecAnswer | ExecFailure> {
243
+ return this.idle.served(() => this.execute(command, execTimeoutSecs, envVars));
244
+ }
245
+
246
+ /** `runCommand` without the ledger entry: the body, and the internal caller
247
+ * (`readBase64`'s stat) that is already inside a served request. */
248
+ private async execute(
249
+ command: string,
250
+ execTimeoutSecs: number,
251
+ envVars: Record<string, string>,
191
252
  ): Promise<ExecAnswer | ExecFailure> {
192
253
  const startedAt = systemClock();
193
254
  const full = `mkdir -p ${WORKDIR} && cd ${WORKDIR} && ${command}`;
@@ -277,7 +338,9 @@ export class SwitchboardSandbox extends Sandbox<Env> {
277
338
  }
278
339
 
279
340
  async readText(path: string): Promise<{ content: string } | FileRefusal> {
280
- return this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content }));
341
+ return this.idle.served(() =>
342
+ this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
343
+ );
281
344
  }
282
345
 
283
346
  /** `encoding: "base64"` (src/execution/binaryRead.ts): the size first, from
@@ -285,7 +348,11 @@ export class SwitchboardSandbox extends Sandbox<Env> {
285
348
  * decoded bytes to it — an SDK read that came back short would otherwise
286
349
  * pass as the file. A file over the cap is refused by name inside a 200. */
287
350
  async readBase64(path: string): Promise<Base64ReadAnswer | FileRefusal> {
288
- const stat = await this.runCommand(statCommandFor(path), 60, {});
351
+ return this.idle.served(() => this.readBase64Now(path));
352
+ }
353
+
354
+ private async readBase64Now(path: string): Promise<Base64ReadAnswer | FileRefusal> {
355
+ const stat = await this.execute(statCommandFor(path), 60, {});
289
356
  if ("error" in stat) {
290
357
  // The stat's own named failure — a full fleet, a silent control port —
291
358
  // is the read's, token included, so the executor waits as it would
@@ -309,10 +376,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
309
376
  }
310
377
 
311
378
  async write(path: string, content: string): Promise<{ ok: true } | FileRefusal> {
312
- return this.fileOp(async () => {
313
- await this.writeFile(path, content);
314
- return { ok: true as const };
315
- });
379
+ return this.idle.served(() =>
380
+ this.fileOp(async () => {
381
+ await this.writeFile(path, content);
382
+ return { ok: true as const };
383
+ }),
384
+ );
316
385
  }
317
386
  }
318
387
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.234.0",
3
+ "version": "1.235.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.234.0",
9
+ "version": "1.235.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20032,7 +20032,7 @@
20032
20032
  },
20033
20033
  "packages/switchboard": {
20034
20034
  "name": "@coreplane/switchboard",
20035
- "version": "1.234.0",
20035
+ "version": "1.235.0",
20036
20036
  "license": "Apache-2.0",
20037
20037
  "dependencies": {
20038
20038
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.234.0",
3
+ "version": "1.235.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.234.0",
3
- "commit": "0c5f8c5e4657cd12a9bbf85177bee69b61505ac3",
4
- "builtAt": "2026-09-16T16:44:55.183Z"
2
+ "version": "1.235.0",
3
+ "commit": "091ab2f1f6d89290bde9c84e933d23525f9c8873",
4
+ "builtAt": "2026-09-16T17:37:07.278Z"
5
5
  }
@@ -367,6 +367,11 @@ export type RunEvent =
367
367
  * past the summary cap. The status card and friction analyzer keep reading
368
368
  * `summary`. */
369
369
  /** `spanId` (docs/reference/specs/tracing.md): the `tool.*` span this call ran under, once the runner emits spans. */
370
+ /** `logIndex` (docs/reference/specs/run-history.md item 53): the row of the run's
371
+ * session log holding the assistant turn this call rode in — on a `tool_result`,
372
+ * the user turn its batch's results make. How a search hit's turn finds its
373
+ * step on the page. Absent for a run without a session, a record from before
374
+ * the field, and a call the mirror could not place. */
370
375
  | {
371
376
  type: "tool_call";
372
377
  tool: string;
@@ -374,6 +379,7 @@ export type RunEvent =
374
379
  command?: string;
375
380
  callId?: string;
376
381
  spanId?: string;
382
+ logIndex?: number;
377
383
  seq?: number;
378
384
  at?: number;
379
385
  }
@@ -393,6 +399,7 @@ export type RunEvent =
393
399
  output?: string;
394
400
  infra?: true;
395
401
  spanId?: string;
402
+ logIndex?: number;
396
403
  seq?: number;
397
404
  at?: number;
398
405
  }