@coreplane/switchboard 1.226.1 → 1.228.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 (37) hide show
  1. package/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +354 -9
  3. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +25 -14
  4. package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
  5. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +68 -0
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +343 -261
  7. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +2 -2
  8. package/dist/assets/package-lock.json +7 -48
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/project.json +2 -2
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +15 -0
  13. package/dist/assets/src/core/authz/policy.ts +8 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  15. package/dist/assets/src/core/coordinator/driver.ts +8 -6
  16. package/dist/assets/src/core/runEvents.ts +50 -3
  17. package/dist/assets/src/core/runFriction.ts +2 -1
  18. package/dist/assets/src/core/runRecord.ts +107 -3
  19. package/dist/assets/src/core/runUsage.ts +199 -0
  20. package/dist/assets/src/core/ship/coordinator.ts +4 -2
  21. package/dist/assets/src/execution/residentRebind.ts +310 -0
  22. package/dist/assets/src/execution/residentSteps.ts +1 -0
  23. package/dist/assets/src/execution/sandboxErrors.ts +85 -10
  24. package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
  25. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  26. package/dist/assets/web/dist/assets/{ResidentDetailPage-BBOpejGX.js → ResidentDetailPage-B1Q9pabX.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B8kFWHpB.js → ResidentsIndexPage-CP7U_4aK.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunRoutePage-COWeVtIE.js → RunRoutePage-dCC25f_b.js} +4 -4
  29. package/dist/assets/web/dist/assets/{RunsIndexPage-BjH93cKx.js → RunsIndexPage-Cgp4t4C8.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ScheduledPage-grlNKvCA.js → ScheduledPage-DthDA2xG.js} +1 -1
  31. package/dist/assets/web/dist/assets/{StatusDot-BQNoaXO6.js → StatusDot-DDc88Kbs.js} +1 -1
  32. package/dist/assets/web/dist/assets/{Tooltip-DVCeIzaa.js → Tooltip-CBapNhsh.js} +1 -1
  33. package/dist/assets/web/dist/assets/{dist-B5Wfk-oY.js → dist-BnwSD1cL.js} +1 -1
  34. package/dist/assets/web/dist/assets/{main-CN0U7d6s.js → main-ZhQGbZ2E.js} +2 -2
  35. package/dist/cli.js +1277 -344
  36. package/package.json +1 -1
  37. package/dist/assets/src/execution/sandboxKeepalive.ts +0 -118
@@ -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([
@@ -0,0 +1,310 @@
1
+ /** The one exception to the resident's sticky ref binding
2
+ * (deploy/cloudflare-resident/worker.ts `attachThreadBody`), kept pure and
3
+ * dependency-free so it is unit-testable from src/ and imported by the
4
+ * resident Worker like residentReuse and residentHead: the tested code IS the
5
+ * shipped code.
6
+ *
7
+ * Background: a thread's first attach binds a ref, and every later attach in
8
+ * the thread keeps it — a differing hint is ignored, so a stray word can never
9
+ * move a thread onto a stranger's branch. That rule has one blind spot: a
10
+ * thread bound to the repo default because its first message named no branch,
11
+ * whose own run then created a branch, pushed it and opened a pull request.
12
+ * The follow-up's target resolution binds that branch (the thread's own PR,
13
+ * docs/reference/specs/resident-repos.md item 29), the resident ignores it, and
14
+ * the follow-up runs on the default branch while the thread's work sits on
15
+ * the branch it made — a push from there would land on the default.
16
+ *
17
+ * Shape: the caller names the reason for its hint (`ownPr`: the pull request
18
+ * the thread's own run opened and its head branch — never a PR a person
19
+ * named). The resident moves the binding only when all of these hold:
20
+ * - the binding was made by default (the first message named no branch), read
21
+ * off `boundBy`, or, for a binding made before that field, off whether the
22
+ * ref is the default branch — a ref a person named is never moved;
23
+ * - the thread was not rebound before — a thread moves once;
24
+ * - the branch is a local branch of the thread's OWN worktree — the physical
25
+ * fact that this thread's run created it; a branch the tree never made is
26
+ * refused whatever the caller says;
27
+ * - the tree has no uncommitted tracked changes — never at the cost of work.
28
+ * The move is a `git checkout` inside the existing tree: same path, same pool
29
+ * user, deps and snapshot lineage untouched. Every refusal is named in the
30
+ * attach answer so the bot can say why the follow-up runs where it does. */
31
+
32
+ /** The pull request the thread's own run opened, and its head branch — the
33
+ * reason a caller's refHint is that branch. */
34
+ export interface OwnPr {
35
+ number: number;
36
+ ref: string;
37
+ }
38
+
39
+ export type ParsedOwnPr = { ownPr: OwnPr | null } | { error: string };
40
+
41
+ const OWN_PR_ERROR = "ownPr must be {number: <positive integer>, ref: <branch>} when present";
42
+
43
+ /** `/attach` body field `ownPr`: absent → null (the body every bot always
44
+ * sent); `{number, ref}` with a positive integer and a non-empty string →
45
+ * itself; anything else → a 400-shaped error. The ref's PATTERN is the
46
+ * Worker's to check, where it checks every ref (`parseRef`), before the
47
+ * string can become a git argument. */
48
+ export function parseOwnPr(value: unknown): ParsedOwnPr {
49
+ if (value === undefined) return { ownPr: null };
50
+ if (typeof value !== "object" || value === null) return { error: OWN_PR_ERROR };
51
+ const { number, ref } = value as { number?: unknown; ref?: unknown };
52
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0) return { error: OWN_PR_ERROR };
53
+ if (typeof ref !== "string" || ref.length === 0) return { error: OWN_PR_ERROR };
54
+ return { ownPr: { number, ref } };
55
+ }
56
+
57
+ export type ParsedRefByDefault = { refByDefault: boolean } | { error: string };
58
+
59
+ /** `/attach` body field `refByDefault`: the caller bound the resident's own
60
+ * default branch because its message named none (item 30). Absent → false; a
61
+ * boolean → itself; anything else → a 400-shaped error. */
62
+ export function parseRefByDefault(value: unknown): ParsedRefByDefault {
63
+ if (value === undefined) return { refByDefault: false };
64
+ if (typeof value === "boolean") return { refByDefault: value };
65
+ return { error: "refByDefault must be a boolean when present" };
66
+ }
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
+
128
+ /** How a binding's ref was chosen: the repo default for want of a named
129
+ * branch, or a branch someone named. */
130
+ export type BoundBy = "default" | "name";
131
+
132
+ /** What a NEW binding records: `default` only when the caller said it bound
133
+ * the default for want of a name AND the ref is that default — a flag on any
134
+ * other ref is a caller bug, and `name` is the direction that never moves. */
135
+ export function boundByFor(input: { refByDefault: boolean; ref: string; defaultRef: string }): BoundBy {
136
+ return input.refByDefault && input.ref === input.defaultRef ? "default" : "name";
137
+ }
138
+
139
+ /** How an EXISTING binding's ref was chosen: the recorded value, or, for a
140
+ * binding made before the field, `default` iff its ref is the default branch. */
141
+ export function boundByOf(binding: { ref: string; boundBy?: BoundBy }, defaultRef: string): BoundBy {
142
+ return binding.boundBy ?? (binding.ref === defaultRef ? "default" : "name");
143
+ }
144
+
145
+ /** The record a rebind leaves on the binding and in the attach answer. */
146
+ export interface Rebound {
147
+ from: string;
148
+ to: string;
149
+ pr: number;
150
+ at: string;
151
+ }
152
+
153
+ export type RebindRefusal = "named-ref" | "already-rebound" | "branch-absent" | "dirty" | "checkout-failed";
154
+
155
+ /** Why the binding stood, in the attach answer: the branch it was asked to
156
+ * move to, the pull request, the reason and its sentence. */
157
+ export interface RebindRefused {
158
+ to: string;
159
+ pr: number;
160
+ reason: RebindRefusal;
161
+ why: string;
162
+ }
163
+
164
+ /** What the plan reads off the thread's binding. */
165
+ export interface RebindableBinding {
166
+ ref: string;
167
+ /** "" once evicted: no tree, no user to measure it as. */
168
+ user: string;
169
+ evicted?: boolean;
170
+ boundBy?: BoundBy;
171
+ rebound?: Rebound;
172
+ /** The branches the thread's own runs pushed, handed over at each release. */
173
+ ownBranches?: OwnBranch[];
174
+ }
175
+
176
+ export type RebindPlan =
177
+ /** No hint, no binding yet (the hint binds as any refHint), the binding is
178
+ * already on that branch, or a resumed run's attach. */
179
+ | { kind: "none" }
180
+ /** The binding alone rules it out; nothing on disk is consulted. */
181
+ | { kind: "refuse"; refused: RebindRefused }
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 };
190
+
191
+ export function rebindRefused(plan: { to: string; pr: number }, reason: RebindRefusal, why: string): RebindRefused {
192
+ return { to: plan.to, pr: plan.pr, reason, why };
193
+ }
194
+
195
+ /** Whether the binding may move, read off the binding alone. */
196
+ export function rebindPlan(input: {
197
+ ownPr: OwnPr | null;
198
+ /** A resumed run's attach keeps the tree exactly as it stands (item 66): its
199
+ * HEAD is where the run left it and must not move under the run. */
200
+ reuse: boolean;
201
+ binding: RebindableBinding | undefined;
202
+ defaultRef: string;
203
+ }): RebindPlan {
204
+ const { ownPr, reuse, binding, defaultRef } = input;
205
+ if (ownPr === null || reuse || binding === undefined || binding.ref === ownPr.ref) return { kind: "none" };
206
+ const plan = { to: ownPr.ref, pr: ownPr.number };
207
+ if (boundByOf(binding, defaultRef) === "name") {
208
+ return {
209
+ kind: "refuse",
210
+ refused: rebindRefused(
211
+ plan,
212
+ "named-ref",
213
+ `the thread is bound to ${JSON.stringify(binding.ref)} by name; a named branch is never moved`,
214
+ ),
215
+ };
216
+ }
217
+ if (binding.rebound) {
218
+ const r = binding.rebound;
219
+ return {
220
+ kind: "refuse",
221
+ refused: rebindRefused(
222
+ plan,
223
+ "already-rebound",
224
+ `the thread was already rebound from ${JSON.stringify(r.from)} to ${JSON.stringify(r.to)} (its pull request #${r.pr}); a thread moves once`,
225
+ ),
226
+ };
227
+ }
228
+ const own = isOwnBranch(binding, plan.to);
229
+ if (binding.evicted || !binding.user) {
230
+ if (own) return { kind: "recreate", from: binding.ref, ...plan };
231
+ return {
232
+ kind: "refuse",
233
+ refused: rebindRefused(
234
+ plan,
235
+ "branch-absent",
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`,
237
+ ),
238
+ };
239
+ }
240
+ return { kind: "measure", from: binding.ref, ...plan, own };
241
+ }
242
+
243
+ /** What the attach measured about the thread's tree, as the thread user. Each
244
+ * probe past `exists` is measured only when the tree is there. */
245
+ export interface RebindTreeFacts {
246
+ /** `<worktree>/.git` is a directory. */
247
+ exists: boolean;
248
+ /** `git rev-parse --verify --quiet refs/heads/<to>` succeeded in the tree. */
249
+ branchExists?: boolean;
250
+ /** `git status --porcelain -uno` ran: false is a tree git cannot read
251
+ * (corrupt, or owned by an earlier pool user), where nothing is verifiable. */
252
+ readable?: boolean;
253
+ /** `git status --porcelain -uno` listed a tracked change (untracked scratch
254
+ * files are the thread's own state and survive a checkout). */
255
+ dirty?: boolean;
256
+ }
257
+
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 };
265
+
266
+ /** The tree's verdict on a measured plan. */
267
+ export function rebindVerdict(plan: { to: string; pr: number; own?: boolean }, tree: RebindTreeFacts): RebindVerdict {
268
+ if (!tree.exists) {
269
+ if (plan.own === true) return { kind: "recreate" };
270
+ return {
271
+ kind: "refuse",
272
+ refused: rebindRefused(
273
+ plan,
274
+ "branch-absent",
275
+ `the thread's worktree is missing and none of its runs pushed ${JSON.stringify(plan.to)}; the branch cannot be verified there`,
276
+ ),
277
+ };
278
+ }
279
+ if (tree.readable === false) {
280
+ return {
281
+ kind: "refuse",
282
+ refused: rebindRefused(
283
+ plan,
284
+ "branch-absent",
285
+ "the thread's worktree cannot be read; the branch cannot be verified there",
286
+ ),
287
+ };
288
+ }
289
+ if (tree.branchExists !== true) {
290
+ return {
291
+ kind: "refuse",
292
+ refused: rebindRefused(
293
+ plan,
294
+ "branch-absent",
295
+ `${JSON.stringify(plan.to)} is not a local branch of the thread's worktree; only a branch this thread's own run made moves it`,
296
+ ),
297
+ };
298
+ }
299
+ if (tree.dirty === true) {
300
+ return {
301
+ kind: "refuse",
302
+ refused: rebindRefused(
303
+ plan,
304
+ "dirty",
305
+ "the worktree has uncommitted changes on the bound branch; the binding stands until they are committed or discarded",
306
+ ),
307
+ };
308
+ }
309
+ return { kind: "rebind" };
310
+ }
@@ -18,6 +18,7 @@ export const RESIDENT_STEP_LABELS = {
18
18
  "for-each-ref": "listing the branches",
19
19
  checkout: "checking out the branch",
20
20
  "checkout-update": "updating the checkout",
21
+ "rebind-checkout": "checking out the thread's own branch",
21
22
  "rev-parse": "reading the commit",
22
23
  "cat-file": "checking the mirror for the commit",
23
24
  "show-ref": "reading the branch tip",
@@ -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. */
@@ -157,3 +147,88 @@ export function thrownText(shape: ThrownShape): string {
157
147
  "while a Worker/image rollout is in progress — retry in a minute"
158
148
  );
159
149
  }
150
+
151
+ // ---- A runtime that did not answer (docs/reference/specs/execution.md item 9) ----
152
+ //
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.
159
+
160
+ /** The named reason the Worker answers with, like `fleet-busy`: a machine
161
+ * token the executor matches on, never the SDK's text. */
162
+ export const RUNTIME_UNREACHABLE_REASON = "runtime-unreachable" as const;
163
+
164
+ /** The name the Worker's typed error carries across the Durable Object RPC
165
+ * boundary (which keeps `name`/`message` and drops the prototype). */
166
+ export const RUNTIME_UNREACHABLE_ERROR_NAME = "SandboxRuntimeUnreachableError";
167
+
168
+ export interface RuntimeUnreachableFacts {
169
+ /** The Durable Object's id — the same word the `containers` log dataset carries as the container id. */
170
+ containerId: string;
171
+ /** The platform's view at the moment of the failure (`ctx.container.running`). */
172
+ running: boolean | undefined;
173
+ /** The Worker's `@cloudflare/sandbox` pin, which is also the image tag. */
174
+ sdkVersion: string;
175
+ /** The SDK's own text, verbatim: what it saw. */
176
+ cause: string;
177
+ }
178
+
179
+ /** The text that names the condition: the token first (the executor and the
180
+ * harness read it), then the facts a reader needs to find the container in
181
+ * the logs and to judge the command — it may not have run, and the workspace
182
+ * is still there because the container is. */
183
+ export function runtimeUnreachableMessage(f: RuntimeUnreachableFacts): string {
184
+ const platform = f.running === true ? "running" : f.running === false ? "stopped" : "in a state it did not report";
185
+ return (
186
+ `${RUNTIME_UNREACHABLE_REASON}: the sandbox container's runtime did not answer ` +
187
+ `(container ${f.containerId}, sandbox SDK ${f.sdkVersion}; the platform reports the container ${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"})`
190
+ );
191
+ }
192
+
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
195
+ * of the RPC boundary, so it is matched by name and by its message token,
196
+ * never by `instanceof`. */
197
+ export class SandboxRuntimeUnreachableError extends Error {
198
+ readonly reason = RUNTIME_UNREACHABLE_REASON;
199
+ constructor(readonly facts: RuntimeUnreachableFacts) {
200
+ super(runtimeUnreachableMessage(facts));
201
+ this.name = RUNTIME_UNREACHABLE_ERROR_NAME;
202
+ }
203
+ }
204
+
205
+ /** Name first, token second: the typed error after the RPC boundary, or any
206
+ * message that starts with the token (an executor reading a Worker's text). */
207
+ export function isRuntimeUnreachableError(err: unknown): boolean {
208
+ const s = thrownShape(err);
209
+ return s.name === RUNTIME_UNREACHABLE_ERROR_NAME || !!s.message?.startsWith(`${RUNTIME_UNREACHABLE_REASON}:`);
210
+ }
211
+
212
+ /** The `/read` and `/write` answer (sent as HTTP 503): the named reason and the
213
+ * text as the error — a 503 the executor's transport retry re-sends, which is
214
+ * safe: a file op that never reached a server did nothing. */
215
+ export function runtimeUnreachableAnswer(message: string): {
216
+ error: string;
217
+ reason: typeof RUNTIME_UNREACHABLE_REASON;
218
+ } {
219
+ return { error: message, reason: RUNTIME_UNREACHABLE_REASON };
220
+ }
221
+
222
+ /** The `/exec` answer, in-body under the streamed HTTP 200 like every other exec
223
+ * failure: the dual `error` + exit-127/stderr shape (item 3) plus the reason.
224
+ * Never re-sent by anyone: the executor re-sends nothing in-body, and the
225
+ * text tells the model to wait, then retry. */
226
+ export function runtimeUnreachableExecAnswer(message: string): {
227
+ error: string;
228
+ reason: typeof RUNTIME_UNREACHABLE_REASON;
229
+ stdout: "";
230
+ stderr: string;
231
+ exitCode: 127;
232
+ } {
233
+ return { error: message, reason: RUNTIME_UNREACHABLE_REASON, stdout: "", stderr: message, exitCode: 127 };
234
+ }