@coreplane/switchboard 1.260.4 → 1.260.5

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 (50) hide show
  1. package/dist/assets/deploy/cloudflare/package.json +2 -2
  2. package/dist/assets/deploy/cloudflare-docs/package.json +1 -1
  3. package/dist/assets/deploy/cloudflare-memory/package.json +2 -2
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +205 -8
  5. package/dist/assets/deploy/cloudflare-resident/package.json +2 -2
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +124 -40
  7. package/dist/assets/deploy/cloudflare-sandbox/package.json +2 -2
  8. package/dist/assets/package-lock.json +712 -97
  9. package/dist/assets/package.json +4 -4
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/budgets.ts +5 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +5 -0
  13. package/dist/assets/src/core/coordinator/driver.ts +114 -25
  14. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  15. package/dist/assets/src/core/plane/decide.ts +19 -2
  16. package/dist/assets/src/core/provider.ts +380 -0
  17. package/dist/assets/src/core/refusal.ts +1 -0
  18. package/dist/assets/src/core/runEvents.ts +8 -0
  19. package/dist/assets/src/core/runLedger/types.ts +10 -1
  20. package/dist/assets/src/core/runRecord.ts +31 -0
  21. package/dist/assets/src/core/ship/contract.ts +2 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +487 -25
  23. package/dist/assets/src/core/types.ts +6 -0
  24. package/dist/assets/src/execution/residentRebind.ts +15 -0
  25. package/dist/assets/src/execution/residentReuse.ts +23 -2
  26. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  27. package/dist/assets/web/dist/assets/{DeliveryPage-BaemWKW8.js → DeliveryPage-Cf1joUyA.js} +1 -1
  28. package/dist/assets/web/dist/assets/{HomePage-fgKQHLaO.js → HomePage-DZEAnCCv.js} +1 -1
  29. package/dist/assets/web/dist/assets/{PendingTurnRow-vuWiDzvz.js → PendingTurnRow-4B8JwETU.js} +1 -1
  30. package/dist/assets/web/dist/assets/{PlanePage-DXMJf8FB.js → PlanePage-CHU2szQs.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-DdC8yU_S.js → ResidentDetailPage-L5SRcN_O.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CSM-KODv.js → ResidentsIndexPage-BIFf8qyE.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-DzPIshJx.js +1 -0
  34. package/dist/assets/web/dist/assets/{RunRoutePage-jo6NZxsP.js → RunRoutePage-Cru5IVux.js} +3 -3
  35. package/dist/assets/web/dist/assets/{RunsIndexPage-Dx1Y4sSq.js → RunsIndexPage-CBkdPAdj.js} +1 -1
  36. package/dist/assets/web/dist/assets/{ScheduledPage-BuB_NZyR.js → ScheduledPage-D_R55BDk.js} +1 -1
  37. package/dist/assets/web/dist/assets/{SettingsPage-Cn96jB6Q.js → SettingsPage-DlmZvAvf.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SilentTurn-D2cJfpEq.js → SilentTurn-PWMDMbMG.js} +1 -1
  39. package/dist/assets/web/dist/assets/{StatusDot-DDHUCPVJ.js → StatusDot-Bq0mYMFo.js} +1 -1
  40. package/dist/assets/web/dist/assets/{Tooltip-B4VIHkAI.js → Tooltip-Bs6zd7yE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{UnitRoutePage-C-2DfjLI.js → UnitRoutePage-BMGEZCvn.js} +1 -1
  42. package/dist/assets/web/dist/assets/budgets-0szUBIMv.js +1 -0
  43. package/dist/assets/web/dist/assets/{dist-Cf1CmCSK.js → dist-EkKVbIM-.js} +11 -11
  44. package/dist/assets/web/dist/assets/{indexRow-Dxg7C8s9.js → indexRow-5s3hNzZ9.js} +1 -1
  45. package/dist/assets/web/dist/assets/{main-DxPqaFu9.js → main-Disot0n_.js} +2 -2
  46. package/dist/assets/web/dist/assets/{sseReplay-BzoeVxZB.js → sseReplay-9gcMpylx.js} +1 -1
  47. package/dist/cli.js +1855 -401
  48. package/package.json +4 -4
  49. package/dist/assets/web/dist/assets/RunFoldRow-Csik3wD6.js +0 -1
  50. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +0 -1
@@ -96,8 +96,14 @@ import {
96
96
  type ResidentLevelsDoc,
97
97
  } from "./levels.js";
98
98
  import { parseReadonly, planReadonlyAttach } from "../../src/execution/residentReadonly.js";
99
- import { decideWorktree, parseReuse, type WorktreeFacts } from "../../src/execution/residentReuse.js";
100
99
  import {
100
+ decideWorktree,
101
+ parseReuse,
102
+ replacementWorktreeCleanup,
103
+ type WorktreeFacts,
104
+ } from "../../src/execution/residentReuse.js";
105
+ import {
106
+ admitThreadDiskWithRollback,
101
107
  boundByFor,
102
108
  canReturnToDefault,
103
109
  parseOwnPr,
@@ -1753,6 +1759,17 @@ async function threadWorktreePath(threadKey: string, ref: string): Promise<strin
1753
1759
  return `${THREADS_DIR}/${slug(threadKey)}-${hash8}/${slug(ref)}`;
1754
1760
  }
1755
1761
 
1762
+ /** Build a named-ref replacement beside the checkout the sticky row still
1763
+ * names. A slash-to-dash slug collision gets a ref hash so the provisional
1764
+ * clone can never overwrite the old checkout before the attach commits. */
1765
+ async function replacementWorktreePath(threadKey: string, ref: string, priorPath: string): Promise<string> {
1766
+ const desired = await threadWorktreePath(threadKey, ref);
1767
+ if (desired !== priorPath) return desired;
1768
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ref));
1769
+ const hash8 = [...new Uint8Array(digest).slice(0, 4)].map((b) => b.toString(16).padStart(2, "0")).join("");
1770
+ return `${desired}-ref-${hash8}`;
1771
+ }
1772
+
1756
1773
  /** Path confinement for /read and /write: conservative charset, no `..`
1757
1774
  * segments, and the resolved absolute path must stay under the worktree
1758
1775
  * root. Symlink tricks past this point are bounded by the OS layer — the
@@ -5397,20 +5414,27 @@ export class ResidentDO extends Sandbox<Env> {
5397
5414
 
5398
5415
  /** Storage-only allocation (atomic under the DO input gate: get → list →
5399
5416
  * put touches nothing but this object's storage, so two concurrent
5400
- * attaches cannot both claim the same user). Sticky: an existing live
5401
- * binding is reused as-is; an evicted binding keeps its ref and
5402
- * gets a fresh user from the pool. */
5417
+ * attaches cannot both claim the same user). A hint that names no branch
5418
+ * keeps the sticky ref. An explicit named ref records named authority even
5419
+ * when it matches the sticky ref; only a changed ref replaces the path. */
5403
5420
  private async allocateThreadUser(
5404
5421
  threadKey: string,
5405
5422
  ref: string,
5406
5423
  worktreePath: string,
5407
- /** How a NEW binding's ref was chosen (item 16); an existing binding keeps its own record. */
5424
+ /** How the requested ref was chosen (item 16). */
5408
5425
  boundBy: BoundBy,
5426
+ /** An explicit named ref is authoritative; false is the sticky fallback. */
5427
+ replaceRef = false,
5409
5428
  ): Promise<{ binding: ThreadBinding; wrote: boolean } | ThreadErr> {
5410
5429
  const key = threadBindingKey(threadKey);
5411
5430
  const existing = await this.ctx.storage.get<ThreadBinding>(key);
5412
- if (existing && !existing.evicted && existing.user) return { binding: existing, wrote: false };
5413
- const user = await this.findFreePoolUser(threadKey);
5431
+ const replacingRef = existing !== undefined && replaceRef && existing.ref !== ref;
5432
+ const recordingNamedAuthority = existing !== undefined && replaceRef && existing.boundBy !== boundBy;
5433
+ const deferRefWrite = replacingRef && !existing.evicted && existing.user !== "";
5434
+ if (existing && !existing.evicted && existing.user && !replacingRef && !recordingNamedAuthority)
5435
+ return { binding: existing, wrote: false };
5436
+ const user =
5437
+ existing && !existing.evicted && existing.user ? existing.user : await this.findFreePoolUser(threadKey);
5414
5438
  if (!user) {
5415
5439
  return {
5416
5440
  error: `user-pool-exhausted: all ${THREAD_USERS.length} thread users are allocated; wait for the inactivity sweep or evict a thread`,
@@ -5420,26 +5444,50 @@ export class ResidentDO extends Sandbox<Env> {
5420
5444
  const now = new Date(systemClock()).toISOString();
5421
5445
  const binding: ThreadBinding = {
5422
5446
  threadKey,
5423
- ref: existing?.ref ?? ref, // sticky across eviction
5447
+ ref: replacingRef ? ref : (existing?.ref ?? ref),
5424
5448
  user,
5425
- worktreePath: existing?.worktreePath ?? worktreePath,
5449
+ worktreePath: replacingRef ? worktreePath : (existing?.worktreePath ?? worktreePath),
5426
5450
  boundAt: existing?.boundAt ?? now,
5427
5451
  lastAttachAt: now,
5428
5452
  evicted: false,
5429
- // An evicted binding's own history: how its ref was chosen (absent on
5430
- // one made before the field — never overwritten by this attach's flag),
5431
- // the one move it may have made and its last move back.
5453
+ // A named ref starts a new binding choice, so stale move/return records
5454
+ // do not make it look default-bound. The thread's pushed-branch memory
5455
+ // remains its history across every ref choice.
5432
5456
  ...(existing
5433
5457
  ? {
5434
- ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
5435
- ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
5436
- ...(existing.returned !== undefined ? { returned: existing.returned } : {}),
5458
+ ...(replacingRef || recordingNamedAuthority
5459
+ ? { boundBy }
5460
+ : {
5461
+ ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
5462
+ ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
5463
+ ...(existing.returned !== undefined ? { returned: existing.returned } : {}),
5464
+ }),
5437
5465
  ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
5438
5466
  }
5439
5467
  : { boundBy }),
5440
5468
  };
5441
- await this.ctx.storage.put(key, binding);
5442
- return { binding, wrote: true };
5469
+ // A live existing binding already reserves this user. Its named-ref
5470
+ // replacement stays provisional until deps and credentials succeed;
5471
+ // keeping the old row here lets a failed attach discard the new checkout
5472
+ // without ever publishing a binding that did not complete. An evicted row
5473
+ // has no user reservation, so its fresh allocation is written and rolled
5474
+ // back through the ordinary path.
5475
+ if (!deferRefWrite) await this.ctx.storage.put(key, binding);
5476
+ return { binding, wrote: !deferRefWrite };
5477
+ }
5478
+
5479
+ /** Best-effort cleanup after a provisional named-ref replacement settles.
5480
+ * The binding and its surviving checkout already agree; a cleanup failure
5481
+ * leaves only an unreachable sibling for the thread's eventual eviction. */
5482
+ private async discardReplacedCheckout(threadKey: string, path: string): Promise<void> {
5483
+ try {
5484
+ const removed = await this.run(["rm", "-rf", path]);
5485
+ if (removed.exitCode !== 0) {
5486
+ console.log(`attach ${threadKey}: failed to discard replaced checkout ${path}: ${removed.stderr}`);
5487
+ }
5488
+ } catch (err) {
5489
+ console.log(`attach ${threadKey}: failed to discard replaced checkout ${path}: ${errMsg(err)}`);
5490
+ }
5443
5491
  }
5444
5492
 
5445
5493
  /** POST /attach: idempotent per-thread workspace materialization.
@@ -5612,24 +5660,20 @@ export class ResidentDO extends Sandbox<Env> {
5612
5660
  cause: "system",
5613
5661
  };
5614
5662
 
5615
- // The binding's ref wins for the thread's whole life, with one exception
5616
- // (item 16): a thread bound to the repo default for want of a named branch
5617
- // moves, once per pull request, onto the branch its own run opened a pull
5618
- // request on — when that branch is the thread's own and the mirror holds
5619
- // it. A decision about the binding alone, made here, before the ref is
5620
- // chosen, so the rest of the attach (fetch, the worktree step that
5621
- // provisions the tree clean at the moved ref, deps, credentials) runs on
5622
- // the moved ref.
5623
- const rebind = await this.rebindToOwnPr(
5624
- stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined,
5625
- reason.ownPr,
5626
- reuse,
5627
- facts.defaultRef,
5628
- slug,
5629
- );
5663
+ // A ref the request names is authoritative. The sticky binding is only a
5664
+ // fallback when the request names none (`refHint` absent, or the bot's
5665
+ // explicit by-default resolution). This keeps a re-issued coordinator
5666
+ // child on the branch its new spawn names instead of an earlier attempt's
5667
+ // branch. The own-PR move remains for callers that name no branch of their
5668
+ // own but carry the typed follow-up fact.
5669
+ const storedPrior = stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined;
5670
+ const namedRef = refHint !== null && !reason.refByDefault && reason.ownPr === null;
5671
+ const rebind = namedRef
5672
+ ? { binding: storedPrior }
5673
+ : await this.rebindToOwnPr(storedPrior, reason.ownPr, reuse, facts.defaultRef, slug);
5630
5674
  if ("error" in rebind) return rebind;
5631
5675
  const prior = rebind.binding;
5632
- const ref = prior?.ref ?? refHint;
5676
+ const ref = namedRef ? refHint : (prior?.ref ?? refHint);
5633
5677
  if (!ref) {
5634
5678
  // Name the default branch so the bot can bind to it (loudly) instead of
5635
5679
  // asking the user when the message named no branch; the binding is still
@@ -5642,7 +5686,10 @@ export class ResidentDO extends Sandbox<Env> {
5642
5686
  cause: "request",
5643
5687
  };
5644
5688
  }
5645
- const worktreePath = prior?.worktreePath ?? (await threadWorktreePath(threadKey, ref));
5689
+ const replacingNamedRef = namedRef && prior !== undefined && prior.ref !== ref;
5690
+ const worktreePath = replacingNamedRef
5691
+ ? await replacementWorktreePath(threadKey, ref, prior.worktreePath)
5692
+ : (prior?.worktreePath ?? (await threadWorktreePath(threadKey, ref)));
5646
5693
  // Read-only vs writable (item 50): decided here, once, from the request
5647
5694
  // and the prior binding's mode — the tested pure helper is the shipped code.
5648
5695
  const mode = planReadonlyAttach({ readonly, prior, slug, mirrorDir: MIRROR_DIR });
@@ -5652,12 +5699,14 @@ export class ResidentDO extends Sandbox<Env> {
5652
5699
  ref,
5653
5700
  worktreePath,
5654
5701
  boundByFor({ refByDefault: reason.refByDefault, ref, defaultRef: facts.defaultRef }),
5702
+ namedRef,
5655
5703
  );
5656
5704
  if ("error" in alloc) return alloc;
5657
5705
  const binding = alloc.binding;
5658
5706
  // "Nothing created" on failure: a FRESH allocation (new binding or a
5659
5707
  // re-allocation after eviction) is rolled back if the attach fails below,
5660
- // so a bogus refHint can neither bind sticky garbage nor leak a pool user.
5708
+ // including a throw during disk admission, so a bogus refHint can neither
5709
+ // bind sticky garbage nor leak a pool user.
5661
5710
  const rollback = async (): Promise<void> => {
5662
5711
  if (!alloc.wrote) return;
5663
5712
  if (prior) await this.ctx.storage.put(threadBindingKey(threadKey), prior);
@@ -5665,13 +5714,19 @@ export class ResidentDO extends Sandbox<Env> {
5665
5714
  };
5666
5715
 
5667
5716
  // Disk admission (item 55): before the lock, since making room takes it.
5668
- const admission = await this.admitThreadDisk({ threadKey, binding, facts, record });
5717
+ // An evicted prior binding has already published its replacement to
5718
+ // reserve the user; keep that row provisional if admission itself throws.
5719
+ const admission = await admitThreadDiskWithRollback(
5720
+ () => this.admitThreadDisk({ threadKey, binding, facts, record }),
5721
+ rollback,
5722
+ );
5669
5723
  if ("error" in admission) {
5670
5724
  await rollback();
5671
5725
  return admission;
5672
5726
  }
5727
+ let attached: AttachOk | ThreadErr | undefined;
5673
5728
  try {
5674
- return await this.attachThreadCreate({
5729
+ attached = await this.attachThreadCreate({
5675
5730
  threadKey,
5676
5731
  refHint,
5677
5732
  wantSha,
@@ -5683,11 +5738,22 @@ export class ResidentDO extends Sandbox<Env> {
5683
5738
  binding,
5684
5739
  mode,
5685
5740
  rollback,
5741
+ namedRef,
5742
+ refChanged: storedPrior !== undefined && storedPrior.ref !== binding.ref,
5686
5743
  ...(rebind.rebound !== undefined ? { rebound: rebind.rebound } : {}),
5687
5744
  ...(rebind.rebindRefused !== undefined ? { rebindRefused: rebind.rebindRefused } : {}),
5688
5745
  });
5746
+ return attached;
5689
5747
  } finally {
5690
5748
  this.diskCommittedKiB -= admission.committedKiB;
5749
+ if (replacingNamedRef && prior !== undefined) {
5750
+ const discard = replacementWorktreeCleanup({
5751
+ priorPath: prior.worktreePath,
5752
+ replacementPath: binding.worktreePath,
5753
+ succeeded: attached !== undefined && !("error" in attached),
5754
+ });
5755
+ if (discard !== null) await this.discardReplacedCheckout(threadKey, discard);
5756
+ }
5691
5757
  }
5692
5758
  }
5693
5759
 
@@ -5903,6 +5969,10 @@ export class ResidentDO extends Sandbox<Env> {
5903
5969
  binding: ThreadBinding;
5904
5970
  mode: ReturnType<typeof planReadonlyAttach>;
5905
5971
  rollback: () => Promise<void>;
5972
+ /** This ask named the ref; a missing named branch may not fall back. */
5973
+ namedRef: boolean;
5974
+ /** The row moved to another ref before provisioning. */
5975
+ refChanged: boolean;
5906
5976
  /** What `rebindToOwnPr` decided (item 16), for the answer. */
5907
5977
  rebound?: Rebound;
5908
5978
  rebindRefused?: RebindRefused;
@@ -5914,6 +5984,7 @@ export class ResidentDO extends Sandbox<Env> {
5914
5984
  // movement): everything after the lock — deps, credentials, the row's
5915
5985
  // final write, the answer — then speaks of the returned binding.
5916
5986
  let binding = input.binding;
5987
+ let refChanged = input.refChanged;
5917
5988
  let returned: Returned | undefined;
5918
5989
 
5919
5990
  // Command-level token mint — before the lock so mint latency
@@ -5954,7 +6025,7 @@ export class ResidentDO extends Sandbox<Env> {
5954
6025
  // cycle's prune caught up. One predicate, read once here, drives the mint
5955
6026
  // pre-check, the fetch under the lock and the return gate alike.
5956
6027
  let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
5957
- const returnable = canReturnToDefault(binding, facts.defaultRef);
6028
+ const returnable = input.namedRef ? false : canReturnToDefault(binding, facts.defaultRef);
5958
6029
  let fetchToken: string | null = token;
5959
6030
  if (
5960
6031
  !fetchToken &&
@@ -6022,6 +6093,7 @@ export class ResidentDO extends Sandbox<Env> {
6022
6093
  // goes on there: the default is always in the mirror, and the
6023
6094
  // worktree step below provisions the tree at its tip.
6024
6095
  const back = await this.returnBindingToDefault(binding, facts.defaultRef);
6096
+ if (back.binding.ref !== binding.ref) refChanged = true;
6025
6097
  binding = back.binding;
6026
6098
  returned = back.returned;
6027
6099
  want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
@@ -6058,6 +6130,7 @@ export class ResidentDO extends Sandbox<Env> {
6058
6130
  const recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
6059
6131
  detached: target.kind === "sha",
6060
6132
  reuse,
6133
+ refChanged,
6061
6134
  });
6062
6135
  return { sha, threadLockKey, recreated };
6063
6136
  }, ATTACH_MUTEX_WAIT_MS);
@@ -6219,7 +6292,11 @@ export class ResidentDO extends Sandbox<Env> {
6219
6292
  * expected commit it still holds (item 51) — the tree is checked out at
6220
6293
  * that commit, detached, instead of at a branch. `reuse`: a resumed
6221
6294
  * run's attach (item 66): keep the tree as it stands, or refuse. */
6222
- opts: { detached: boolean; reuse: boolean } = { detached: false, reuse: false },
6295
+ opts: { detached: boolean; reuse: boolean; refChanged: boolean } = {
6296
+ detached: false,
6297
+ reuse: false,
6298
+ refChanged: false,
6299
+ },
6223
6300
  ): Promise<boolean> {
6224
6301
  const wt = binding.worktreePath;
6225
6302
  const threadDir = parentDir(wt);
@@ -6256,7 +6333,14 @@ export class ResidentDO extends Sandbox<Env> {
6256
6333
  }
6257
6334
  }
6258
6335
  }
6259
- const decision = decideWorktree({ reuse: opts.reuse, modeSwitch, sha, worktreePath: wt, facts });
6336
+ const decision = decideWorktree({
6337
+ reuse: opts.reuse,
6338
+ modeSwitch,
6339
+ refChanged: opts.refChanged,
6340
+ sha,
6341
+ worktreePath: wt,
6342
+ facts,
6343
+ });
6260
6344
  if (decision.kind === "refuse") throw new ReuseRefusedError(decision.why);
6261
6345
  if (decision.kind === "reuse") return false;
6262
6346
 
@@ -19,8 +19,8 @@
19
19
  "@cloudflare/sandbox": "0.13.0-next.751.1"
20
20
  },
21
21
  "devDependencies": {
22
- "@cloudflare/workers-types": "^5.20260904.1",
22
+ "@cloudflare/workers-types": "^5.20260914.1",
23
23
  "typescript": "^5.9.3",
24
- "wrangler": "^4.129.1"
24
+ "wrangler": "^4.131.2"
25
25
  }
26
26
  }