@coreplane/switchboard 1.226.0 → 1.227.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 (25) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +238 -8
  2. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +7 -0
  3. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +67 -0
  4. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +49 -0
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/core/authz/policy.ts +8 -0
  9. package/dist/assets/src/core/runEvents.ts +16 -2
  10. package/dist/assets/src/core/runRecord.ts +61 -0
  11. package/dist/assets/src/execution/residentRebind.ts +233 -0
  12. package/dist/assets/src/execution/residentSteps.ts +1 -0
  13. package/dist/assets/src/execution/sandboxErrors.ts +99 -0
  14. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  15. package/dist/assets/web/dist/assets/{ResidentDetailPage-BBOpejGX.js → ResidentDetailPage-Chvll3wy.js} +1 -1
  16. package/dist/assets/web/dist/assets/{ResidentsIndexPage-B8kFWHpB.js → ResidentsIndexPage-B5f8IwGF.js} +1 -1
  17. package/dist/assets/web/dist/assets/{RunRoutePage-COWeVtIE.js → RunRoutePage-CRvmCuXh.js} +4 -4
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-BjH93cKx.js → RunsIndexPage-BTJuKFTv.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-grlNKvCA.js → ScheduledPage-BVfgUBvP.js} +1 -1
  20. package/dist/assets/web/dist/assets/{StatusDot-BQNoaXO6.js → StatusDot-CFXbAw7S.js} +1 -1
  21. package/dist/assets/web/dist/assets/{Tooltip-DVCeIzaa.js → Tooltip-DcHMtbHJ.js} +1 -1
  22. package/dist/assets/web/dist/assets/{dist-B5Wfk-oY.js → dist-DKhqHu0V.js} +1 -1
  23. package/dist/assets/web/dist/assets/{main-CN0U7d6s.js → main-CveRd2yk.js} +2 -2
  24. package/dist/cli.js +470 -190
  25. package/package.json +1 -1
@@ -85,6 +85,19 @@ 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
+ parseRefByDefault,
92
+ rebindPlan,
93
+ rebindRefused,
94
+ rebindVerdict,
95
+ type BoundBy,
96
+ type OwnPr,
97
+ type Rebound,
98
+ type RebindRefused,
99
+ type RebindTreeFacts,
100
+ } from "../../src/execution/residentRebind.js";
88
101
  import {
89
102
  depCacheScript,
90
103
  mutableCachePaths,
@@ -966,10 +979,21 @@ interface ResidentRecord {
966
979
 
967
980
  /** Per-thread binding: persisted in the resident DO keyed by
968
981
  * threadKey; survives restarts and eviction. `user`/`evicted` describe the
969
- * current allocation; `ref` is sticky for the thread's whole life. */
982
+ * current allocation; `ref` is sticky for the thread's whole life, with one
983
+ * exception — the branch the thread's own run opened a pull request on
984
+ * (`rebound`, docs/reference/specs/resident-repos.md item 16). */
970
985
  interface ThreadBinding {
971
986
  threadKey: string;
972
987
  ref: string;
988
+ /** How `ref` was chosen when the binding was made: the repo default for want
989
+ * of a named branch (item 30), or a branch someone named. Absent on a
990
+ * binding made before the field: read as `default` iff `ref` is the default
991
+ * branch. Only a `default` binding may ever move. */
992
+ boundBy?: BoundBy;
993
+ /** The one move a binding may make (item 16): from the default it was bound
994
+ * to, onto the branch the thread's own run opened a pull request on. Set,
995
+ * the binding never moves again. */
996
+ rebound?: Rebound;
973
997
  /** Allocated OS user (worker2..worker17); "" once evicted (pool released). */
974
998
  user: string;
975
999
  worktreePath: string;
@@ -1047,8 +1071,27 @@ interface AttachOk {
1047
1071
  /** Every command this attach ran, as offsets from its start (docs/reference/specs/
1048
1072
  * tracing.md item 19): the bot grafts them under its attach span. */
1049
1073
  trace: ResidentStep[];
1074
+ /** This attach moved the binding onto the branch the thread's own run opened
1075
+ * a pull request on (item 16): from where, to where, which PR. */
1076
+ rebound?: Rebound;
1077
+ /** The caller asked for that move and the binding stood: the branch, the PR
1078
+ * and why — so the bot can say where the follow-up runs and why. */
1079
+ rebindRefused?: RebindRefused;
1080
+ }
1081
+
1082
+ /** Why the caller's `refHint` is what it is (item 16), beside the hint itself:
1083
+ * `ownPr` — the pull request the thread's own run opened, whose head branch
1084
+ * the hint is (the one reason a binding may move; never a PR a person named);
1085
+ * `refByDefault` — the caller bound the resident's own default branch because
1086
+ * its message named none (item 30), recorded on a new binding as `boundBy`. */
1087
+ interface RefHintReason {
1088
+ ownPr: OwnPr | null;
1089
+ refByDefault: boolean;
1050
1090
  }
1051
1091
 
1092
+ /** The body every bot always sent: a hint with no reason attached. */
1093
+ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1094
+
1052
1095
  /** Result of one /op test/build execution. `ok` is the command's
1053
1096
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
1054
1097
  interface OpRunOk {
@@ -4286,6 +4329,8 @@ export class ResidentDO extends Sandbox<Env> {
4286
4329
  threadKey: string,
4287
4330
  ref: string,
4288
4331
  worktreePath: string,
4332
+ /** How a NEW binding's ref was chosen (item 16); an existing binding keeps its own record. */
4333
+ boundBy: BoundBy,
4289
4334
  ): Promise<{ binding: ThreadBinding; wrote: boolean } | ThreadErr> {
4290
4335
  const key = threadBindingKey(threadKey);
4291
4336
  const existing = await this.ctx.storage.get<ThreadBinding>(key);
@@ -4306,6 +4351,15 @@ export class ResidentDO extends Sandbox<Env> {
4306
4351
  boundAt: existing?.boundAt ?? now,
4307
4352
  lastAttachAt: now,
4308
4353
  evicted: false,
4354
+ // An evicted binding's own history: how its ref was chosen (absent on
4355
+ // one made before the field — never overwritten by this attach's flag)
4356
+ // and the one move it may have made.
4357
+ ...(existing
4358
+ ? {
4359
+ ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4360
+ ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4361
+ }
4362
+ : { boundBy }),
4309
4363
  };
4310
4364
  await this.ctx.storage.put(key, binding);
4311
4365
  return { binding, wrote: true };
@@ -4326,13 +4380,14 @@ export class ResidentDO extends Sandbox<Env> {
4326
4380
  reuse = false,
4327
4381
  record?: ResidentRecord,
4328
4382
  traceparent?: string,
4383
+ reason: RefHintReason = NO_REF_HINT_REASON,
4329
4384
  ): Promise<AttachOk | ThreadErr> {
4330
4385
  // One step trace per attach (docs/reference/specs/tracing.md item 19): every command
4331
4386
  // the attach runs lands on it, and the answer carries it.
4332
4387
  const t0 = systemClock();
4333
4388
  const trace = createStepTrace(t0);
4334
4389
  const res = await this.stepTrace.run(trace, () =>
4335
- this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0),
4390
+ this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0, reason),
4336
4391
  );
4337
4392
  // The same steps as the resident's own `resident.attach` root (item 22).
4338
4393
  emitStepRoot("resident.attach", t0, trace.steps(), traceparent, "error" in res ? refusalOutcome(res) : "ok");
@@ -4348,6 +4403,7 @@ export class ResidentDO extends Sandbox<Env> {
4348
4403
  reuse: boolean,
4349
4404
  record: ResidentRecord | undefined,
4350
4405
  t0: number,
4406
+ reason: RefHintReason,
4351
4407
  ): Promise<AttachOk | ThreadErr> {
4352
4408
  try {
4353
4409
  await this.ensureHydrated();
@@ -4365,7 +4421,17 @@ export class ResidentDO extends Sandbox<Env> {
4365
4421
  // container under it (and isIdle never parks the cycle mid-attach).
4366
4422
  this.attachesInFlight++;
4367
4423
  try {
4368
- return await this.attachThreadBody(threadKey, refHint, readonly, wantSha, reuse, resourceId, t0, record);
4424
+ return await this.attachThreadBody(
4425
+ threadKey,
4426
+ refHint,
4427
+ readonly,
4428
+ wantSha,
4429
+ reuse,
4430
+ resourceId,
4431
+ t0,
4432
+ record,
4433
+ reason,
4434
+ );
4369
4435
  } finally {
4370
4436
  this.attachesInFlight--;
4371
4437
  }
@@ -4399,7 +4465,8 @@ export class ResidentDO extends Sandbox<Env> {
4399
4465
  reuse: boolean,
4400
4466
  resourceId: string,
4401
4467
  t0: number,
4402
- recordFromRoute?: ResidentRecord,
4468
+ recordFromRoute: ResidentRecord | undefined,
4469
+ reason: RefHintReason,
4403
4470
  ): Promise<AttachOk | ThreadErr> {
4404
4471
  try {
4405
4472
  await this.refreshIfStale(resourceId);
@@ -4419,8 +4486,21 @@ export class ResidentDO extends Sandbox<Env> {
4419
4486
  const record = recordFromRoute ?? (await this.registry().getRecord(resource));
4420
4487
  if (!record || !facts) return { error: "not-serviceable: registry record or repo facts missing", status: 503 };
4421
4488
 
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
4489
+ // The binding's ref wins for the thread's whole life, with one exception
4490
+ // (item 16): a thread bound to the repo default for want of a named branch
4491
+ // moves, once and in place, onto the branch its own run opened a pull
4492
+ // request on — when that branch is a local branch of this thread's tree and
4493
+ // the tree is clean. Decided here, before the ref is chosen, so the rest of
4494
+ // the attach (fetch, stale check, deps, credentials) runs on the moved ref.
4495
+ const rebind = await this.rebindToOwnPr(
4496
+ stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined,
4497
+ reason.ownPr,
4498
+ reuse,
4499
+ facts.defaultRef,
4500
+ );
4501
+ if ("error" in rebind) return rebind;
4502
+ const prior = rebind.binding;
4503
+ const ref = prior?.ref ?? refHint;
4424
4504
  if (!ref) {
4425
4505
  // Name the default branch so the bot can bind to it (loudly) instead of
4426
4506
  // asking the user when the message named no branch; the binding is still
@@ -4437,7 +4517,12 @@ export class ResidentDO extends Sandbox<Env> {
4437
4517
  // and the prior binding's mode — the tested pure helper is the shipped code.
4438
4518
  const mode = planReadonlyAttach({ readonly, prior, slug, mirrorDir: MIRROR_DIR });
4439
4519
 
4440
- const alloc = await this.allocateThreadUser(threadKey, ref, worktreePath);
4520
+ const alloc = await this.allocateThreadUser(
4521
+ threadKey,
4522
+ ref,
4523
+ worktreePath,
4524
+ boundByFor({ refByDefault: reason.refByDefault, ref, defaultRef: facts.defaultRef }),
4525
+ );
4441
4526
  if ("error" in alloc) return alloc;
4442
4527
  const binding = alloc.binding;
4443
4528
  // "Nothing created" on failure: a FRESH allocation (new binding or a
@@ -4468,12 +4553,130 @@ export class ResidentDO extends Sandbox<Env> {
4468
4553
  binding,
4469
4554
  mode,
4470
4555
  rollback,
4556
+ ...(rebind.rebound !== undefined ? { rebound: rebind.rebound } : {}),
4557
+ ...(rebind.rebindRefused !== undefined ? { rebindRefused: rebind.rebindRefused } : {}),
4471
4558
  });
4472
4559
  } finally {
4473
4560
  this.diskCommittedKiB -= admission.committedKiB;
4474
4561
  }
4475
4562
  }
4476
4563
 
4564
+ /** Item 16's one exception to the sticky binding: a thread bound to the repo
4565
+ * default for want of a named branch moves onto the branch its own run
4566
+ * opened a pull request on (`ownPr`), once, in place. The decision is the
4567
+ * pure `rebindPlan` / `rebindVerdict` of src/execution/residentRebind.ts;
4568
+ * this method measures the tree as the thread user and runs the verdict: a
4569
+ * `git checkout` inside the existing worktree — same path, same pool user,
4570
+ * no clone, no `rm -rf`, deps and snapshot lineage untouched — under the
4571
+ * mirror mutex so no other attach of the thread touches the tree meanwhile.
4572
+ * The rest of the attach then runs on the moved ref: the mirror fetch for
4573
+ * the PR head (item 51) and item 17's stale check, which keeps a tree whose
4574
+ * HEAD is the ref's tip or a descendant of it. The binding records the move
4575
+ * (`rebound`); the answer carries it, or the named refusal, so the bot can
4576
+ * say where the follow-up runs and why. A refusal never fails the attach. */
4577
+ private async rebindToOwnPr(
4578
+ prior: ThreadBinding | undefined,
4579
+ ownPr: OwnPr | null,
4580
+ reuse: boolean,
4581
+ defaultRef: string,
4582
+ ): Promise<{ binding: ThreadBinding | undefined; rebound?: Rebound; rebindRefused?: RebindRefused } | ThreadErr> {
4583
+ const plan = rebindPlan({ ownPr, reuse, binding: prior, defaultRef });
4584
+ if (plan.kind === "none" || prior === undefined) return { binding: prior };
4585
+ if (plan.kind === "refuse") return { binding: prior, rebindRefused: plan.refused };
4586
+ 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;
4592
+ try {
4593
+ outcome = (
4594
+ await this.withMirrorLock(async (): Promise<Outcome> => {
4595
+ // The binding as it stands NOW, under the mutex — not the snapshot
4596
+ // the plan read before the wait: the move is judged on, and written
4597
+ // over, the row's current fields (its last attach time, its
4598
+ // credential stamp), and a premise that changed while waiting —
4599
+ // another attach already moved the ref — is re-judged, never
4600
+ // overwritten.
4601
+ const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? prior;
4602
+ const again = rebindPlan({ ownPr, reuse, binding: current, defaultRef });
4603
+ if (again.kind === "none") return { kind: "none", binding: current };
4604
+ if (again.kind === "refuse") return again;
4605
+ const wt = current.worktreePath;
4606
+ // The facts, as the thread user in the thread's own tree: is the
4607
+ // branch a local branch here (the physical trace of this thread's
4608
+ // run having made it), and are the tracked files clean — judged
4609
+ // only off a status git could read.
4610
+ const tree: RebindTreeFacts = { exists: (await this.run(["test", "-d", `${wt}/.git`])).exitCode === 0 };
4611
+ if (tree.exists) {
4612
+ const branch = await this.threadRun(
4613
+ current.user,
4614
+ wt,
4615
+ `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${plan.to}`)}`,
4616
+ DEFAULT_EXEC_TIMEOUT_MS,
4617
+ );
4618
+ tree.branchExists = branch.exitCode === 0;
4619
+ const status = await this.threadRun(
4620
+ current.user,
4621
+ wt,
4622
+ "git status --porcelain -uno",
4623
+ DEFAULT_EXEC_TIMEOUT_MS,
4624
+ );
4625
+ tree.readable = status.exitCode === 0;
4626
+ if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4627
+ }
4628
+ const verdict = rebindVerdict(plan, tree);
4629
+ if (verdict.kind !== "rebind") return verdict;
4630
+ const startedAt = systemClock();
4631
+ const checkout = await this.threadRun(
4632
+ current.user,
4633
+ wt,
4634
+ `git checkout --quiet ${shellQuote(plan.to)}`,
4635
+ DEFAULT_EXEC_TIMEOUT_MS,
4636
+ );
4637
+ this.stepTrace.getStore()?.record("rebind-checkout", {
4638
+ startedAt,
4639
+ endedAt: systemClock(),
4640
+ exitCode: checkout.exitCode,
4641
+ timedOut: checkout.timedOut,
4642
+ });
4643
+ if (checkout.exitCode !== 0) {
4644
+ const detail =
4645
+ checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4646
+ return { kind: "refuse", refused: rebindRefused(plan, "checkout-failed", detail) };
4647
+ }
4648
+ const rebound: Rebound = {
4649
+ from: current.ref,
4650
+ to: plan.to,
4651
+ pr: plan.pr,
4652
+ at: new Date(systemClock()).toISOString(),
4653
+ };
4654
+ const moved: ThreadBinding = { ...current, ref: plan.to, rebound };
4655
+ await this.ctx.storage.put(key, moved);
4656
+ return { kind: "rebound", moved, rebound };
4657
+ }, ATTACH_MUTEX_WAIT_MS)
4658
+ ).value;
4659
+ } catch (err) {
4660
+ if (err instanceof MirrorBusyError) {
4661
+ const s = await this.getStatus();
4662
+ return { error: errMsg(err), status: 503, state: s.state, reason: "mirror-busy" };
4663
+ }
4664
+ return { error: `attach-failed: ${errMsg(err)}`, status: 500 };
4665
+ }
4666
+ if (outcome.kind === "none") return { binding: outcome.binding };
4667
+ if (outcome.kind === "refuse") {
4668
+ console.log(
4669
+ `attach ${prior.threadKey}: kept on ${prior.ref} — rebind to ${plan.to} refused (${outcome.refused.reason}: ${outcome.refused.why})`,
4670
+ );
4671
+ return { binding: prior, rebindRefused: outcome.refused };
4672
+ }
4673
+ const { moved, rebound } = outcome;
4674
+ console.log(
4675
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4676
+ );
4677
+ return { binding: moved, rebound };
4678
+ }
4679
+
4477
4680
  /** The second half of an attach, past disk admission: mint, fetch + clone
4478
4681
  * under the mirror lock, deps, credentials, the binding write. Split from
4479
4682
  * `attachThreadBody` only so the admission's commitment is released on every
@@ -4490,8 +4693,12 @@ export class ResidentDO extends Sandbox<Env> {
4490
4693
  binding: ThreadBinding;
4491
4694
  mode: ReturnType<typeof planReadonlyAttach>;
4492
4695
  rollback: () => Promise<void>;
4696
+ /** What `rebindToOwnPr` decided (item 16), for the answer. */
4697
+ rebound?: Rebound;
4698
+ rebindRefused?: RebindRefused;
4493
4699
  }): Promise<AttachOk | ThreadErr> {
4494
4700
  const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, binding, mode, rollback } = input;
4701
+ const { rebound, rebindRefused } = input;
4495
4702
 
4496
4703
  // Command-level token mint — before the lock so mint latency
4497
4704
  // never holds the mutex, and failure never blocks the attach.
@@ -4664,6 +4871,8 @@ export class ResidentDO extends Sandbox<Env> {
4664
4871
  mutexWaitMs: locked.waitedMs,
4665
4872
  attachMs: systemClock() - t0,
4666
4873
  trace: this.currentSteps(),
4874
+ ...(rebound !== undefined ? { rebound } : {}),
4875
+ ...(rebindRefused !== undefined ? { rebindRefused } : {}),
4667
4876
  };
4668
4877
  }
4669
4878
 
@@ -7540,13 +7749,34 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
7540
7749
  if ("error" in want) return json({ error: want.error }, 400);
7541
7750
  const reuse = parseReuse(body.reuse);
7542
7751
  if ("error" in reuse) return json({ error: reuse.error }, 400);
7752
+ // Why the hint is what it is (item 16): the thread's own pull request and
7753
+ // its head branch — the branch checked against the one ref pattern like
7754
+ // every ref, before it can become a git argument — and the bound-by-default flag.
7755
+ const parsedOwnPr = parseOwnPr(body.ownPr);
7756
+ if ("error" in parsedOwnPr) return json({ error: parsedOwnPr.error }, 400);
7757
+ if (parsedOwnPr.ownPr !== null) {
7758
+ const ownRef = parseRef(parsedOwnPr.ownPr.ref, "ownPr.ref");
7759
+ if ("error" in ownRef) return json({ error: ownRef.error }, 400);
7760
+ }
7761
+ const refByDefault = parseRefByDefault(body.refByDefault);
7762
+ if ("error" in refByDefault) return json({ error: refByDefault.error }, 400);
7763
+ const reason: RefHintReason = { ownPr: parsedOwnPr.ownPr, refByDefault: refByDefault.refByDefault };
7543
7764
  // Post-validation, the answer streams like /exec (item 59): heartbeat
7544
7765
  // whitespace then ONE JSON document over HTTP 200, so an attach that waits
7545
7766
  // on a deps install (minutes) cannot lose the connection the way a plain
7546
7767
  // response does (`fetch failed` a few minutes in). A refusal
7547
7768
  // carries its `status` in the body; `ResidentExecutor.attach` reads it there.
7548
7769
  return streamHeartbeatJson(
7549
- ctx.stub.attachThread(ctx.threadKey, refHint, readonly.readonly, want.sha, reuse.reuse, ctx.record, traceparent),
7770
+ ctx.stub.attachThread(
7771
+ ctx.threadKey,
7772
+ refHint,
7773
+ readonly.readonly,
7774
+ want.sha,
7775
+ reuse.reuse,
7776
+ ctx.record,
7777
+ traceparent,
7778
+ reason,
7779
+ ),
7550
7780
  (result) => result,
7551
7781
  (err) => ({ error: errMsg(err), status: 500 }),
7552
7782
  );
@@ -173,3 +173,10 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
173
173
  && npm cache clean --force \
174
174
  && pi --version | grep -qx '0.85.1' \
175
175
  && pi --help | grep -q -- '--mode <mode>'
176
+
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.
180
+ # src/deploy/sandboxRuntimeSupervisor.test.ts holds this pair of lines.
181
+ COPY --chmod=0755 runtime-supervisor.sh /usr/local/bin/sandbox-runtime-supervisor
182
+ ENTRYPOINT ["/usr/local/bin/sandbox-runtime-supervisor"]
@@ -0,0 +1,67 @@
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).
11
+ #
12
+ # A stop from the platform is SIGTERM to PID 1: forwarded to the server, and
13
+ # nothing starts again — a stop that lands during the pause between two
14
+ # starts, or before the new pid is known, still ends here — and the server's
15
+ # own exit status becomes this script's. A terminal's INT is forwarded as
16
+ # TERM too: a child started with `&` by a non-interactive shell ignores INT
17
+ # (POSIX), so INT itself would stop nothing. A server that cannot stay up —
18
+ # QUICK_EXIT_LIMIT exits in a row, each within QUICK_EXIT_SECS of starting —
19
+ # ends the container with the last status instead of looping forever.
20
+ # src/deploy/sandboxRuntimeSupervisor.test.ts holds the shape and drives it.
21
+ set -u
22
+ RUNTIME=${SANDBOX_RUNTIME:-/container-server/sandbox}
23
+ QUICK_EXIT_SECS=10
24
+ QUICK_EXIT_LIMIT=5
25
+ pid=
26
+ stopping=
27
+ quick=0
28
+ code=0
29
+
30
+ on_signal() {
31
+ stopping=1
32
+ if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null; fi
33
+ }
34
+ trap on_signal TERM INT
35
+
36
+ while :; do
37
+ started=$(date +%s)
38
+ "$RUNTIME" "$@" &
39
+ pid=$!
40
+ # A stop that landed between the start and this line found no pid to
41
+ # forward to; the server it missed gets it here.
42
+ if [ -n "$stopping" ]; then kill -TERM "$pid" 2>/dev/null; fi
43
+ wait "$pid"
44
+ code=$?
45
+ # A trapped signal ends `wait` early (status > 128) while the server still
46
+ # runs; wait again until it is really gone, so the status is its own.
47
+ while kill -0 "$pid" 2>/dev/null; do
48
+ wait "$pid"
49
+ code=$?
50
+ done
51
+ pid=
52
+ if [ -n "$stopping" ]; then exit "$code"; fi
53
+ if [ $(( $(date +%s) - started )) -lt "$QUICK_EXIT_SECS" ]; then
54
+ quick=$((quick + 1))
55
+ else
56
+ quick=0
57
+ fi
58
+ if [ "$quick" -ge "$QUICK_EXIT_LIMIT" ]; then
59
+ 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
60
+ exit "$code"
61
+ fi
62
+ echo "sandbox-runtime-supervisor: the runtime exited with status $code; starting it again" >&2
63
+ sleep 1
64
+ # A stop that landed during the pause: the last status is the answer, and
65
+ # nothing starts again.
66
+ if [ -n "$stopping" ]; then exit "$code"; fi
67
+ done
@@ -40,6 +40,11 @@ import {
40
40
  fleetBusyExecAnswer,
41
41
  isContainerStarting,
42
42
  isFleetBusyError,
43
+ isRuntimeProxyFailure,
44
+ isRuntimeUnreachableError,
45
+ runtimeUnreachableAnswer,
46
+ runtimeUnreachableExecAnswer,
47
+ SandboxRuntimeUnreachableError,
43
48
  thrownShape,
44
49
  thrownText,
45
50
  } from "../../src/execution/sandboxErrors.js";
@@ -96,6 +101,38 @@ export class SwitchboardSandbox extends Sandbox {
96
101
  }
97
102
  }
98
103
 
104
+ // A proxied fetch that nothing answered (docs/reference/specs/execution.md
105
+ // item 9): the `@cloudflare/containers` base class turns a failed fetch to
106
+ // the container's port into a plain-text HTTP 500 — `Error proxying request
107
+ // to container: …` when nothing listens (the SDK's server exited, or the
108
+ // image's PID 1 has not started it again yet), `Container suddenly
109
+ // disconnected` when the connection dropped mid-request — and the SDK's
110
+ // client, expecting JSON, throws the bare `HTTP error! status: 500`. Read
111
+ // the body here, before the client does, and throw the named error with the
112
+ // facts a card and a log search need: this container's id (the Durable
113
+ // Object's id is the container id in the `containers` dataset), the SDK
114
+ // pin, and whether the platform still calls the container running. Every
115
+ // other answer passes through untouched — a 500 body that is not the proxy's
116
+ // is re-wrapped unread by the client.
117
+ override async containerFetch(
118
+ requestOrUrl: Request | string | URL,
119
+ portOrInit?: number | RequestInit,
120
+ portParam?: number,
121
+ ): Promise<Response> {
122
+ const res = await super.containerFetch(requestOrUrl, portOrInit, portParam);
123
+ if (res.status !== 500) return res;
124
+ const body = await res.text();
125
+ if (isRuntimeProxyFailure(res.status, body)) {
126
+ throw new SandboxRuntimeUnreachableError({
127
+ containerId: this.ctx.id.toString(),
128
+ running: this.ctx.container?.running,
129
+ sdkVersion: SDK_PIN,
130
+ cause: body,
131
+ });
132
+ }
133
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
134
+ }
135
+
99
136
  override async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
100
137
  return withActivityKeepalive(
101
138
  () => this.renewActivityTimeout(),
@@ -316,6 +353,11 @@ export default {
316
353
  // Never an empty text (item 3): a message-less SDK error is named as
317
354
  // such, with the rollout hint.
318
355
  const msg = thrownText(thrownShape(err));
356
+ // Nothing answered at the container's port (item 9): the file op never
357
+ // reached a server, so a 503 the executor's transport retry re-sends —
358
+ // by then the image's PID 1 has the server back — with the named reason
359
+ // and the container in the text.
360
+ if (isRuntimeUnreachableError(err)) return json(runtimeUnreachableAnswer(msg), 503);
319
361
  // A full fleet (docs/reference/specs/execution.md item 14): the SDK could not get a
320
362
  // container instance for this thread's Durable Object, so no session
321
363
  // exists and the file op never started — re-sending is safe by
@@ -406,6 +448,13 @@ function streamExec(
406
448
  // message-less SDK error is named, with the rollout hint. The
407
449
  // classifiers below still read the raw `shape`.
408
450
  const raw = thrownText(shape);
451
+ // Nothing answered at the container's port (item 9): named, with the
452
+ // container, in the dual shape — and never re-sent by anyone, since a
453
+ // command in flight when the server died may have run.
454
+ if (isRuntimeUnreachableError(err)) {
455
+ finish(runtimeUnreachableExecAnswer(raw));
456
+ return;
457
+ }
409
458
  // A full fleet (docs/reference/specs/execution.md item 14): session creation
410
459
  // failed because no container instance was free, so the command
411
460
  // never started — re-sending it is safe by construction. The named
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.226.0",
3
+ "version": "1.227.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.226.0",
9
+ "version": "1.227.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -17685,7 +17685,7 @@
17685
17685
  },
17686
17686
  "packages/switchboard": {
17687
17687
  "name": "@coreplane/switchboard",
17688
- "version": "1.226.0",
17688
+ "version": "1.227.0",
17689
17689
  "license": "Apache-2.0",
17690
17690
  "dependencies": {
17691
17691
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.226.0",
3
+ "version": "1.227.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.226.0",
3
- "commit": "994440466168ef0a5b2446eadcb6c56c59e52580",
4
- "builtAt": "2026-09-15T18:47:52.793Z"
2
+ "version": "1.227.0",
3
+ "commit": "ddc2b12dbf8da3149e5128cac35fb34b8b43755e",
4
+ "builtAt": "2026-09-15T19:54:11.830Z"
5
5
  }
@@ -58,6 +58,14 @@ export const POLICY: readonly Rule[] = [
58
58
  { action: "runs:read", resource: "command", when: [grant("runs:read")] },
59
59
  { action: "runs:write", resource: "command", when: [grant("runs:write")] },
60
60
 
61
+ // ── conversations ────────────────────────────────────────────────────────
62
+ // Who may point the bot at another thread (record 0037). Asked for a
63
+ // pointing actor (`pointingActor`: one membership, the origin channel, no
64
+ // grants), so `member-of` reads: a public channel from anywhere, a private
65
+ // channel only from inside it, an admin's `all` not consulted. The row has
66
+ // no caller until the references dispatch step lands behind its flag.
67
+ { action: "conversation:read", resource: "channel", when: [MEMBER_OF] },
68
+
61
69
  // ── review ───────────────────────────────────────────────────────────────
62
70
  // `review abridge` spends one Opus-class call and rewrites a stored record:
63
71
  // the grant admits the command (admins through `all`, operators by name;
@@ -116,6 +116,14 @@ export type RunNoteKind =
116
116
  * after the attach, before the first turn, so the run page explains a
117
117
  * sandbox run that shows resident steps. */
118
118
  | "cold_sandbox"
119
+ /** The resident kept this thread's binding where it was instead of moving
120
+ * it onto the branch the thread's own run opened a pull request on
121
+ * (docs/reference/specs/resident-repos.md item 16): the summary names the
122
+ * branch the run stays on, the PR, the reason and the resident's sentence.
123
+ * The same word the card carries; published by the dispatcher after the
124
+ * attach, before the first turn, so the run page explains a follow-up that
125
+ * runs on the default instead of on its thread's PR. */
126
+ | "rebind_refused"
119
127
  /** A coding run submitted a PR description but the post-step opened no
120
128
  * pull request because the branch it observed IS the base the pull
121
129
  * request would target (docs/reference/specs/pr-description.md item 5) —
@@ -170,6 +178,7 @@ export const RUN_NOTE_KINDS = [
170
178
  "seed",
171
179
  "description_turn",
172
180
  "cold_sandbox",
181
+ "rebind_refused",
173
182
  "pr_not_opened",
174
183
  "review_not_posted",
175
184
  "compacted",
@@ -249,7 +258,7 @@ export function isSpanRecord(e: { type: string }): e is SpanStartEvent | SpanEnd
249
258
 
250
259
  /** The protected head (docs/reference/specs/tracing.md; live-view item 2) — is this event head material? The root's start, `slack.receive` and the
251
260
  * `dispatch.*` span pairs, `input`, `context`, `run_meta`, `route`, and the
252
- * `mcp_unavailable` / `spans_dropped` notes. */
261
+ * `mcp_unavailable` / `spans_dropped` / `cold_sandbox` / `rebind_refused` notes. */
253
262
  export function isHeadMaterial(event: RunEvent): boolean {
254
263
  switch (event.type) {
255
264
  case "input":
@@ -258,7 +267,12 @@ export function isHeadMaterial(event: RunEvent): boolean {
258
267
  case "route":
259
268
  return true;
260
269
  case "run_note":
261
- return event.kind === "mcp_unavailable" || event.kind === "spans_dropped" || event.kind === "cold_sandbox";
270
+ return (
271
+ event.kind === "mcp_unavailable" ||
272
+ event.kind === "spans_dropped" ||
273
+ event.kind === "cold_sandbox" ||
274
+ event.kind === "rebind_refused"
275
+ );
262
276
  case "span_start":
263
277
  return event.name === "request" || event.name === "slack.receive" || event.name.startsWith("dispatch.");
264
278
  case "span_end":