@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.
- package/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +354 -9
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +25 -14
- package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +68 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +343 -261
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +2 -2
- package/dist/assets/package-lock.json +7 -48
- package/dist/assets/package.json +1 -1
- package/dist/assets/project.json +2 -2
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +15 -0
- package/dist/assets/src/core/authz/policy.ts +8 -0
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +8 -6
- package/dist/assets/src/core/runEvents.ts +50 -3
- package/dist/assets/src/core/runFriction.ts +2 -1
- package/dist/assets/src/core/runRecord.ts +107 -3
- package/dist/assets/src/core/runUsage.ts +199 -0
- package/dist/assets/src/core/ship/coordinator.ts +4 -2
- package/dist/assets/src/execution/residentRebind.ts +310 -0
- package/dist/assets/src/execution/residentSteps.ts +1 -0
- package/dist/assets/src/execution/sandboxErrors.ts +85 -10
- package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BBOpejGX.js → ResidentDetailPage-B1Q9pabX.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-B8kFWHpB.js → ResidentsIndexPage-CP7U_4aK.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-COWeVtIE.js → RunRoutePage-dCC25f_b.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-BjH93cKx.js → RunsIndexPage-Cgp4t4C8.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-grlNKvCA.js → ScheduledPage-DthDA2xG.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BQNoaXO6.js → StatusDot-DDc88Kbs.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVCeIzaa.js → Tooltip-CBapNhsh.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-B5Wfk-oY.js → dist-BnwSD1cL.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CN0U7d6s.js → main-ZhQGbZ2E.js} +2 -2
- package/dist/cli.js +1277 -344
- package/package.json +1 -1
- 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(
|
|
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
|
|
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
|
-
|
|
4423
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
|
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.
|
|
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
|