@coreplane/switchboard 1.227.0 → 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 (35) hide show
  1. package/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +129 -14
  3. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +22 -18
  4. package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
  5. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +11 -10
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +332 -299
  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/coordinator/contract.ts +6 -0
  14. package/dist/assets/src/core/coordinator/driver.ts +8 -6
  15. package/dist/assets/src/core/runEvents.ts +34 -1
  16. package/dist/assets/src/core/runFriction.ts +2 -1
  17. package/dist/assets/src/core/runRecord.ts +46 -3
  18. package/dist/assets/src/core/runUsage.ts +199 -0
  19. package/dist/assets/src/core/ship/coordinator.ts +4 -2
  20. package/dist/assets/src/execution/residentRebind.ts +84 -7
  21. package/dist/assets/src/execution/sandboxErrors.ts +14 -38
  22. package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
  23. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  24. package/dist/assets/web/dist/assets/{ResidentDetailPage-Chvll3wy.js → ResidentDetailPage-B1Q9pabX.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B5f8IwGF.js → ResidentsIndexPage-CP7U_4aK.js} +1 -1
  26. package/dist/assets/web/dist/assets/{RunRoutePage-CRvmCuXh.js → RunRoutePage-dCC25f_b.js} +4 -4
  27. package/dist/assets/web/dist/assets/{RunsIndexPage-BTJuKFTv.js → RunsIndexPage-Cgp4t4C8.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ScheduledPage-BVfgUBvP.js → ScheduledPage-DthDA2xG.js} +1 -1
  29. package/dist/assets/web/dist/assets/{StatusDot-CFXbAw7S.js → StatusDot-DDc88Kbs.js} +1 -1
  30. package/dist/assets/web/dist/assets/{Tooltip-DcHMtbHJ.js → Tooltip-CBapNhsh.js} +1 -1
  31. package/dist/assets/web/dist/assets/{dist-DKhqHu0V.js → dist-BnwSD1cL.js} +1 -1
  32. package/dist/assets/web/dist/assets/{main-CveRd2yk.js → main-ZhQGbZ2E.js} +2 -2
  33. package/dist/cli.js +808 -155
  34. package/package.json +1 -1
  35. package/dist/assets/src/execution/sandboxKeepalive.ts +0 -118
@@ -56,6 +56,14 @@ import {
56
56
  textOfStoredRow,
57
57
  } from "../../src/core/runLedger/sessionLog.ts";
58
58
  import type { RunEvent } from "../../src/core/runEvents.ts";
59
+ import {
60
+ aggregateUsageByUser,
61
+ isRunUsage,
62
+ usageOfEvents,
63
+ type RunUsage,
64
+ type RunUsageReport,
65
+ type UsageRun,
66
+ } from "../../src/core/runUsage.ts";
59
67
  import {
60
68
  checkFence,
61
69
  decideClaim,
@@ -1160,6 +1168,10 @@ export class RunHistoryDO extends DurableObject<Env> {
1160
1168
  // The session a run was a range of (session-log item 7), so the sweep can
1161
1169
  // tell which sessions still have a kept run; null for a record without one.
1162
1170
  if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1171
+ // What the run cost in tokens (costs.md, cost by user): the record's `usage`
1172
+ // as JSON; NULL for a record written before the field existed, until the
1173
+ // by-user aggregate fills it in from the run's stored events.
1174
+ if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
1163
1175
  this.sql.exec(`
1164
1176
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1165
1177
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
@@ -1820,8 +1832,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1820
1832
  );
1821
1833
  this.sql.exec(
1822
1834
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1823
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key)
1824
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1835
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json)
1836
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1825
1837
  ON CONFLICT(run_id) DO UPDATE SET
1826
1838
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1827
1839
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
@@ -1829,7 +1841,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1829
1841
  finished_at = excluded.finished_at, stored_at = excluded.stored_at, status = excluded.status,
1830
1842
  event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
1831
1843
  bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1832
- session_key = excluded.session_key`,
1844
+ session_key = excluded.session_key,
1845
+ usage_json = COALESCE(excluded.usage_json, runs.usage_json)`,
1833
1846
  stored.id,
1834
1847
  stored.label ?? null,
1835
1848
  stored.agent ?? null,
@@ -1850,6 +1863,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1850
1863
  JSON.stringify(stored.diagnosis),
1851
1864
  JSON.stringify(summary),
1852
1865
  stored.session?.key ?? null,
1866
+ stored.usage ? JSON.stringify(stored.usage) : null,
1853
1867
  );
1854
1868
  // The session's registry row learns its newest finish (session-log item
1855
1869
  // 7); a record that reaches the store without a claim (the plain put
@@ -2053,6 +2067,83 @@ export class RunHistoryDO extends DurableObject<Env> {
2053
2067
  return summary ? { ...summary, bytes: row.bytes } : null;
2054
2068
  }
2055
2069
 
2070
+ /**
2071
+ * Who spent what, per UTC day (costs.md, cost by user), over the runs that
2072
+ * finished in [sinceMs, untilMs) and are inside the retention window. A row
2073
+ * written before `usage_json` existed is filled in here from its stored
2074
+ * `model.turn` events — up to USAGE_BACKFILL_PER_CALL a call, the rest
2075
+ * reported as `pending` — so the history heals as it is read, with no
2076
+ * operator step; a run without turns is written back as the zero usage so it
2077
+ * is not re-read. A child is billed to its parent's requester; a parent
2078
+ * outside the range is looked up by id.
2079
+ */
2080
+ usageByUser(sinceMs: number, untilMs: number): RunUsageReport {
2081
+ const now = systemClock();
2082
+ const { policy } = this.policyState();
2083
+ const cutoff = now - policy.retentionDays * 86_400_000;
2084
+ const rows = this.sql
2085
+ .exec<UsageRow>(
2086
+ `SELECT run_id, user_id, started_at, finished_at, usage_json, summary_json FROM runs
2087
+ WHERE finished_at >= ? AND finished_at < ? ORDER BY finished_at ASC, run_id ASC`,
2088
+ Math.max(sinceMs, cutoff),
2089
+ untilMs,
2090
+ )
2091
+ .toArray();
2092
+ let backfilled = 0;
2093
+ const runs: UsageRun[] = rows.map((row) => {
2094
+ let usage = parseUsageJson(row.usage_json);
2095
+ if (usage === undefined && backfilled < USAGE_BACKFILL_PER_CALL) {
2096
+ usage = usageOfEvents(this.modelTurnEvents(row.run_id));
2097
+ this.sql.exec(`UPDATE runs SET usage_json = ? WHERE run_id = ?`, JSON.stringify(usage), row.run_id);
2098
+ backfilled += 1;
2099
+ }
2100
+ const who = identityOfSummary(row.summary_json);
2101
+ return {
2102
+ id: row.run_id,
2103
+ userId: row.user_id,
2104
+ ...(who.userName ? { userName: who.userName } : {}),
2105
+ ...(who.parentRunId ? { parentRunId: who.parentRunId } : {}),
2106
+ startedAt: row.started_at,
2107
+ finishedAt: row.finished_at,
2108
+ ...(usage ? { usage } : {}),
2109
+ };
2110
+ });
2111
+ const lookupParent = (id: string) => {
2112
+ const p = this.sql
2113
+ .exec<{ user_id: string; summary_json: string }>(`SELECT user_id, summary_json FROM runs WHERE run_id = ?`, id)
2114
+ .toArray()[0];
2115
+ if (!p) return undefined;
2116
+ const who = identityOfSummary(p.summary_json);
2117
+ return { userId: p.user_id, ...(who.userName ? { userName: who.userName } : {}) };
2118
+ };
2119
+ const { rows: out, pending } = aggregateUsageByUser(runs, lookupParent);
2120
+ const earliest = this.sql
2121
+ .exec<{ m: number | null }>(`SELECT MIN(finished_at) AS m FROM runs WHERE finished_at >= ?`, cutoff)
2122
+ .one().m;
2123
+ return {
2124
+ rows: out,
2125
+ pending,
2126
+ ...(earliest !== null ? { earliestFinishedAt: earliest } : {}),
2127
+ retentionDays: policy.retentionDays,
2128
+ };
2129
+ }
2130
+
2131
+ /** The stored `model.turn` span ends of one run — the rows a backfill prices. */
2132
+ private modelTurnEvents(runId: string): RunEvent[] {
2133
+ const out: RunEvent[] = [];
2134
+ for (const r of this.sql
2135
+ .exec<{ json: string }>(`SELECT json FROM run_events WHERE run_id = ? AND json LIKE '%"model.turn"%'`, runId)
2136
+ .toArray()) {
2137
+ try {
2138
+ const e = JSON.parse(r.json) as RunEvent;
2139
+ if (e && e.type === "span_end") out.push(e);
2140
+ } catch {
2141
+ // an unparsable event row prices nothing
2142
+ }
2143
+ }
2144
+ return out;
2145
+ }
2146
+
2056
2147
  /**
2057
2148
  * Newest first (finished_at desc, run_id desc) among the rows the policy
2058
2149
  * keeps, filtered, capped at RUN_LIST_MAX_LIMIT. The `before`/`beforeId`
@@ -2181,6 +2272,43 @@ function parseEventRows(rows: readonly EventRow[]): StoredRunEvent[] {
2181
2272
  }
2182
2273
 
2183
2274
  /** The stored summary (record minus events); null when the row is unreadable. */
2275
+ /** How many usage-less rows one by-user read prices from their events before
2276
+ * reporting the rest as pending: a page load stays quick while the history heals. */
2277
+ const USAGE_BACKFILL_PER_CALL = 200;
2278
+
2279
+ type UsageRow = {
2280
+ run_id: string;
2281
+ user_id: string;
2282
+ started_at: number;
2283
+ finished_at: number;
2284
+ usage_json: string | null;
2285
+ summary_json: string;
2286
+ };
2287
+
2288
+ /** A stored `usage_json`, or undefined when absent or unreadable (then it is recomputed). */
2289
+ function parseUsageJson(raw: string | null): RunUsage | undefined {
2290
+ if (raw === null) return undefined;
2291
+ try {
2292
+ const parsed: unknown = JSON.parse(raw);
2293
+ return isRunUsage(parsed) ? parsed : undefined;
2294
+ } catch {
2295
+ return undefined;
2296
+ }
2297
+ }
2298
+
2299
+ /** The two identity fields the by-user aggregate needs off a summary, read leniently. */
2300
+ function identityOfSummary(raw: string): { userName?: string; parentRunId?: string } {
2301
+ try {
2302
+ const s = JSON.parse(raw) as Record<string, unknown>;
2303
+ return {
2304
+ ...(typeof s.userName === "string" && s.userName ? { userName: s.userName } : {}),
2305
+ ...(typeof s.parentRunId === "string" && s.parentRunId ? { parentRunId: s.parentRunId } : {}),
2306
+ };
2307
+ } catch {
2308
+ return {};
2309
+ }
2310
+ }
2311
+
2184
2312
  function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
2185
2313
  try {
2186
2314
  const parsed: unknown = JSON.parse(row.summary_json);
@@ -2237,6 +2365,23 @@ function parseRunPut(body: unknown): Validated<{ storeKey: string; record: RunRe
2237
2365
  }
2238
2366
 
2239
2367
  /** `{storeKey, id}` — the body of /runs/get, /runs/summary and /runs/delete, and the base of /runs/events. */
2368
+ /** At most a year of runs a call — the page asks for 90 days at most. */
2369
+ const USAGE_QUERY_MAX_SPAN_MS = 366 * 86_400_000;
2370
+
2371
+ function parseRunUsageQuery(body: unknown): Validated<{ storeKey: string; sinceMs: number; untilMs: number }> {
2372
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
2373
+ const b = body as Record<string, unknown>;
2374
+ const key = parseStoreKey(b);
2375
+ if (!key.ok) return key;
2376
+ const { sinceMs, untilMs } = b;
2377
+ if (typeof sinceMs !== "number" || !Number.isFinite(sinceMs) || sinceMs < 0)
2378
+ return invalid("sinceMs must be a non-negative number");
2379
+ if (typeof untilMs !== "number" || !Number.isFinite(untilMs) || untilMs <= sinceMs)
2380
+ return invalid("untilMs must be a number after sinceMs");
2381
+ if (untilMs - sinceMs > USAGE_QUERY_MAX_SPAN_MS) return invalid("the range may span at most 366 days");
2382
+ return { ok: true, value: { storeKey: key.value, sinceMs, untilMs } };
2383
+ }
2384
+
2240
2385
  function parseRunTarget(body: unknown): Validated<{ storeKey: string; id: string }> {
2241
2386
  if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
2242
2387
  const b = body as Record<string, unknown>;
@@ -3504,6 +3649,14 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
3504
3649
  );
3505
3650
  return json(result ?? { events: null });
3506
3651
  }
3652
+ if (pathname === "/runs/usage-by-user") {
3653
+ const parsed = parseRunUsageQuery(body);
3654
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
3655
+ const { storeKey, sinceMs, untilMs } = parsed.value;
3656
+ const report = await stub(storeKey).usageByUser(sinceMs, untilMs);
3657
+ console.log(`[runs/usage-by-user] ${storeKey} -> ${report.rows.length} rows, ${report.pending} pending`);
3658
+ return json(report);
3659
+ }
3507
3660
  // /runs/delete
3508
3661
  const parsed = parseRunTarget(body);
3509
3662
  if (!parsed.ok) return json({ error: parsed.error }, 400);
@@ -3526,6 +3679,7 @@ const ROUTES = new Set([
3526
3679
  "/runs/summary",
3527
3680
  "/runs/list",
3528
3681
  "/runs/events",
3682
+ "/runs/usage-by-user",
3529
3683
  "/runs/delete",
3530
3684
  ...LEDGER_ROUTES,
3531
3685
  ]);
@@ -88,12 +88,16 @@ import { decideWorktree, parseReuse, type WorktreeFacts } from "../../src/execut
88
88
  import {
89
89
  boundByFor,
90
90
  parseOwnPr,
91
+ parsePushed,
91
92
  parseRefByDefault,
92
93
  rebindPlan,
93
94
  rebindRefused,
94
95
  rebindVerdict,
96
+ rememberOwnBranches,
95
97
  type BoundBy,
98
+ type OwnBranch,
96
99
  type OwnPr,
100
+ type PushedBranch,
97
101
  type Rebound,
98
102
  type RebindRefused,
99
103
  type RebindTreeFacts,
@@ -994,6 +998,13 @@ interface ThreadBinding {
994
998
  * to, onto the branch the thread's own run opened a pull request on. Set,
995
999
  * the binding never moves again. */
996
1000
  rebound?: Rebound;
1001
+ /** The branches the thread's own runs pushed and the pull requests they
1002
+ * head, as each run's release handed them over (`/detach` `pushed`, item
1003
+ * 16a) — written before any eviction decision, so the fact outlives the
1004
+ * tree: a clean tree is released the moment a run ends, and this is what
1005
+ * lets the follow-up's attach move onto the branch and recreate the tree
1006
+ * there. Newest last; the oldest fall off past `OWN_BRANCHES_MAX`. */
1007
+ ownBranches?: OwnBranch[];
997
1008
  /** Allocated OS user (worker2..worker17); "" once evicted (pool released). */
998
1009
  user: string;
999
1010
  worktreePath: string;
@@ -1092,6 +1103,15 @@ interface RefHintReason {
1092
1103
  /** The body every bot always sent: a hint with no reason attached. */
1093
1104
  const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1094
1105
 
1106
+ /** What the rebind decided under the mutex (item 16): nothing to move (the
1107
+ * row is already on the branch), a named refusal, or the move — written on
1108
+ * the row, whether a checkout in the live tree or a ref the attach will
1109
+ * recreate the tree at. */
1110
+ type RebindOutcome =
1111
+ | { kind: "none"; binding: ThreadBinding }
1112
+ | { kind: "refuse"; refused: RebindRefused }
1113
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound };
1114
+
1095
1115
  /** Result of one /op test/build execution. `ok` is the command's
1096
1116
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
1097
1117
  interface OpRunOk {
@@ -4358,6 +4378,7 @@ export class ResidentDO extends Sandbox<Env> {
4358
4378
  ? {
4359
4379
  ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4360
4380
  ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4381
+ ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
4361
4382
  }
4362
4383
  : { boundBy }),
4363
4384
  };
@@ -4497,6 +4518,7 @@ export class ResidentDO extends Sandbox<Env> {
4497
4518
  reason.ownPr,
4498
4519
  reuse,
4499
4520
  facts.defaultRef,
4521
+ slug,
4500
4522
  );
4501
4523
  if ("error" in rebind) return rebind;
4502
4524
  const prior = rebind.binding;
@@ -4579,19 +4601,26 @@ export class ResidentDO extends Sandbox<Env> {
4579
4601
  ownPr: OwnPr | null,
4580
4602
  reuse: boolean,
4581
4603
  defaultRef: string,
4604
+ slug: string,
4582
4605
  ): Promise<{ binding: ThreadBinding | undefined; rebound?: Rebound; rebindRefused?: RebindRefused } | ThreadErr> {
4583
4606
  const plan = rebindPlan({ ownPr, reuse, binding: prior, defaultRef });
4584
4607
  if (plan.kind === "none" || prior === undefined) return { binding: prior };
4585
4608
  if (plan.kind === "refuse") return { binding: prior, rebindRefused: plan.refused };
4586
4609
  const key = threadBindingKey(prior.threadKey);
4587
- type Outcome =
4588
- | { kind: "none"; binding: ThreadBinding }
4589
- | { kind: "refuse"; refused: RebindRefused }
4590
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound };
4591
- let outcome: Outcome;
4610
+ // A tree that is gone is recreated at the branch by the attach (item 16):
4611
+ // the mirror must hold the branch first, and a branch pushed since the
4612
+ // last refresh cycle is fetched for — with a token minted here, before
4613
+ // the mutex, like the attach's own recovery fetch. Only when the binding
4614
+ // allows a recreate at all: an evicted tree, or a live one whose runs
4615
+ // pushed the branch (the tree may turn out missing).
4616
+ const fetchToken =
4617
+ (plan.kind === "recreate" || plan.own) && githubAppConfigured(this.env)
4618
+ ? ((await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null)
4619
+ : null;
4620
+ let outcome: RebindOutcome;
4592
4621
  try {
4593
4622
  outcome = (
4594
- await this.withMirrorLock(async (): Promise<Outcome> => {
4623
+ await this.withMirrorLock(async (): Promise<RebindOutcome> => {
4595
4624
  // The binding as it stands NOW, under the mutex — not the snapshot
4596
4625
  // the plan read before the wait: the move is judged on, and written
4597
4626
  // over, the row's current fields (its last attach time, its
@@ -4602,6 +4631,7 @@ export class ResidentDO extends Sandbox<Env> {
4602
4631
  const again = rebindPlan({ ownPr, reuse, binding: current, defaultRef });
4603
4632
  if (again.kind === "none") return { kind: "none", binding: current };
4604
4633
  if (again.kind === "refuse") return again;
4634
+ if (again.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4605
4635
  const wt = current.worktreePath;
4606
4636
  // The facts, as the thread user in the thread's own tree: is the
4607
4637
  // branch a local branch here (the physical trace of this thread's
@@ -4612,7 +4642,7 @@ export class ResidentDO extends Sandbox<Env> {
4612
4642
  const branch = await this.threadRun(
4613
4643
  current.user,
4614
4644
  wt,
4615
- `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${plan.to}`)}`,
4645
+ `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${again.to}`)}`,
4616
4646
  DEFAULT_EXEC_TIMEOUT_MS,
4617
4647
  );
4618
4648
  tree.branchExists = branch.exitCode === 0;
@@ -4625,13 +4655,16 @@ export class ResidentDO extends Sandbox<Env> {
4625
4655
  tree.readable = status.exitCode === 0;
4626
4656
  if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4627
4657
  }
4628
- const verdict = rebindVerdict(plan, tree);
4658
+ // Judged on the re-read plan: it carries whether the branch is the
4659
+ // thread's own, which the pre-lock plan lacks when it was a recreate.
4660
+ const verdict = rebindVerdict(again, tree);
4661
+ if (verdict.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4629
4662
  if (verdict.kind !== "rebind") return verdict;
4630
4663
  const startedAt = systemClock();
4631
4664
  const checkout = await this.threadRun(
4632
4665
  current.user,
4633
4666
  wt,
4634
- `git checkout --quiet ${shellQuote(plan.to)}`,
4667
+ `git checkout --quiet ${shellQuote(again.to)}`,
4635
4668
  DEFAULT_EXEC_TIMEOUT_MS,
4636
4669
  );
4637
4670
  this.stepTrace.getStore()?.record("rebind-checkout", {
@@ -4643,15 +4676,15 @@ export class ResidentDO extends Sandbox<Env> {
4643
4676
  if (checkout.exitCode !== 0) {
4644
4677
  const detail =
4645
4678
  checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4646
- return { kind: "refuse", refused: rebindRefused(plan, "checkout-failed", detail) };
4679
+ return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4647
4680
  }
4648
4681
  const rebound: Rebound = {
4649
4682
  from: current.ref,
4650
- to: plan.to,
4651
- pr: plan.pr,
4683
+ to: again.to,
4684
+ pr: again.pr,
4652
4685
  at: new Date(systemClock()).toISOString(),
4653
4686
  };
4654
- const moved: ThreadBinding = { ...current, ref: plan.to, rebound };
4687
+ const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4655
4688
  await this.ctx.storage.put(key, moved);
4656
4689
  return { kind: "rebound", moved, rebound };
4657
4690
  }, ATTACH_MUTEX_WAIT_MS)
@@ -4677,6 +4710,77 @@ export class ResidentDO extends Sandbox<Env> {
4677
4710
  return { binding: moved, rebound };
4678
4711
  }
4679
4712
 
4713
+ /** The move when the thread's tree is gone (item 16): the binding's ref
4714
+ * becomes the branch its own run pushed, and the attach that follows
4715
+ * recreates the tree at it — `ensureThreadWorktree` finds no tree and
4716
+ * clones the branch from the mirror. So the mirror must hold the branch:
4717
+ * one pushed since the last refresh cycle is fetched for here, under the
4718
+ * mutex; a branch still missing after the fetch (deleted after a merge, or
4719
+ * never pushed) is a `branch-absent` refusal and the binding stands — the
4720
+ * attach then goes on at the ref it had, never `unknown-ref`. Nothing on
4721
+ * disk is touched: no checkout (there is no tree), no clone (the attach's). */
4722
+ private async recreateAtOwnBranch(
4723
+ current: ThreadBinding,
4724
+ plan: { to: string; pr: number },
4725
+ fetchToken: string | null,
4726
+ ): Promise<RebindOutcome> {
4727
+ let present = await this.refExists(plan.to);
4728
+ if (!present) {
4729
+ // A fetch that fails — GitHub unreachable, no token for a private repo —
4730
+ // is not the attach's failure: the branch is then simply not in the
4731
+ // mirror, and the refusal below names it, so the binding stands and the
4732
+ // run goes on at the ref it had instead of the attach ending 500.
4733
+ try {
4734
+ await this.gitWithCred(
4735
+ fetchToken,
4736
+ ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
4737
+ "fetch",
4738
+ GIT_NETWORK_TIMEOUT_MS,
4739
+ );
4740
+ } catch (err) {
4741
+ console.log(
4742
+ `attach ${current.threadKey}: the fetch for ${plan.to} failed (${errMsg(err)}) — the rebind is refused, the attach goes on`,
4743
+ );
4744
+ }
4745
+ present = await this.refExists(plan.to);
4746
+ }
4747
+ if (!present) {
4748
+ return {
4749
+ kind: "refuse",
4750
+ refused: rebindRefused(
4751
+ plan,
4752
+ "branch-absent",
4753
+ `the thread's worktree is gone and the mirror does not hold ${JSON.stringify(plan.to)} (deleted after a merge, or never pushed); the tree cannot be recreated at it`,
4754
+ ),
4755
+ };
4756
+ }
4757
+ const rebound: Rebound = {
4758
+ from: current.ref,
4759
+ to: plan.to,
4760
+ pr: plan.pr,
4761
+ at: new Date(systemClock()).toISOString(),
4762
+ };
4763
+ const moved: ThreadBinding = { ...current, ref: plan.to, rebound };
4764
+ await this.ctx.storage.put(threadBindingKey(current.threadKey), moved);
4765
+ return { kind: "rebound", moved, rebound };
4766
+ }
4767
+
4768
+ /** The release's word on what the run pushed (`/detach` `pushed`, item
4769
+ * 16a), remembered on the binding BEFORE the detach decides anything: a
4770
+ * clean tree is evicted at once, a dirty one kept, an already-evicted
4771
+ * binding answered as such — and in every case the fact must outlive the
4772
+ * tree, since it is what a follow-up's rebind onto the thread's own pull
4773
+ * request branch reads once the tree is gone. No binding → nothing to
4774
+ * remember on (the detach answers its 404 next). */
4775
+ private async rememberOwnBranches(threadKey: string, pushed: readonly PushedBranch[]): Promise<void> {
4776
+ const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
4777
+ if (!binding) return;
4778
+ await this.ctx.storage.put(threadBindingKey(threadKey), {
4779
+ ...binding,
4780
+ ownBranches: rememberOwnBranches(binding.ownBranches, pushed, new Date(systemClock()).toISOString()),
4781
+ } satisfies ThreadBinding);
4782
+ }
4783
+
4680
4784
  /** The second half of an attach, past disk admission: mint, fetch + clone
4681
4785
  * under the mirror lock, deps, credentials, the binding write. Split from
4682
4786
  * `attachThreadBody` only so the admission's commitment is released on every
@@ -5910,7 +6014,10 @@ export class ResidentDO extends Sandbox<Env> {
5910
6014
  async detachThread(
5911
6015
  threadKey: string,
5912
6016
  force: boolean,
6017
+ /** What the ending run pushed (item 16a): remembered on the binding first, whatever the detach then decides. */
6018
+ pushed: readonly PushedBranch[] = [],
5913
6019
  ): Promise<{ released: boolean; reason?: string; user?: string } | ThreadErr> {
6020
+ if (pushed.length > 0) await this.rememberOwnBranches(threadKey, pushed);
5914
6021
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
5915
6022
  if (!binding) return { error: `no-binding: ${threadKey} has never attached to this resident`, status: 404 };
5916
6023
  if (binding.evicted || !binding.user) return { released: false, reason: "already-evicted" };
@@ -7785,7 +7892,15 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
7785
7892
  async function handleDetach(env: Env, body: Record<string, unknown>): Promise<Response> {
7786
7893
  const ctx = await resolveThreadRoute(env, body);
7787
7894
  if (ctx instanceof Response) return ctx;
7788
- const result = await ctx.stub.detachThread(ctx.threadKey, body.force === true);
7895
+ // What the run pushed (item 16a), each ref through the one ref pattern
7896
+ // before it can be stored or become a git argument.
7897
+ const pushed = parsePushed(body.pushed);
7898
+ if ("error" in pushed) return json({ error: pushed.error }, 400);
7899
+ for (const entry of pushed.pushed) {
7900
+ const ref = parseRef(entry.ref, "pushed[].ref");
7901
+ if ("error" in ref) return json({ error: ref.error }, 400);
7902
+ }
7903
+ const result = await ctx.stub.detachThread(ctx.threadKey, body.force === true, pushed.pushed);
7789
7904
  if ("error" in result) return threadErrResponse(result);
7790
7905
  return json(result);
7791
7906
  }
@@ -13,19 +13,21 @@
13
13
  FROM --platform=linux/amd64 docker.io/library/node:24.21.0-slim AS node
14
14
 
15
15
  # The tag MUST match the @cloudflare/sandbox version in package.json exactly
16
- # (no 'latest' tag exists) — bump both together.
17
- FROM docker.io/cloudflare/sandbox:0.12.9
16
+ # (no 'latest' tag exists on the @next line) — bump both together. The same
17
+ # base as the resident image: its container server is compiled with a Bun
18
+ # whose directory watcher no longer dies on an entry removed under it, which
19
+ # ended the 0.12.9 line's server mid-command (docs/reference/specs/execution.md
20
+ # item 21).
21
+ FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
18
22
  USER root
19
- # The SDK's container server kills any command after COMMAND_TIMEOUT_MS. On
20
- # 0.12.x the default is unlimited (unset → no limit; 0.3.x defaulted to 30s),
21
- # a per-exec `timeout` option wins over it, and a kill by it lands as a normal
22
- # result with a synthetic exit code (143/137), not a rejection. worker.ts
23
- # passes no per-call timeout, so this env var is the container-level lever.
24
- # Pure BACKSTOP: the real per-command limit is coreutils `timeout` in
25
- # worker.ts (280s default, raisable per call via the bash tool's timeoutMs up
26
- # to the 20-min ceiling BASH_TIMEOUT_MAX_MS), which yields a genuine exit 124
27
- # through the normal result path. This sits 40s above the 20-min ceiling so it
28
- # never wins the race. Bump alongside BASH_TIMEOUT_MAX_MS
23
+ # The SDK's container server kills any command after COMMAND_TIMEOUT_MS
24
+ # (unset → no limit). Pure BACKSTOP behind two closer ones: the real
25
+ # per-command limit is coreutils `timeout` in worker.ts (280s default,
26
+ # raisable per call via the bash tool's timeoutMs up to the 20-min ceiling
27
+ # BASH_TIMEOUT_MAX_MS), which yields a genuine exit 124 through the normal
28
+ # result path, and worker.ts launches each process with the SDK's own
29
+ # per-process `timeout` a margin above that. This sits 40s above the 20-min
30
+ # ceiling so it never wins the race. Bump alongside BASH_TIMEOUT_MAX_MS
29
31
  # (src/execution/bashTimeout.ts); changing it requires a container rebuild +
30
32
  # redeploy.
31
33
  ENV COMMAND_TIMEOUT_MS=1240000
@@ -53,8 +55,8 @@ RUN apt-get update \
53
55
  # execs the real client. src/deploy/sandboxDocker.test.ts holds its shape.
54
56
  COPY --chmod=0755 docker-wrapper.sh /usr/local/bin/docker
55
57
 
56
- # Node 24, not the base's. The published 0.12.9 base ships Node 22.23.2
57
- # (copied from node:22-slim: the binary at /usr/local/bin/node, npm and
58
+ # Node 24, not the base's. The 0.13.0-next base ships Node 22.23.2 like the
59
+ # 0.12.9 one (copied from node:22-slim: the binary at /usr/local/bin/node, npm and
58
60
  # corepack under /usr/local/lib/node_modules, npm/npx symlinked into it), while
59
61
  # this repository builds and tests on Node 24 (.nvmrc, CI) and its published
60
62
  # CLI requires `>=24`. A run that exercised the suite inside this image read a
@@ -174,9 +176,11 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
174
176
  && pi --version | grep -qx '0.85.1' \
175
177
  && pi --help | grep -q -- '--mode <mode>'
176
178
 
177
- # PID 1 keeps the container alive across a crash of the SDK's container
178
- # server (the supervisor's header says why); the base image's ENTRYPOINT is
179
- # the server itself, so one uncaught exception there ended the container.
179
+ # The container outlives a crash of the SDK's container server (the
180
+ # supervisor's header says why). The base image's ENTRYPOINT is tini running
181
+ # the server, so one uncaught exception there ended the container; here tini
182
+ # stays PID 1 — it reaps orphans and forwards the platform's signals — and
183
+ # runs the supervisor, which starts the server again when it exits.
180
184
  # src/deploy/sandboxRuntimeSupervisor.test.ts holds this pair of lines.
181
185
  COPY --chmod=0755 runtime-supervisor.sh /usr/local/bin/sandbox-runtime-supervisor
182
- ENTRYPOINT ["/usr/local/bin/sandbox-runtime-supervisor"]
186
+ ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sandbox-runtime-supervisor"]
@@ -16,7 +16,7 @@
16
16
  "secrets": "npm --prefix ../.. run --silent cli -- deploy secrets sandbox"
17
17
  },
18
18
  "dependencies": {
19
- "@cloudflare/sandbox": "0.12.9"
19
+ "@cloudflare/sandbox": "0.13.0-next.751.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@cloudflare/workers-types": "^5.20260904.1",
@@ -1,15 +1,16 @@
1
1
  #!/bin/sh
2
- # PID 1 of the cold sandbox: keeps the container alive across a crash of the
3
- # SDK's container server (/container-server/sandbox). That server calls
4
- # process.exit(1) on any uncaught exception, and as the base image's
5
- # ENTRYPOINT it was PID 1 — so one uncaught error ended the whole container:
6
- # /workspace gone, a detached pi with it, every later /exec a 500 until the
7
- # platform noticed. Started here as a child instead, the server's exit is a
8
- # restart a second later; the disk, the detached processes and the Durable
9
- # Object's view of a running container all survive, and the SDK recreates its
10
- # session on the next command (docs/reference/specs/execution.md item 21).
2
+ # The cold sandbox's process supervisor, run by tini (PID 1): keeps the
3
+ # container alive across a crash of the SDK's container server
4
+ # (/container-server/sandbox). That server calls process.exit(1) on any
5
+ # uncaught exception, and the base image runs it as tini's one child — so one
6
+ # uncaught error ended the whole container: /workspace gone, a detached pi
7
+ # with it, every later /exec a failure until the platform noticed. Started
8
+ # here as a child instead, the server's exit is a restart a second later; the
9
+ # disk, the detached processes and the Durable Object's view of a running
10
+ # container all survive, and the next command starts a fresh process on the
11
+ # new server (docs/reference/specs/execution.md item 21).
11
12
  #
12
- # A stop from the platform is SIGTERM to PID 1: forwarded to the server, and
13
+ # A stop from the platform is SIGTERM, which tini forwards here: forwarded to the server, and
13
14
  # nothing starts again — a stop that lands during the pause between two
14
15
  # starts, or before the new pid is known, still ends here — and the server's
15
16
  # own exit status becomes this script's. A terminal's INT is forwarded as