@coreplane/switchboard 1.226.1 → 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.
- package/dist/assets/deploy/cloudflare-resident/worker.ts +238 -8
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +7 -0
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +49 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/authz/policy.ts +8 -0
- package/dist/assets/src/core/runEvents.ts +16 -2
- package/dist/assets/src/core/runRecord.ts +61 -0
- package/dist/assets/src/execution/residentRebind.ts +233 -0
- package/dist/assets/src/execution/residentSteps.ts +1 -0
- package/dist/assets/src/execution/sandboxErrors.ts +99 -0
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BBOpejGX.js → ResidentDetailPage-Chvll3wy.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-B8kFWHpB.js → ResidentsIndexPage-B5f8IwGF.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-COWeVtIE.js → RunRoutePage-CRvmCuXh.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-BjH93cKx.js → RunsIndexPage-BTJuKFTv.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-grlNKvCA.js → ScheduledPage-BVfgUBvP.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BQNoaXO6.js → StatusDot-CFXbAw7S.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVCeIzaa.js → Tooltip-DcHMtbHJ.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-B5Wfk-oY.js → dist-DKhqHu0V.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CN0U7d6s.js → main-CveRd2yk.js} +2 -2
- package/dist/cli.js +470 -190
- 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(
|
|
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
|
|
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
|
-
|
|
4423
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
17688
|
+
"version": "1.227.0",
|
|
17689
17689
|
"license": "Apache-2.0",
|
|
17690
17690
|
"dependencies": {
|
|
17691
17691
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
|
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":
|