@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.
- package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
- package/dist/assets/deploy/cloudflare-resident/worker.ts +125 -78
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +82 -13
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/runEvents.ts +7 -0
- package/dist/assets/src/execution/residentCleanliness.ts +24 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
- package/dist/assets/src/execution/sandboxErrors.ts +5 -0
- package/dist/assets/src/execution/sandboxIdle.ts +220 -0
- package/dist/assets/web/dist/.vite/manifest.json +26 -25
- package/dist/assets/web/dist/assets/{ResidentDetailPage-C_WNBUao.js → ResidentDetailPage-DSpsye5e.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-D1sTyaZ_.js → ResidentsIndexPage-CT8fI1M3.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +9 -0
- package/dist/assets/web/dist/assets/RunRoutePage-oN9GkVEA.js +6 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-H6GkSv0a.js → RunsIndexPage-sjXr8iCV.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BLcPzm1S.js → ScheduledPage-Tz__zLzi.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-Ct6AtHhS.js → StatusDot-D14iJMy7.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-qol5Boof.js → Tooltip-B0Ob5MQ4.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +1 -0
- package/dist/assets/web/dist/assets/{dist-DwtJj-ou.js → dist-pOdUzj6D.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CRtlGxRm.js → main-D3lG-yQ9.js} +2 -2
- package/dist/cli.js +84 -25
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +0 -5
- package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +0 -10
- 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,
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
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` /
|
|
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
|
|
3790
|
-
*
|
|
3791
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
3807
|
-
* is clean
|
|
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 —
|
|
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
|
|
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
|
|
3954
|
-
*
|
|
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
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
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
|
|
6026
|
-
*
|
|
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
|
|
6109
|
-
// Not measured on a force release: a read-only
|
|
6110
|
-
// hard stop's tree is whatever the killed
|
|
6111
|
-
// fails names nothing
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
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
|
-
/**
|
|
6187
|
-
*
|
|
6188
|
-
*
|
|
6189
|
-
*
|
|
6190
|
-
*
|
|
6191
|
-
*
|
|
6192
|
-
*
|
|
6193
|
-
*
|
|
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<
|
|
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(
|
|
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
|
|
6541
|
-
*
|
|
6542
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// 5 minutes
|
|
176
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
20035
|
+
"version": "1.235.0",
|
|
20036
20036
|
"license": "Apache-2.0",
|
|
20037
20037
|
"dependencies": {
|
|
20038
20038
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
}
|