@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.
Files changed (46) hide show
  1. package/dist/assets/config/config.example.yaml +7 -13
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +170 -30
  4. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +22 -18
  5. package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
  6. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +11 -10
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +332 -299
  8. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +2 -2
  9. package/dist/assets/package-lock.json +2542 -212
  10. package/dist/assets/package.json +2 -2
  11. package/dist/assets/project.json +2 -2
  12. package/dist/assets/source.json +3 -3
  13. package/dist/assets/src/agents/registry.ts +19 -34
  14. package/dist/assets/src/core/authz/policy.ts +8 -0
  15. package/dist/assets/src/core/authz/resource.ts +3 -1
  16. package/dist/assets/src/core/authz/types.ts +10 -1
  17. package/dist/assets/src/core/chatMessage.ts +1 -1
  18. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  19. package/dist/assets/src/core/coordinator/driver.ts +8 -6
  20. package/dist/assets/src/core/runEvents.ts +53 -14
  21. package/dist/assets/src/core/runFriction.ts +3 -2
  22. package/dist/assets/src/core/runRecord.ts +46 -3
  23. package/dist/assets/src/core/runUsage.ts +199 -0
  24. package/dist/assets/src/core/ship/coordinator.ts +4 -2
  25. package/dist/assets/src/execution/residentDepCache.ts +34 -8
  26. package/dist/assets/src/execution/residentRebind.ts +84 -7
  27. package/dist/assets/src/execution/sandboxErrors.ts +14 -38
  28. package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
  29. package/dist/assets/web/dist/.vite/manifest.json +20 -20
  30. package/dist/assets/web/dist/assets/CostsPage-DQg30mHr.js +2 -0
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-Chvll3wy.js → ResidentDetailPage-CkVYktwT.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B5f8IwGF.js → ResidentsIndexPage-BLanxf27.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunRoutePage-U3nwL8Df.js +13 -0
  34. package/dist/assets/web/dist/assets/{RunsIndexPage-BTJuKFTv.js → RunsIndexPage-DrIVxmpl.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ScheduledPage-BVfgUBvP.js → ScheduledPage-DpqubmIm.js} +1 -1
  36. package/dist/assets/web/dist/assets/{StatusDot-CFXbAw7S.js → StatusDot-BpD9MRge.js} +1 -1
  37. package/dist/assets/web/dist/assets/{Tooltip-DcHMtbHJ.js → Tooltip-BYv0WSrA.js} +1 -1
  38. package/dist/assets/web/dist/assets/{dist-DKhqHu0V.js → dist-BZmA5qTt.js} +1 -1
  39. package/dist/assets/web/dist/assets/{main-CveRd2yk.js → main-C4GOEklV.js} +2 -2
  40. package/dist/assets/web/dist/assets/main-CAVqMbiX.css +1 -0
  41. package/dist/cli.js +9094 -9223
  42. package/package.json +1 -2
  43. package/dist/assets/src/execution/sandboxKeepalive.ts +0 -118
  44. package/dist/assets/web/dist/assets/CostsPage-5pl3HB2F.js +0 -2
  45. package/dist/assets/web/dist/assets/RunRoutePage-CRvmCuXh.js +0 -12
  46. 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 runner (a plan branch, under its grant) or a person (any other branch). */
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 a plan branch's pull request, and ship never approves.",
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 warm checkout's matching subpath (fresh inodes),
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
- lines.push(`rm -rf ${shellQuote(p)} || { echo err=deps-mutable-rm; exit 1; }`);
313
- lines.push(`cp -R ${shellQuote(`${srcRoot}${rel}`)} ${shellQuote(p)} || { echo err=deps-mutable-copy; exit 1; }`);
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 ${shellQuote(p)} || { echo err=deps-mutable-chmod; exit 1; }`);
318
- lines.push(`chown -Rh ${owner} ${shellQuote(p)} || { echo err=deps-mutable-chown; exit 1; }`);
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 (`rebindVerdict`). */
121
- | { kind: "measure"; from: string; to: string; pr: number };
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
- "the thread's worktree was evicted; the branch cannot be verified there",
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 = { kind: "rebind" } | { kind: "refuse"; refused: RebindRefused };
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
- "the thread's worktree is missing; the branch cannot be verified there",
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
- // The SDK proxies every command to the container's port through the
164
- // `@cloudflare/containers` base class. When nothing answers there — the SDK's
165
- // server exited (an uncaught exception ends it), or is being started again by
166
- // the image's PID 1 — that class answers HTTP 500 with a plain-text body, not
167
- // the JSON the SDK client expects, and the client renders it as the bare
168
- // `HTTP error! status: 500`: no container, no cause, nothing a card can act
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 and the harness match on, never the SDK's text. */
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 base class's body, verbatim: what the platform said. */
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
- "its server exited and the image's PID 1 starts it again within seconds; this command may not have run and " +
213
- `/workspace is intact: wait a moment, check, then retry (${f.cause.trim() || "no detail from the platform"})`
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 failed proxy: thrown from `containerFetch`
218
- * inside the Durable Object, caught by the Worker's routes on the other side
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: a command in flight when the server died may have
249
- * run; the text tells the model to check before it retries. */
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
+ }