@coreplane/switchboard 1.227.0 → 1.229.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/config/config.example.yaml +7 -13
- package/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +170 -30
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +22 -18
- package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +11 -10
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +332 -299
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +2 -2
- package/dist/assets/package-lock.json +2542 -212
- package/dist/assets/package.json +2 -2
- package/dist/assets/project.json +2 -2
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +19 -34
- package/dist/assets/src/core/authz/policy.ts +8 -0
- package/dist/assets/src/core/authz/resource.ts +3 -1
- package/dist/assets/src/core/authz/types.ts +10 -1
- package/dist/assets/src/core/chatMessage.ts +1 -1
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +8 -6
- package/dist/assets/src/core/runEvents.ts +53 -14
- package/dist/assets/src/core/runFriction.ts +3 -2
- package/dist/assets/src/core/runRecord.ts +46 -3
- package/dist/assets/src/core/runUsage.ts +199 -0
- package/dist/assets/src/core/ship/coordinator.ts +4 -2
- package/dist/assets/src/execution/residentDepCache.ts +34 -8
- package/dist/assets/src/execution/residentRebind.ts +84 -7
- package/dist/assets/src/execution/sandboxErrors.ts +14 -38
- package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
- package/dist/assets/web/dist/.vite/manifest.json +20 -20
- package/dist/assets/web/dist/assets/CostsPage-DQg30mHr.js +2 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Chvll3wy.js → ResidentDetailPage-CkVYktwT.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-B5f8IwGF.js → ResidentsIndexPage-BLanxf27.js} +1 -1
- package/dist/assets/web/dist/assets/RunRoutePage-U3nwL8Df.js +13 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-BTJuKFTv.js → RunsIndexPage-DrIVxmpl.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BVfgUBvP.js → ScheduledPage-DpqubmIm.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CFXbAw7S.js → StatusDot-BpD9MRge.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DcHMtbHJ.js → Tooltip-BYv0WSrA.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-DKhqHu0V.js → dist-BZmA5qTt.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CveRd2yk.js → main-C4GOEklV.js} +2 -2
- package/dist/assets/web/dist/assets/main-CAVqMbiX.css +1 -0
- package/dist/cli.js +9094 -9223
- package/package.json +1 -2
- package/dist/assets/src/execution/sandboxKeepalive.ts +0 -118
- package/dist/assets/web/dist/assets/CostsPage-5pl3HB2F.js +0 -2
- package/dist/assets/web/dist/assets/RunRoutePage-CRvmCuXh.js +0 -12
- package/dist/assets/web/dist/assets/main-xLAsdkfB.css +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { RunEvent } from "./runEvents.js";
|
|
2
|
+
|
|
3
|
+
// What a run cost in tokens, and who it belongs to — the data behind "cost by
|
|
4
|
+
// user" on the costs page (docs/reference/specs/costs.md). Every provider call a
|
|
5
|
+
// run makes is one `model.turn` span with the provider's own token counts as
|
|
6
|
+
// attrs (metered by the model proxy or the runner; docs/reference/specs/tracing.md), so a
|
|
7
|
+
// run's usage is the sum of those spans, per model. It is computed ONCE, at
|
|
8
|
+
// finish, from the events still in memory (`assembleRunRecord`), and rides the
|
|
9
|
+
// record — a record's events may be cut to fit the byte budget, so an aggregate
|
|
10
|
+
// taken then is more faithful than one re-read later. A record written before
|
|
11
|
+
// the field existed has none; the store fills it in from the run's stored
|
|
12
|
+
// events on demand (the lazy backfill), and reports how many still wait.
|
|
13
|
+
|
|
14
|
+
export interface ModelUsage {
|
|
15
|
+
turns: number;
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
cacheReadTokens: number;
|
|
19
|
+
cacheWriteTokens: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RunUsage {
|
|
23
|
+
/** Every `model.turn` span, whatever its model. */
|
|
24
|
+
turns: number;
|
|
25
|
+
/** Per `<provider>/<model>` as the span named it; `unknown` for a turn without a model attr. */
|
|
26
|
+
byModel: Record<string, ModelUsage>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const UNKNOWN_MODEL = "unknown";
|
|
30
|
+
|
|
31
|
+
const MODEL_TURN = "model.turn";
|
|
32
|
+
|
|
33
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
34
|
+
|
|
35
|
+
export const emptyUsage = (): RunUsage => ({ turns: 0, byModel: {} });
|
|
36
|
+
|
|
37
|
+
/** The run's usage from its events: one `model.turn` span end per provider call. */
|
|
38
|
+
export function usageOfEvents(events: readonly RunEvent[]): RunUsage {
|
|
39
|
+
const usage = emptyUsage();
|
|
40
|
+
for (const e of events) {
|
|
41
|
+
if (e.type !== "span_end" || e.name !== MODEL_TURN) continue;
|
|
42
|
+
const attrs = (e.attrs ?? {}) as Record<string, unknown>;
|
|
43
|
+
const model = typeof attrs.model === "string" && attrs.model ? attrs.model : UNKNOWN_MODEL;
|
|
44
|
+
const m = usage.byModel[model] ?? {
|
|
45
|
+
turns: 0,
|
|
46
|
+
inputTokens: 0,
|
|
47
|
+
outputTokens: 0,
|
|
48
|
+
cacheReadTokens: 0,
|
|
49
|
+
cacheWriteTokens: 0,
|
|
50
|
+
};
|
|
51
|
+
m.turns += 1;
|
|
52
|
+
m.inputTokens += num(attrs.inputTokens);
|
|
53
|
+
m.outputTokens += num(attrs.outputTokens);
|
|
54
|
+
m.cacheReadTokens += num(attrs.cacheReadTokens);
|
|
55
|
+
m.cacheWriteTokens += num(attrs.cacheWriteTokens);
|
|
56
|
+
usage.byModel[model] = m;
|
|
57
|
+
usage.turns += 1;
|
|
58
|
+
}
|
|
59
|
+
return usage;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addUsage(a: RunUsage, b: RunUsage): RunUsage {
|
|
63
|
+
const out: RunUsage = { turns: a.turns + b.turns, byModel: {} };
|
|
64
|
+
for (const src of [a.byModel, b.byModel]) {
|
|
65
|
+
for (const [model, m] of Object.entries(src)) {
|
|
66
|
+
const acc = out.byModel[model] ?? {
|
|
67
|
+
turns: 0,
|
|
68
|
+
inputTokens: 0,
|
|
69
|
+
outputTokens: 0,
|
|
70
|
+
cacheReadTokens: 0,
|
|
71
|
+
cacheWriteTokens: 0,
|
|
72
|
+
};
|
|
73
|
+
acc.turns += m.turns;
|
|
74
|
+
acc.inputTokens += m.inputTokens;
|
|
75
|
+
acc.outputTokens += m.outputTokens;
|
|
76
|
+
acc.cacheReadTokens += m.cacheReadTokens;
|
|
77
|
+
acc.cacheWriteTokens += m.cacheWriteTokens;
|
|
78
|
+
out.byModel[model] = acc;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isModelUsage = (v: unknown): v is ModelUsage =>
|
|
85
|
+
typeof v === "object" &&
|
|
86
|
+
v !== null &&
|
|
87
|
+
(["turns", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"] as const).every(
|
|
88
|
+
(k) => typeof (v as Record<string, unknown>)[k] === "number",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
export function isRunUsage(v: unknown): v is RunUsage {
|
|
92
|
+
if (typeof v !== "object" || v === null) return false;
|
|
93
|
+
const u = v as Record<string, unknown>;
|
|
94
|
+
if (typeof u.turns !== "number") return false;
|
|
95
|
+
if (typeof u.byModel !== "object" || u.byModel === null || Array.isArray(u.byModel)) return false;
|
|
96
|
+
return Object.values(u.byModel).every(isModelUsage);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- the aggregate: who spent what, per UTC day ---------------------------------------
|
|
100
|
+
|
|
101
|
+
/** One finished run as the aggregate sees it — the record's identity fields and its usage. */
|
|
102
|
+
export interface UsageRun {
|
|
103
|
+
id: string;
|
|
104
|
+
userId: string;
|
|
105
|
+
userName?: string;
|
|
106
|
+
/** A child run is billed to whoever started its parent (run-history item 46). */
|
|
107
|
+
parentRunId?: string;
|
|
108
|
+
startedAt: number;
|
|
109
|
+
finishedAt: number;
|
|
110
|
+
/** Absent on a record written before usage existed and not yet backfilled. */
|
|
111
|
+
usage?: RunUsage;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UserDayUsage {
|
|
115
|
+
userId: string;
|
|
116
|
+
userName?: string;
|
|
117
|
+
/** The UTC day the run finished, `YYYY-MM-DD`. */
|
|
118
|
+
day: string;
|
|
119
|
+
runs: number;
|
|
120
|
+
/** Summed wall-clock of the runs (finish − start), for allocating shared cloud spend. */
|
|
121
|
+
wallMs: number;
|
|
122
|
+
usage: RunUsage;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface RunUsageQuery {
|
|
126
|
+
/** Runs that finished at or after this epoch ms … */
|
|
127
|
+
sinceMs: number;
|
|
128
|
+
/** … and before this one. */
|
|
129
|
+
untilMs: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RunUsageReport {
|
|
133
|
+
rows: UserDayUsage[];
|
|
134
|
+
/** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
|
|
135
|
+
pending: number;
|
|
136
|
+
/** The oldest finish the store still holds, so a page can bound its range to the data. */
|
|
137
|
+
earliestFinishedAt?: number;
|
|
138
|
+
retentionDays: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const dayOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
|
|
142
|
+
|
|
143
|
+
/** Who a run is billed to: its parent's requester when it is a child and the
|
|
144
|
+
* parent is known (in the batch, or through `lookupParent`), else its own. */
|
|
145
|
+
export function billedTo(
|
|
146
|
+
run: UsageRun,
|
|
147
|
+
batch: ReadonlyMap<string, UsageRun>,
|
|
148
|
+
lookupParent: (id: string) => Pick<UsageRun, "userId" | "userName"> | undefined,
|
|
149
|
+
): Pick<UsageRun, "userId" | "userName"> {
|
|
150
|
+
if (!run.parentRunId) return { userId: run.userId, ...(run.userName ? { userName: run.userName } : {}) };
|
|
151
|
+
const parent = batch.get(run.parentRunId) ?? lookupParent(run.parentRunId);
|
|
152
|
+
if (!parent) return { userId: run.userId, ...(run.userName ? { userName: run.userName } : {}) };
|
|
153
|
+
return { userId: parent.userId, ...(parent.userName ? { userName: parent.userName } : {}) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Pure: the runs summed per (billed user, UTC day of finish). A run without
|
|
157
|
+
* usage counts as pending and contributes its run and wall-clock only. Rows
|
|
158
|
+
* come out oldest day first, then by user id. */
|
|
159
|
+
export function aggregateUsageByUser(
|
|
160
|
+
runs: readonly UsageRun[],
|
|
161
|
+
lookupParent: (id: string) => Pick<UsageRun, "userId" | "userName"> | undefined = () => undefined,
|
|
162
|
+
): { rows: UserDayUsage[]; pending: number } {
|
|
163
|
+
const batch = new Map(runs.map((r) => [r.id, r]));
|
|
164
|
+
const rows = new Map<string, UserDayUsage>();
|
|
165
|
+
let pending = 0;
|
|
166
|
+
for (const run of runs) {
|
|
167
|
+
const who = billedTo(run, batch, lookupParent);
|
|
168
|
+
const day = dayOf(run.finishedAt);
|
|
169
|
+
const key = `${day} ${who.userId}`;
|
|
170
|
+
const row = rows.get(key) ?? { userId: who.userId, day, runs: 0, wallMs: 0, usage: emptyUsage() };
|
|
171
|
+
if (who.userName && !row.userName) row.userName = who.userName;
|
|
172
|
+
row.runs += 1;
|
|
173
|
+
row.wallMs += Math.max(0, run.finishedAt - run.startedAt);
|
|
174
|
+
if (run.usage) row.usage = addUsage(row.usage, run.usage);
|
|
175
|
+
else pending += 1;
|
|
176
|
+
rows.set(key, row);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
rows: [...rows.values()].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : a.userId < b.userId ? -1 : 1)),
|
|
180
|
+
pending,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function isRunUsageReport(v: unknown): v is RunUsageReport {
|
|
185
|
+
if (typeof v !== "object" || v === null) return false;
|
|
186
|
+
const r = v as Record<string, unknown>;
|
|
187
|
+
if (!Array.isArray(r.rows) || typeof r.pending !== "number" || typeof r.retentionDays !== "number") return false;
|
|
188
|
+
if (r.earliestFinishedAt !== undefined && typeof r.earliestFinishedAt !== "number") return false;
|
|
189
|
+
return r.rows.every(
|
|
190
|
+
(row) =>
|
|
191
|
+
typeof row === "object" &&
|
|
192
|
+
row !== null &&
|
|
193
|
+
typeof (row as UserDayUsage).userId === "string" &&
|
|
194
|
+
typeof (row as UserDayUsage).day === "string" &&
|
|
195
|
+
typeof (row as UserDayUsage).runs === "number" &&
|
|
196
|
+
typeof (row as UserDayUsage).wallMs === "number" &&
|
|
197
|
+
isRunUsage((row as UserDayUsage).usage),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -424,7 +424,9 @@ export interface UnitPipelineInput {
|
|
|
424
424
|
/** Each child preset's own wall-clock budget (its `maxMinutes`), the number a
|
|
425
425
|
* round's budget is clipped from — supplied by the bot, which holds the registry. */
|
|
426
426
|
childMinutes: Readonly<Record<ChildPreset, number>>;
|
|
427
|
-
/** Who merges: the
|
|
427
|
+
/** Who merges: the instance's `merge` field as the plan route answers it —
|
|
428
|
+
* `runner` (a seeded plan, under its grant) or `person` (a task, or a
|
|
429
|
+
* record without the field). */
|
|
428
430
|
merge: "runner" | "person";
|
|
429
431
|
/** Resume at review: an open pull request of ship's own the requester named. */
|
|
430
432
|
resume?: { pr: number; headSha?: string; url?: string };
|
|
@@ -1102,7 +1104,7 @@ export function renderUnitReport(s: UnitPipelineState): string {
|
|
|
1102
1104
|
`✅ Merge-ready after ${rounds}: ${e.pr.url}`,
|
|
1103
1105
|
verdictLine,
|
|
1104
1106
|
declinedLine,
|
|
1105
|
-
"Remaining gate: a person's merge — the runner merges only
|
|
1107
|
+
"Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
|
|
1106
1108
|
].join("\n");
|
|
1107
1109
|
case "merge_refused":
|
|
1108
1110
|
return join([
|
|
@@ -213,6 +213,10 @@ export interface DepCacheScriptParse {
|
|
|
213
213
|
/** Raw `find` output for the hardlinked node_modules (the exact
|
|
214
214
|
* `mutableCacheFindArgv` shape), for `mutableCachePaths`. */
|
|
215
215
|
mutableListing: string[];
|
|
216
|
+
/** The swap script's `skipped=` lines: tool-managed paths (relative to
|
|
217
|
+
* node_modules) it left in place because the source has no counterpart —
|
|
218
|
+
* tree-private entries, not shared inodes (see `mutableCacheSwapScript`). */
|
|
219
|
+
skipped: string[];
|
|
216
220
|
failedStep: string | null;
|
|
217
221
|
}
|
|
218
222
|
|
|
@@ -275,6 +279,7 @@ export function depCacheScript(
|
|
|
275
279
|
export function parseDepCacheScriptOutput(stdout: string): DepCacheScriptParse {
|
|
276
280
|
let mech: DepCacheMaterialization | "none" = "none";
|
|
277
281
|
const mutableListing: string[] = [];
|
|
282
|
+
const skipped: string[] = [];
|
|
278
283
|
let failedStep: string | null = null;
|
|
279
284
|
for (const raw of stdout.split("\n")) {
|
|
280
285
|
const line = raw.trim();
|
|
@@ -285,19 +290,34 @@ export function parseDepCacheScriptOutput(stdout: string): DepCacheScriptParse {
|
|
|
285
290
|
}
|
|
286
291
|
} else if ((m = /^mutable=(.+)$/.exec(line))) {
|
|
287
292
|
mutableListing.push(m[1]);
|
|
293
|
+
} else if ((m = /^skipped=(.+)$/.exec(line))) {
|
|
294
|
+
skipped.push(m[1]);
|
|
288
295
|
} else if ((m = /^err=(.+)$/.exec(line))) {
|
|
289
296
|
failedStep ??= m[1];
|
|
290
297
|
}
|
|
291
298
|
}
|
|
292
|
-
return { mech, mutableListing, failedStep };
|
|
299
|
+
return { mech, mutableListing, skipped, failedStep };
|
|
293
300
|
}
|
|
294
301
|
|
|
295
302
|
/** The per-path swaps for a hardlinked node_modules' tool-managed entries
|
|
296
303
|
* (`mutableCachePaths` output), all in one fork: `rm -rf` the shared
|
|
297
|
-
* subtree, `cp -R` the
|
|
298
|
-
* `chown -Rh` to the thread user (-h: a postinstall-planted symlink is
|
|
304
|
+
* subtree, `cp -R` the source's matching subpath (fresh inodes), `chmod -R
|
|
305
|
+
* u+w`, `chown -Rh` to the thread user (-h: a postinstall-planted symlink is
|
|
299
306
|
* re-owned as a LINK, never followed to an out-of-tree target). Same steps,
|
|
300
|
-
* same order, same flags as the old per-spawn loop.
|
|
307
|
+
* same order, same flags as the old per-spawn loop.
|
|
308
|
+
*
|
|
309
|
+
* Each swap is gated on the counterpart existing in the source. The paths
|
|
310
|
+
* come from a `find` over the TREE, and the tree's listing can name an
|
|
311
|
+
* entry the source cannot stat — a top-level dot entry the store entry
|
|
312
|
+
* lacks (one repo's tree listed `node_modules/.eports.d.ts`). Such an entry
|
|
313
|
+
* is not a shared inode to swap: whatever is at that path is already
|
|
314
|
+
* tree-private. Deleting it is a regression, and failing on it took the
|
|
315
|
+
* whole refresh down — the `rm` had run, the `cp` died on the missing
|
|
316
|
+
* source, and the resident degraded on every cycle after. It is left in
|
|
317
|
+
* place and named on a `skipped=<path relative to node_modules>` line so
|
|
318
|
+
* the step's output says so (`DepCacheScriptParse.skipped`). `-L` beside
|
|
319
|
+
* `-e`: `-e` follows symlinks, and a dangling link in the source is still
|
|
320
|
+
* an entry `cp -R` copies as a link. */
|
|
301
321
|
export function mutableCacheSwapScript(
|
|
302
322
|
srcRoot: string,
|
|
303
323
|
dstRoot: string,
|
|
@@ -309,13 +329,19 @@ export function mutableCacheSwapScript(
|
|
|
309
329
|
const lines: string[] = [];
|
|
310
330
|
for (const p of paths) {
|
|
311
331
|
const rel = p.slice(root.length);
|
|
312
|
-
|
|
313
|
-
|
|
332
|
+
const src = shellQuote(`${srcRoot}${rel}`);
|
|
333
|
+
const dst = shellQuote(p);
|
|
334
|
+
lines.push(`if [ -e ${src} ] || [ -L ${src} ]; then`);
|
|
335
|
+
lines.push(` rm -rf ${dst} || { echo err=deps-mutable-rm; exit 1; }`);
|
|
336
|
+
lines.push(` cp -R ${src} ${dst} || { echo err=deps-mutable-copy; exit 1; }`);
|
|
314
337
|
// cp copies mode bits: a store entry's files are owner-read-only (item 59,
|
|
315
338
|
// hardened so no consumer can write through the shared inodes), and a
|
|
316
339
|
// cache the tree's own tools must rewrite in place has to be writable.
|
|
317
|
-
lines.push(`chmod -R u+w ${
|
|
318
|
-
lines.push(`chown -Rh ${owner} ${
|
|
340
|
+
lines.push(` chmod -R u+w ${dst} || { echo err=deps-mutable-chmod; exit 1; }`);
|
|
341
|
+
lines.push(` chown -Rh ${owner} ${dst} || { echo err=deps-mutable-chown; exit 1; }`);
|
|
342
|
+
lines.push(`else`);
|
|
343
|
+
lines.push(` echo ${shellQuote(`skipped=${rel.replace(/^\/+/, "")}`)}`);
|
|
344
|
+
lines.push(`fi`);
|
|
319
345
|
}
|
|
320
346
|
return lines.join("\n");
|
|
321
347
|
}
|
|
@@ -65,6 +65,66 @@ export function parseRefByDefault(value: unknown): ParsedRefByDefault {
|
|
|
65
65
|
return { error: "refByDefault must be a boolean when present" };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** A branch a run pushed and the pull request it heads — what the run's
|
|
69
|
+
* release hands the resident (`/detach` body `pushed`), read off the run's
|
|
70
|
+
* own `pr_opened` events. */
|
|
71
|
+
export interface PushedBranch {
|
|
72
|
+
ref: string;
|
|
73
|
+
pr: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The same fact as the binding remembers it, with when it was told. */
|
|
77
|
+
export interface OwnBranch extends PushedBranch {
|
|
78
|
+
at: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The most branches one release may hand over: a run pushes a handful at most. */
|
|
82
|
+
export const PUSHED_MAX = 20;
|
|
83
|
+
/** The most branches a binding remembers: the newest are kept. */
|
|
84
|
+
export const OWN_BRANCHES_MAX = 50;
|
|
85
|
+
|
|
86
|
+
export type ParsedPushed = { pushed: PushedBranch[] } | { error: string };
|
|
87
|
+
|
|
88
|
+
const PUSHED_ERROR = "pushed must be a list of {ref: <branch>, pr: <positive integer>} when present";
|
|
89
|
+
|
|
90
|
+
/** `/detach` body field `pushed`: absent → nothing (the body every bot always
|
|
91
|
+
* sent); a list of up to `PUSHED_MAX` well-formed `{ref, pr}` → itself;
|
|
92
|
+
* anything else → a 400-shaped error. Each ref's PATTERN is the Worker's to
|
|
93
|
+
* check (`parseRef`), like every ref. */
|
|
94
|
+
export function parsePushed(value: unknown): ParsedPushed {
|
|
95
|
+
if (value === undefined) return { pushed: [] };
|
|
96
|
+
if (!Array.isArray(value) || value.length > PUSHED_MAX) return { error: PUSHED_ERROR };
|
|
97
|
+
const pushed: PushedBranch[] = [];
|
|
98
|
+
for (const entry of value) {
|
|
99
|
+
if (typeof entry !== "object" || entry === null) return { error: PUSHED_ERROR };
|
|
100
|
+
const { ref, pr } = entry as { ref?: unknown; pr?: unknown };
|
|
101
|
+
if (typeof ref !== "string" || ref.length === 0) return { error: PUSHED_ERROR };
|
|
102
|
+
if (typeof pr !== "number" || !Number.isSafeInteger(pr) || pr <= 0) return { error: PUSHED_ERROR };
|
|
103
|
+
pushed.push({ ref, pr });
|
|
104
|
+
}
|
|
105
|
+
return { pushed };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The binding's memory after a release: every branch handed over is
|
|
109
|
+
* remembered once — a branch pushed again moves to the end with its current
|
|
110
|
+
* pull request and time — and only the newest `OWN_BRANCHES_MAX` are kept.
|
|
111
|
+
* Stored BEFORE any eviction decision, so the fact survives the tree. */
|
|
112
|
+
export function rememberOwnBranches(
|
|
113
|
+
existing: readonly OwnBranch[] | undefined,
|
|
114
|
+
pushed: readonly PushedBranch[],
|
|
115
|
+
at: string,
|
|
116
|
+
): OwnBranch[] {
|
|
117
|
+
const refs = new Set(pushed.map((p) => p.ref));
|
|
118
|
+
const kept = (existing ?? []).filter((b) => !refs.has(b.ref));
|
|
119
|
+
const added = pushed.map((p) => ({ ref: p.ref, pr: p.pr, at }));
|
|
120
|
+
return [...kept, ...added].slice(-OWN_BRANCHES_MAX);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether the thread's own runs pushed `ref`, as the binding remembers it. */
|
|
124
|
+
export function isOwnBranch(binding: { ownBranches?: readonly OwnBranch[] }, ref: string): boolean {
|
|
125
|
+
return (binding.ownBranches ?? []).some((b) => b.ref === ref);
|
|
126
|
+
}
|
|
127
|
+
|
|
68
128
|
/** How a binding's ref was chosen: the repo default for want of a named
|
|
69
129
|
* branch, or a branch someone named. */
|
|
70
130
|
export type BoundBy = "default" | "name";
|
|
@@ -109,6 +169,8 @@ export interface RebindableBinding {
|
|
|
109
169
|
evicted?: boolean;
|
|
110
170
|
boundBy?: BoundBy;
|
|
111
171
|
rebound?: Rebound;
|
|
172
|
+
/** The branches the thread's own runs pushed, handed over at each release. */
|
|
173
|
+
ownBranches?: OwnBranch[];
|
|
112
174
|
}
|
|
113
175
|
|
|
114
176
|
export type RebindPlan =
|
|
@@ -117,8 +179,14 @@ export type RebindPlan =
|
|
|
117
179
|
| { kind: "none" }
|
|
118
180
|
/** The binding alone rules it out; nothing on disk is consulted. */
|
|
119
181
|
| { kind: "refuse"; refused: RebindRefused }
|
|
120
|
-
/** The binding allows it; the tree decides
|
|
121
|
-
|
|
182
|
+
/** The binding allows it and has a live tree; the tree decides
|
|
183
|
+
* (`rebindVerdict`). `own`: the thread's own runs pushed the branch, so a
|
|
184
|
+
* tree that turns out missing may still be recreated at it. */
|
|
185
|
+
| { kind: "measure"; from: string; to: string; pr: number; own: boolean }
|
|
186
|
+
/** The binding allows it, its tree was evicted, and the thread's own runs
|
|
187
|
+
* pushed the branch: the binding moves and the attach recreates the tree
|
|
188
|
+
* at the branch — once the mirror is known to hold it. */
|
|
189
|
+
| { kind: "recreate"; from: string; to: string; pr: number };
|
|
122
190
|
|
|
123
191
|
export function rebindRefused(plan: { to: string; pr: number }, reason: RebindRefusal, why: string): RebindRefused {
|
|
124
192
|
return { to: plan.to, pr: plan.pr, reason, why };
|
|
@@ -157,17 +225,19 @@ export function rebindPlan(input: {
|
|
|
157
225
|
),
|
|
158
226
|
};
|
|
159
227
|
}
|
|
228
|
+
const own = isOwnBranch(binding, plan.to);
|
|
160
229
|
if (binding.evicted || !binding.user) {
|
|
230
|
+
if (own) return { kind: "recreate", from: binding.ref, ...plan };
|
|
161
231
|
return {
|
|
162
232
|
kind: "refuse",
|
|
163
233
|
refused: rebindRefused(
|
|
164
234
|
plan,
|
|
165
235
|
"branch-absent",
|
|
166
|
-
|
|
236
|
+
`the thread's worktree was evicted and none of its runs pushed ${JSON.stringify(plan.to)}; only a branch this thread's own run pushed moves it`,
|
|
167
237
|
),
|
|
168
238
|
};
|
|
169
239
|
}
|
|
170
|
-
return { kind: "measure", from: binding.ref, ...plan };
|
|
240
|
+
return { kind: "measure", from: binding.ref, ...plan, own };
|
|
171
241
|
}
|
|
172
242
|
|
|
173
243
|
/** What the attach measured about the thread's tree, as the thread user. Each
|
|
@@ -185,17 +255,24 @@ export interface RebindTreeFacts {
|
|
|
185
255
|
dirty?: boolean;
|
|
186
256
|
}
|
|
187
257
|
|
|
188
|
-
export type RebindVerdict =
|
|
258
|
+
export type RebindVerdict =
|
|
259
|
+
/** Check the branch out in the existing tree. */
|
|
260
|
+
| { kind: "rebind" }
|
|
261
|
+
/** The tree is gone (a slept container) and the thread's own runs pushed the
|
|
262
|
+
* branch: move the binding and let the attach recreate the tree at it. */
|
|
263
|
+
| { kind: "recreate" }
|
|
264
|
+
| { kind: "refuse"; refused: RebindRefused };
|
|
189
265
|
|
|
190
266
|
/** The tree's verdict on a measured plan. */
|
|
191
|
-
export function rebindVerdict(plan: { to: string; pr: number }, tree: RebindTreeFacts): RebindVerdict {
|
|
267
|
+
export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, tree: RebindTreeFacts): RebindVerdict {
|
|
192
268
|
if (!tree.exists) {
|
|
269
|
+
if (plan.own === true) return { kind: "recreate" };
|
|
193
270
|
return {
|
|
194
271
|
kind: "refuse",
|
|
195
272
|
refused: rebindRefused(
|
|
196
273
|
plan,
|
|
197
274
|
"branch-absent",
|
|
198
|
-
|
|
275
|
+
`the thread's worktree is missing and none of its runs pushed ${JSON.stringify(plan.to)}; the branch cannot be verified there`,
|
|
199
276
|
),
|
|
200
277
|
};
|
|
201
278
|
}
|
|
@@ -90,16 +90,6 @@ export function isFleetBusyError(err: unknown): boolean {
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/** The 0.12.x Durable Object's answer while its container is still booting:
|
|
94
|
-
* no session exists yet and nothing ran, so the SAME request can be re-sent
|
|
95
|
-
* after a short pause — the one retry the Worker still does itself. */
|
|
96
|
-
export const CONTAINER_STARTING_PATTERN = /^Container is starting\. Please retry in a moment\.?$/i;
|
|
97
|
-
|
|
98
|
-
export function isContainerStarting(err: unknown): boolean {
|
|
99
|
-
const { message } = thrownShape(err);
|
|
100
|
-
return !!message && CONTAINER_STARTING_PATTERN.test(message.trim());
|
|
101
|
-
}
|
|
102
|
-
|
|
103
93
|
/** The Worker's answer on /read and /write (sent as HTTP 503): the named
|
|
104
94
|
* reason plus an `error` that keeps the SDK's own message as the cause, so
|
|
105
95
|
* the logs and the model can still see what the platform actually said. */
|
|
@@ -160,35 +150,21 @@ export function thrownText(shape: ThrownShape): string {
|
|
|
160
150
|
|
|
161
151
|
// ---- A runtime that did not answer (docs/reference/specs/execution.md item 9) ----
|
|
162
152
|
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// server exited
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// on. The Worker reads the body first and names the condition instead.
|
|
153
|
+
// Every command and file operation reaches the container through the SDK's
|
|
154
|
+
// control connection to the container's port. When nothing answers there —
|
|
155
|
+
// the SDK's server exited, or the image's PID 1 is starting it again — the
|
|
156
|
+
// SDK's connect aborts after 30 s with a bare `The operation was aborted`:
|
|
157
|
+
// no container, no cause, nothing a card can act on. The Durable Object
|
|
158
|
+
// names the condition instead, with the facts a reader needs.
|
|
170
159
|
|
|
171
160
|
/** The named reason the Worker answers with, like `fleet-busy`: a machine
|
|
172
|
-
* token the executor
|
|
161
|
+
* token the executor matches on, never the SDK's text. */
|
|
173
162
|
export const RUNTIME_UNREACHABLE_REASON = "runtime-unreachable" as const;
|
|
174
163
|
|
|
175
164
|
/** The name the Worker's typed error carries across the Durable Object RPC
|
|
176
165
|
* boundary (which keeps `name`/`message` and drops the prototype). */
|
|
177
166
|
export const RUNTIME_UNREACHABLE_ERROR_NAME = "SandboxRuntimeUnreachableError";
|
|
178
167
|
|
|
179
|
-
/** The base class's two plain-text 500 bodies for a failed proxy to the
|
|
180
|
-
* container's port: the fetch itself failed (nothing listening — the server
|
|
181
|
-
* exited, or has not come back yet), and a connection lost mid-request. A
|
|
182
|
-
* JSON 500 from the server itself is neither: that server was alive. */
|
|
183
|
-
const PROXY_FAILURE_BODIES: readonly RegExp[] = [
|
|
184
|
-
/^Error proxying request to container\b/,
|
|
185
|
-
/^Container suddenly disconnected\b/,
|
|
186
|
-
];
|
|
187
|
-
|
|
188
|
-
export function isRuntimeProxyFailure(status: number, body: string): boolean {
|
|
189
|
-
return status === 500 && PROXY_FAILURE_BODIES.some((re) => re.test(body.trim()));
|
|
190
|
-
}
|
|
191
|
-
|
|
192
168
|
export interface RuntimeUnreachableFacts {
|
|
193
169
|
/** The Durable Object's id — the same word the `containers` log dataset carries as the container id. */
|
|
194
170
|
containerId: string;
|
|
@@ -196,7 +172,7 @@ export interface RuntimeUnreachableFacts {
|
|
|
196
172
|
running: boolean | undefined;
|
|
197
173
|
/** The Worker's `@cloudflare/sandbox` pin, which is also the image tag. */
|
|
198
174
|
sdkVersion: string;
|
|
199
|
-
/** The
|
|
175
|
+
/** The SDK's own text, verbatim: what it saw. */
|
|
200
176
|
cause: string;
|
|
201
177
|
}
|
|
202
178
|
|
|
@@ -209,13 +185,13 @@ export function runtimeUnreachableMessage(f: RuntimeUnreachableFacts): string {
|
|
|
209
185
|
return (
|
|
210
186
|
`${RUNTIME_UNREACHABLE_REASON}: the sandbox container's runtime did not answer ` +
|
|
211
187
|
`(container ${f.containerId}, sandbox SDK ${f.sdkVersion}; the platform reports the container ${platform}) — ` +
|
|
212
|
-
"
|
|
213
|
-
|
|
188
|
+
"nothing ran; if the container still runs, /workspace is intact and its runtime is being started again: " +
|
|
189
|
+
`wait a moment, then retry (${f.cause.trim() || "no detail from the SDK"})`
|
|
214
190
|
);
|
|
215
191
|
}
|
|
216
192
|
|
|
217
|
-
/** The Worker's typed error for a
|
|
218
|
-
*
|
|
193
|
+
/** The Worker's typed error for a silent control port: built inside the
|
|
194
|
+
* Durable Object with its facts, read by the fetch handler on the other side
|
|
219
195
|
* of the RPC boundary, so it is matched by name and by its message token,
|
|
220
196
|
* never by `instanceof`. */
|
|
221
197
|
export class SandboxRuntimeUnreachableError extends Error {
|
|
@@ -245,8 +221,8 @@ export function runtimeUnreachableAnswer(message: string): {
|
|
|
245
221
|
|
|
246
222
|
/** The `/exec` answer, in-body under the streamed HTTP 200 like every other exec
|
|
247
223
|
* failure: the dual `error` + exit-127/stderr shape (item 3) plus the reason.
|
|
248
|
-
* Never re-sent by anyone:
|
|
249
|
-
*
|
|
224
|
+
* Never re-sent by anyone: the executor re-sends nothing in-body, and the
|
|
225
|
+
* text tells the model to wait, then retry. */
|
|
250
226
|
export function runtimeUnreachableExecAnswer(message: string): {
|
|
251
227
|
error: string;
|
|
252
228
|
reason: typeof RUNTIME_UNREACHABLE_REASON;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The per-thread sandbox's lifecycle facts the Worker and the bot share
|
|
2
|
+
// (docs/reference/specs/execution.md items 1, 2 and 9): how long an idle
|
|
3
|
+
// container stays warm, and how a container replaced or restarted under a
|
|
4
|
+
// command is recognized and named. Deliberately free of node: imports so
|
|
5
|
+
// wrangler can bundle it into the sandbox Worker.
|
|
6
|
+
|
|
7
|
+
/** How long an IDLE container stays warm before the Durable Object stops it,
|
|
8
|
+
* in the Container class's own `<n>[smh]` grammar. Idle means idle: the SDK
|
|
9
|
+
* renews the activity timeout every second while a command's stream is open
|
|
10
|
+
* (its control connection's busy poll), so a running command never counts
|
|
11
|
+
* toward it. 5 minutes frees a finished thread's slot (`max_instances`)
|
|
12
|
+
* sooner than the SDK default (10 min), while a follow-up inside 5 minutes
|
|
13
|
+
* still lands on the same warm workspace; a later one re-clones, which is the
|
|
14
|
+
* documented per-thread degradation (item 1). */
|
|
15
|
+
export const SANDBOX_SLEEP_AFTER = "5m";
|
|
16
|
+
|
|
17
|
+
/** The texts the SDK produces when the container is torn down under a
|
|
18
|
+
* command, across generations. 0.3.x: the exec handler's generic wrapper (its
|
|
19
|
+
* real cause, "Session terminated", sat in a field the client discarded), the
|
|
20
|
+
* cause itself, and the stale-session answer the same attempt got once the
|
|
21
|
+
* sessions were cleared. 0.12.x: the typed `SessionTerminatedError` text
|
|
22
|
+
* (`Session '<id>' shell exited (exit code: <n>)`) and the
|
|
23
|
+
* `OperationInterruptedError` text for a container that stopped under a
|
|
24
|
+
* pending call, and the disconnect text for a sandbox `destroy()`ed under a
|
|
25
|
+
* pending call. Anything else — a transport error, a file-op failure — is
|
|
26
|
+
* never recycle-shaped, whenever it arrives. */
|
|
27
|
+
const RECYCLE_SHAPED: readonly RegExp[] = [
|
|
28
|
+
/^Command execution failed$/,
|
|
29
|
+
/^Session terminated$/i,
|
|
30
|
+
/^Session '[^']*' not found$/i,
|
|
31
|
+
/^Session '[^']*' shell exited \(exit code: -?\d+\)$/i,
|
|
32
|
+
/^The sandbox container stopped while the operation was pending\.?$/i,
|
|
33
|
+
/^The sandbox was destroyed while the operation was pending\.?$/i,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** The typed errors that MEAN the container's runtime went away under the
|
|
37
|
+
* call, across the 0.12 and 0.13 lines. Matched by name, not `instanceof`:
|
|
38
|
+
* the fetch handler sees them after the Durable Object RPC boundary, which
|
|
39
|
+
* keeps `name`/`message` and drops the prototype (inside the Durable Object,
|
|
40
|
+
* `instanceof` holds and decides first). */
|
|
41
|
+
export const RECYCLE_ERROR_NAMES: readonly string[] = [
|
|
42
|
+
"SessionTerminatedError",
|
|
43
|
+
"OperationInterruptedError",
|
|
44
|
+
"StaleProcessHandleError",
|
|
45
|
+
"RuntimeIdentityInactiveError",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Type first, text second: a typed recycle error, or a recycle-shaped text. */
|
|
49
|
+
export function isRecycleError(err: { name?: string; message?: string }): boolean {
|
|
50
|
+
if (err.name && RECYCLE_ERROR_NAMES.includes(err.name)) return true;
|
|
51
|
+
return !!err.message && RECYCLE_SHAPED.some((re) => re.test(err.message!.trim()));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Grace inside which a recycle-shaped TEXT is taken at face value: a session
|
|
55
|
+
* that fails to start does so in seconds, not minutes. A typed recycle error
|
|
56
|
+
* needs no grace — the SDK is stating the runtime went away. */
|
|
57
|
+
const RECYCLE_SUSPECT_AFTER_MS = 60_000;
|
|
58
|
+
|
|
59
|
+
/** The message `/exec` puts in-body when a command's failure looks like the
|
|
60
|
+
* container's runtime went away under it: a typed recycle error (`certain`),
|
|
61
|
+
* or a recycle-shaped text that arrived more than a minute into THIS attempt
|
|
62
|
+
* (a startup failure shows in seconds). Any other text, however late, is
|
|
63
|
+
* returned unchanged — timing alone never rewords an unrelated error. The
|
|
64
|
+
* exit code stays 127: it IS an infra failure and the command did not finish,
|
|
65
|
+
* so a faked exit 124 would tell the model to shorten a command that was
|
|
66
|
+
* never the problem. What the workspace holds afterwards depends on what
|
|
67
|
+
* went away: a replaced container has an empty `/workspace` (re-clone); a
|
|
68
|
+
* runtime the image's PID 1 started again (item 21) left it intact. The text
|
|
69
|
+
* says to look before assuming either. */
|
|
70
|
+
export function recycledMidCommandMessage(elapsedMs: number, msg: string, certain = false): string {
|
|
71
|
+
const shaped = RECYCLE_SHAPED.some((re) => re.test(msg.trim()));
|
|
72
|
+
if (!certain && (!shaped || elapsedMs <= RECYCLE_SUSPECT_AFTER_MS)) return msg;
|
|
73
|
+
const secs = Math.round(elapsedMs / 1_000);
|
|
74
|
+
return (
|
|
75
|
+
`sandbox recycled mid-command after ${secs}s — the container's runtime was replaced or restarted under the command, which did not finish; ` +
|
|
76
|
+
`check /workspace before continuing: it is empty if the container was replaced (re-clone), intact if only its runtime restarted (${msg})`
|
|
77
|
+
);
|
|
78
|
+
}
|