@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
@@ -85,6 +85,23 @@ import { selectBindingsToPurge } from "../../src/execution/bindingPurge.js";
85
85
  import { busyAfterKillReason, planForceDetach } from "../../src/execution/residentDetach.js";
86
86
  import { parseReadonly, planReadonlyAttach } from "../../src/execution/residentReadonly.js";
87
87
  import { decideWorktree, parseReuse, type WorktreeFacts } from "../../src/execution/residentReuse.js";
88
+ import {
89
+ boundByFor,
90
+ parseOwnPr,
91
+ parsePushed,
92
+ parseRefByDefault,
93
+ rebindPlan,
94
+ rebindRefused,
95
+ rebindVerdict,
96
+ rememberOwnBranches,
97
+ type BoundBy,
98
+ type OwnBranch,
99
+ type OwnPr,
100
+ type PushedBranch,
101
+ type Rebound,
102
+ type RebindRefused,
103
+ type RebindTreeFacts,
104
+ } from "../../src/execution/residentRebind.js";
88
105
  import {
89
106
  depCacheScript,
90
107
  mutableCachePaths,
@@ -966,10 +983,28 @@ interface ResidentRecord {
966
983
 
967
984
  /** Per-thread binding: persisted in the resident DO keyed by
968
985
  * threadKey; survives restarts and eviction. `user`/`evicted` describe the
969
- * current allocation; `ref` is sticky for the thread's whole life. */
986
+ * current allocation; `ref` is sticky for the thread's whole life, with one
987
+ * exception — the branch the thread's own run opened a pull request on
988
+ * (`rebound`, docs/reference/specs/resident-repos.md item 16). */
970
989
  interface ThreadBinding {
971
990
  threadKey: string;
972
991
  ref: string;
992
+ /** How `ref` was chosen when the binding was made: the repo default for want
993
+ * of a named branch (item 30), or a branch someone named. Absent on a
994
+ * binding made before the field: read as `default` iff `ref` is the default
995
+ * branch. Only a `default` binding may ever move. */
996
+ boundBy?: BoundBy;
997
+ /** The one move a binding may make (item 16): from the default it was bound
998
+ * to, onto the branch the thread's own run opened a pull request on. Set,
999
+ * the binding never moves again. */
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[];
973
1008
  /** Allocated OS user (worker2..worker17); "" once evicted (pool released). */
974
1009
  user: string;
975
1010
  worktreePath: string;
@@ -1047,8 +1082,36 @@ interface AttachOk {
1047
1082
  /** Every command this attach ran, as offsets from its start (docs/reference/specs/
1048
1083
  * tracing.md item 19): the bot grafts them under its attach span. */
1049
1084
  trace: ResidentStep[];
1085
+ /** This attach moved the binding onto the branch the thread's own run opened
1086
+ * a pull request on (item 16): from where, to where, which PR. */
1087
+ rebound?: Rebound;
1088
+ /** The caller asked for that move and the binding stood: the branch, the PR
1089
+ * and why — so the bot can say where the follow-up runs and why. */
1090
+ rebindRefused?: RebindRefused;
1091
+ }
1092
+
1093
+ /** Why the caller's `refHint` is what it is (item 16), beside the hint itself:
1094
+ * `ownPr` — the pull request the thread's own run opened, whose head branch
1095
+ * the hint is (the one reason a binding may move; never a PR a person named);
1096
+ * `refByDefault` — the caller bound the resident's own default branch because
1097
+ * its message named none (item 30), recorded on a new binding as `boundBy`. */
1098
+ interface RefHintReason {
1099
+ ownPr: OwnPr | null;
1100
+ refByDefault: boolean;
1050
1101
  }
1051
1102
 
1103
+ /** The body every bot always sent: a hint with no reason attached. */
1104
+ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
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
+
1052
1115
  /** Result of one /op test/build execution. `ok` is the command's
1053
1116
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
1054
1117
  interface OpRunOk {
@@ -4286,6 +4349,8 @@ export class ResidentDO extends Sandbox<Env> {
4286
4349
  threadKey: string,
4287
4350
  ref: string,
4288
4351
  worktreePath: string,
4352
+ /** How a NEW binding's ref was chosen (item 16); an existing binding keeps its own record. */
4353
+ boundBy: BoundBy,
4289
4354
  ): Promise<{ binding: ThreadBinding; wrote: boolean } | ThreadErr> {
4290
4355
  const key = threadBindingKey(threadKey);
4291
4356
  const existing = await this.ctx.storage.get<ThreadBinding>(key);
@@ -4306,6 +4371,16 @@ export class ResidentDO extends Sandbox<Env> {
4306
4371
  boundAt: existing?.boundAt ?? now,
4307
4372
  lastAttachAt: now,
4308
4373
  evicted: false,
4374
+ // An evicted binding's own history: how its ref was chosen (absent on
4375
+ // one made before the field — never overwritten by this attach's flag)
4376
+ // and the one move it may have made.
4377
+ ...(existing
4378
+ ? {
4379
+ ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4380
+ ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4381
+ ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
4382
+ }
4383
+ : { boundBy }),
4309
4384
  };
4310
4385
  await this.ctx.storage.put(key, binding);
4311
4386
  return { binding, wrote: true };
@@ -4326,13 +4401,14 @@ export class ResidentDO extends Sandbox<Env> {
4326
4401
  reuse = false,
4327
4402
  record?: ResidentRecord,
4328
4403
  traceparent?: string,
4404
+ reason: RefHintReason = NO_REF_HINT_REASON,
4329
4405
  ): Promise<AttachOk | ThreadErr> {
4330
4406
  // One step trace per attach (docs/reference/specs/tracing.md item 19): every command
4331
4407
  // the attach runs lands on it, and the answer carries it.
4332
4408
  const t0 = systemClock();
4333
4409
  const trace = createStepTrace(t0);
4334
4410
  const res = await this.stepTrace.run(trace, () =>
4335
- this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0),
4411
+ this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0, reason),
4336
4412
  );
4337
4413
  // The same steps as the resident's own `resident.attach` root (item 22).
4338
4414
  emitStepRoot("resident.attach", t0, trace.steps(), traceparent, "error" in res ? refusalOutcome(res) : "ok");
@@ -4348,6 +4424,7 @@ export class ResidentDO extends Sandbox<Env> {
4348
4424
  reuse: boolean,
4349
4425
  record: ResidentRecord | undefined,
4350
4426
  t0: number,
4427
+ reason: RefHintReason,
4351
4428
  ): Promise<AttachOk | ThreadErr> {
4352
4429
  try {
4353
4430
  await this.ensureHydrated();
@@ -4365,7 +4442,17 @@ export class ResidentDO extends Sandbox<Env> {
4365
4442
  // container under it (and isIdle never parks the cycle mid-attach).
4366
4443
  this.attachesInFlight++;
4367
4444
  try {
4368
- return await this.attachThreadBody(threadKey, refHint, readonly, wantSha, reuse, resourceId, t0, record);
4445
+ return await this.attachThreadBody(
4446
+ threadKey,
4447
+ refHint,
4448
+ readonly,
4449
+ wantSha,
4450
+ reuse,
4451
+ resourceId,
4452
+ t0,
4453
+ record,
4454
+ reason,
4455
+ );
4369
4456
  } finally {
4370
4457
  this.attachesInFlight--;
4371
4458
  }
@@ -4399,7 +4486,8 @@ export class ResidentDO extends Sandbox<Env> {
4399
4486
  reuse: boolean,
4400
4487
  resourceId: string,
4401
4488
  t0: number,
4402
- recordFromRoute?: ResidentRecord,
4489
+ recordFromRoute: ResidentRecord | undefined,
4490
+ reason: RefHintReason,
4403
4491
  ): Promise<AttachOk | ThreadErr> {
4404
4492
  try {
4405
4493
  await this.refreshIfStale(resourceId);
@@ -4419,8 +4507,22 @@ export class ResidentDO extends Sandbox<Env> {
4419
4507
  const record = recordFromRoute ?? (await this.registry().getRecord(resource));
4420
4508
  if (!record || !facts) return { error: "not-serviceable: registry record or repo facts missing", status: 503 };
4421
4509
 
4422
- const prior = stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined;
4423
- const ref = prior?.ref ?? refHint; // the binding's ref wins for the thread's whole life
4510
+ // The binding's ref wins for the thread's whole life, with one exception
4511
+ // (item 16): a thread bound to the repo default for want of a named branch
4512
+ // moves, once and in place, onto the branch its own run opened a pull
4513
+ // request on — when that branch is a local branch of this thread's tree and
4514
+ // the tree is clean. Decided here, before the ref is chosen, so the rest of
4515
+ // the attach (fetch, stale check, deps, credentials) runs on the moved ref.
4516
+ const rebind = await this.rebindToOwnPr(
4517
+ stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined,
4518
+ reason.ownPr,
4519
+ reuse,
4520
+ facts.defaultRef,
4521
+ slug,
4522
+ );
4523
+ if ("error" in rebind) return rebind;
4524
+ const prior = rebind.binding;
4525
+ const ref = prior?.ref ?? refHint;
4424
4526
  if (!ref) {
4425
4527
  // Name the default branch so the bot can bind to it (loudly) instead of
4426
4528
  // asking the user when the message named no branch; the binding is still
@@ -4437,7 +4539,12 @@ export class ResidentDO extends Sandbox<Env> {
4437
4539
  // and the prior binding's mode — the tested pure helper is the shipped code.
4438
4540
  const mode = planReadonlyAttach({ readonly, prior, slug, mirrorDir: MIRROR_DIR });
4439
4541
 
4440
- const alloc = await this.allocateThreadUser(threadKey, ref, worktreePath);
4542
+ const alloc = await this.allocateThreadUser(
4543
+ threadKey,
4544
+ ref,
4545
+ worktreePath,
4546
+ boundByFor({ refByDefault: reason.refByDefault, ref, defaultRef: facts.defaultRef }),
4547
+ );
4441
4548
  if ("error" in alloc) return alloc;
4442
4549
  const binding = alloc.binding;
4443
4550
  // "Nothing created" on failure: a FRESH allocation (new binding or a
@@ -4468,12 +4575,212 @@ export class ResidentDO extends Sandbox<Env> {
4468
4575
  binding,
4469
4576
  mode,
4470
4577
  rollback,
4578
+ ...(rebind.rebound !== undefined ? { rebound: rebind.rebound } : {}),
4579
+ ...(rebind.rebindRefused !== undefined ? { rebindRefused: rebind.rebindRefused } : {}),
4471
4580
  });
4472
4581
  } finally {
4473
4582
  this.diskCommittedKiB -= admission.committedKiB;
4474
4583
  }
4475
4584
  }
4476
4585
 
4586
+ /** Item 16's one exception to the sticky binding: a thread bound to the repo
4587
+ * default for want of a named branch moves onto the branch its own run
4588
+ * opened a pull request on (`ownPr`), once, in place. The decision is the
4589
+ * pure `rebindPlan` / `rebindVerdict` of src/execution/residentRebind.ts;
4590
+ * this method measures the tree as the thread user and runs the verdict: a
4591
+ * `git checkout` inside the existing worktree — same path, same pool user,
4592
+ * no clone, no `rm -rf`, deps and snapshot lineage untouched — under the
4593
+ * mirror mutex so no other attach of the thread touches the tree meanwhile.
4594
+ * The rest of the attach then runs on the moved ref: the mirror fetch for
4595
+ * the PR head (item 51) and item 17's stale check, which keeps a tree whose
4596
+ * HEAD is the ref's tip or a descendant of it. The binding records the move
4597
+ * (`rebound`); the answer carries it, or the named refusal, so the bot can
4598
+ * say where the follow-up runs and why. A refusal never fails the attach. */
4599
+ private async rebindToOwnPr(
4600
+ prior: ThreadBinding | undefined,
4601
+ ownPr: OwnPr | null,
4602
+ reuse: boolean,
4603
+ defaultRef: string,
4604
+ slug: string,
4605
+ ): Promise<{ binding: ThreadBinding | undefined; rebound?: Rebound; rebindRefused?: RebindRefused } | ThreadErr> {
4606
+ const plan = rebindPlan({ ownPr, reuse, binding: prior, defaultRef });
4607
+ if (plan.kind === "none" || prior === undefined) return { binding: prior };
4608
+ if (plan.kind === "refuse") return { binding: prior, rebindRefused: plan.refused };
4609
+ const key = threadBindingKey(prior.threadKey);
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;
4621
+ try {
4622
+ outcome = (
4623
+ await this.withMirrorLock(async (): Promise<RebindOutcome> => {
4624
+ // The binding as it stands NOW, under the mutex — not the snapshot
4625
+ // the plan read before the wait: the move is judged on, and written
4626
+ // over, the row's current fields (its last attach time, its
4627
+ // credential stamp), and a premise that changed while waiting —
4628
+ // another attach already moved the ref — is re-judged, never
4629
+ // overwritten.
4630
+ const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? prior;
4631
+ const again = rebindPlan({ ownPr, reuse, binding: current, defaultRef });
4632
+ if (again.kind === "none") return { kind: "none", binding: current };
4633
+ if (again.kind === "refuse") return again;
4634
+ if (again.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4635
+ const wt = current.worktreePath;
4636
+ // The facts, as the thread user in the thread's own tree: is the
4637
+ // branch a local branch here (the physical trace of this thread's
4638
+ // run having made it), and are the tracked files clean — judged
4639
+ // only off a status git could read.
4640
+ const tree: RebindTreeFacts = { exists: (await this.run(["test", "-d", `${wt}/.git`])).exitCode === 0 };
4641
+ if (tree.exists) {
4642
+ const branch = await this.threadRun(
4643
+ current.user,
4644
+ wt,
4645
+ `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${again.to}`)}`,
4646
+ DEFAULT_EXEC_TIMEOUT_MS,
4647
+ );
4648
+ tree.branchExists = branch.exitCode === 0;
4649
+ const status = await this.threadRun(
4650
+ current.user,
4651
+ wt,
4652
+ "git status --porcelain -uno",
4653
+ DEFAULT_EXEC_TIMEOUT_MS,
4654
+ );
4655
+ tree.readable = status.exitCode === 0;
4656
+ if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4657
+ }
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);
4662
+ if (verdict.kind !== "rebind") return verdict;
4663
+ const startedAt = systemClock();
4664
+ const checkout = await this.threadRun(
4665
+ current.user,
4666
+ wt,
4667
+ `git checkout --quiet ${shellQuote(again.to)}`,
4668
+ DEFAULT_EXEC_TIMEOUT_MS,
4669
+ );
4670
+ this.stepTrace.getStore()?.record("rebind-checkout", {
4671
+ startedAt,
4672
+ endedAt: systemClock(),
4673
+ exitCode: checkout.exitCode,
4674
+ timedOut: checkout.timedOut,
4675
+ });
4676
+ if (checkout.exitCode !== 0) {
4677
+ const detail =
4678
+ checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4679
+ return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4680
+ }
4681
+ const rebound: Rebound = {
4682
+ from: current.ref,
4683
+ to: again.to,
4684
+ pr: again.pr,
4685
+ at: new Date(systemClock()).toISOString(),
4686
+ };
4687
+ const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4688
+ await this.ctx.storage.put(key, moved);
4689
+ return { kind: "rebound", moved, rebound };
4690
+ }, ATTACH_MUTEX_WAIT_MS)
4691
+ ).value;
4692
+ } catch (err) {
4693
+ if (err instanceof MirrorBusyError) {
4694
+ const s = await this.getStatus();
4695
+ return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
4696
+ }
4697
+ return { error: `attach-failed: ${errMsg(err)}`, status: 500 };
4698
+ }
4699
+ if (outcome.kind === "none") return { binding: outcome.binding };
4700
+ if (outcome.kind === "refuse") {
4701
+ console.log(
4702
+ `attach ${prior.threadKey}: kept on ${prior.ref} — rebind to ${plan.to} refused (${outcome.refused.reason}: ${outcome.refused.why})`,
4703
+ );
4704
+ return { binding: prior, rebindRefused: outcome.refused };
4705
+ }
4706
+ const { moved, rebound } = outcome;
4707
+ console.log(
4708
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4709
+ );
4710
+ return { binding: moved, rebound };
4711
+ }
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
+
4477
4784
  /** The second half of an attach, past disk admission: mint, fetch + clone
4478
4785
  * under the mirror lock, deps, credentials, the binding write. Split from
4479
4786
  * `attachThreadBody` only so the admission's commitment is released on every
@@ -4490,8 +4797,12 @@ export class ResidentDO extends Sandbox<Env> {
4490
4797
  binding: ThreadBinding;
4491
4798
  mode: ReturnType<typeof planReadonlyAttach>;
4492
4799
  rollback: () => Promise<void>;
4800
+ /** What `rebindToOwnPr` decided (item 16), for the answer. */
4801
+ rebound?: Rebound;
4802
+ rebindRefused?: RebindRefused;
4493
4803
  }): Promise<AttachOk | ThreadErr> {
4494
4804
  const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, binding, mode, rollback } = input;
4805
+ const { rebound, rebindRefused } = input;
4495
4806
 
4496
4807
  // Command-level token mint — before the lock so mint latency
4497
4808
  // never holds the mutex, and failure never blocks the attach.
@@ -4664,6 +4975,8 @@ export class ResidentDO extends Sandbox<Env> {
4664
4975
  mutexWaitMs: locked.waitedMs,
4665
4976
  attachMs: systemClock() - t0,
4666
4977
  trace: this.currentSteps(),
4978
+ ...(rebound !== undefined ? { rebound } : {}),
4979
+ ...(rebindRefused !== undefined ? { rebindRefused } : {}),
4667
4980
  };
4668
4981
  }
4669
4982
 
@@ -5701,7 +6014,10 @@ export class ResidentDO extends Sandbox<Env> {
5701
6014
  async detachThread(
5702
6015
  threadKey: string,
5703
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[] = [],
5704
6019
  ): Promise<{ released: boolean; reason?: string; user?: string } | ThreadErr> {
6020
+ if (pushed.length > 0) await this.rememberOwnBranches(threadKey, pushed);
5705
6021
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
5706
6022
  if (!binding) return { error: `no-binding: ${threadKey} has never attached to this resident`, status: 404 };
5707
6023
  if (binding.evicted || !binding.user) return { released: false, reason: "already-evicted" };
@@ -7540,13 +7856,34 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
7540
7856
  if ("error" in want) return json({ error: want.error }, 400);
7541
7857
  const reuse = parseReuse(body.reuse);
7542
7858
  if ("error" in reuse) return json({ error: reuse.error }, 400);
7859
+ // Why the hint is what it is (item 16): the thread's own pull request and
7860
+ // its head branch — the branch checked against the one ref pattern like
7861
+ // every ref, before it can become a git argument — and the bound-by-default flag.
7862
+ const parsedOwnPr = parseOwnPr(body.ownPr);
7863
+ if ("error" in parsedOwnPr) return json({ error: parsedOwnPr.error }, 400);
7864
+ if (parsedOwnPr.ownPr !== null) {
7865
+ const ownRef = parseRef(parsedOwnPr.ownPr.ref, "ownPr.ref");
7866
+ if ("error" in ownRef) return json({ error: ownRef.error }, 400);
7867
+ }
7868
+ const refByDefault = parseRefByDefault(body.refByDefault);
7869
+ if ("error" in refByDefault) return json({ error: refByDefault.error }, 400);
7870
+ const reason: RefHintReason = { ownPr: parsedOwnPr.ownPr, refByDefault: refByDefault.refByDefault };
7543
7871
  // Post-validation, the answer streams like /exec (item 59): heartbeat
7544
7872
  // whitespace then ONE JSON document over HTTP 200, so an attach that waits
7545
7873
  // on a deps install (minutes) cannot lose the connection the way a plain
7546
7874
  // response does (`fetch failed` a few minutes in). A refusal
7547
7875
  // carries its `status` in the body; `ResidentExecutor.attach` reads it there.
7548
7876
  return streamHeartbeatJson(
7549
- ctx.stub.attachThread(ctx.threadKey, refHint, readonly.readonly, want.sha, reuse.reuse, ctx.record, traceparent),
7877
+ ctx.stub.attachThread(
7878
+ ctx.threadKey,
7879
+ refHint,
7880
+ readonly.readonly,
7881
+ want.sha,
7882
+ reuse.reuse,
7883
+ ctx.record,
7884
+ traceparent,
7885
+ reason,
7886
+ ),
7550
7887
  (result) => result,
7551
7888
  (err) => ({ error: errMsg(err), status: 500 }),
7552
7889
  );
@@ -7555,7 +7892,15 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
7555
7892
  async function handleDetach(env: Env, body: Record<string, unknown>): Promise<Response> {
7556
7893
  const ctx = await resolveThreadRoute(env, body);
7557
7894
  if (ctx instanceof Response) return ctx;
7558
- 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);
7559
7904
  if ("error" in result) return threadErrResponse(result);
7560
7905
  return json(result);
7561
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
@@ -173,3 +175,12 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
173
175
  && npm cache clean --force \
174
176
  && pi --version | grep -qx '0.85.1' \
175
177
  && pi --help | grep -q -- '--mode <mode>'
178
+
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.
184
+ # src/deploy/sandboxRuntimeSupervisor.test.ts holds this pair of lines.
185
+ COPY --chmod=0755 runtime-supervisor.sh /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",
@@ -0,0 +1,68 @@
1
+ #!/bin/sh
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).
12
+ #
13
+ # A stop from the platform is SIGTERM, which tini forwards here: forwarded to the server, and
14
+ # nothing starts again — a stop that lands during the pause between two
15
+ # starts, or before the new pid is known, still ends here — and the server's
16
+ # own exit status becomes this script's. A terminal's INT is forwarded as
17
+ # TERM too: a child started with `&` by a non-interactive shell ignores INT
18
+ # (POSIX), so INT itself would stop nothing. A server that cannot stay up —
19
+ # QUICK_EXIT_LIMIT exits in a row, each within QUICK_EXIT_SECS of starting —
20
+ # ends the container with the last status instead of looping forever.
21
+ # src/deploy/sandboxRuntimeSupervisor.test.ts holds the shape and drives it.
22
+ set -u
23
+ RUNTIME=${SANDBOX_RUNTIME:-/container-server/sandbox}
24
+ QUICK_EXIT_SECS=10
25
+ QUICK_EXIT_LIMIT=5
26
+ pid=
27
+ stopping=
28
+ quick=0
29
+ code=0
30
+
31
+ on_signal() {
32
+ stopping=1
33
+ if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null; fi
34
+ }
35
+ trap on_signal TERM INT
36
+
37
+ while :; do
38
+ started=$(date +%s)
39
+ "$RUNTIME" "$@" &
40
+ pid=$!
41
+ # A stop that landed between the start and this line found no pid to
42
+ # forward to; the server it missed gets it here.
43
+ if [ -n "$stopping" ]; then kill -TERM "$pid" 2>/dev/null; fi
44
+ wait "$pid"
45
+ code=$?
46
+ # A trapped signal ends `wait` early (status > 128) while the server still
47
+ # runs; wait again until it is really gone, so the status is its own.
48
+ while kill -0 "$pid" 2>/dev/null; do
49
+ wait "$pid"
50
+ code=$?
51
+ done
52
+ pid=
53
+ if [ -n "$stopping" ]; then exit "$code"; fi
54
+ if [ $(( $(date +%s) - started )) -lt "$QUICK_EXIT_SECS" ]; then
55
+ quick=$((quick + 1))
56
+ else
57
+ quick=0
58
+ fi
59
+ if [ "$quick" -ge "$QUICK_EXIT_LIMIT" ]; then
60
+ echo "sandbox-runtime-supervisor: the runtime exited with status $code, $quick times in a row within ${QUICK_EXIT_SECS}s of starting; giving up" >&2
61
+ exit "$code"
62
+ fi
63
+ echo "sandbox-runtime-supervisor: the runtime exited with status $code; starting it again" >&2
64
+ sleep 1
65
+ # A stop that landed during the pause: the last status is the answer, and
66
+ # nothing starts again.
67
+ if [ -n "$stopping" ]; then exit "$code"; fi
68
+ done