@coreplane/switchboard 1.232.0 → 1.234.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/assets/.env.example +2 -0
  2. package/dist/assets/config/config.example.yaml +14 -12
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +200 -214
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/source.json +3 -3
  7. package/dist/assets/src/agents/registry.ts +13 -7
  8. package/dist/assets/src/core/provider.ts +9 -6
  9. package/dist/assets/src/core/runEvents.ts +18 -0
  10. package/dist/assets/src/execution/residentCleanliness.ts +45 -5
  11. package/dist/assets/src/execution/residentRebind.ts +92 -77
  12. package/dist/assets/src/execution/residentReuse.ts +7 -18
  13. package/dist/assets/src/execution/residentSteps.ts +0 -1
  14. package/dist/assets/web/dist/.vite/manifest.json +67 -28
  15. package/dist/assets/web/dist/assets/{ResidentDetailPage-V4K35SYX.js → ResidentDetailPage-C_WNBUao.js} +1 -1
  16. package/dist/assets/web/dist/assets/{ResidentsIndexPage-3HuYaDwD.js → ResidentsIndexPage-D1sTyaZ_.js} +1 -1
  17. package/dist/assets/web/dist/assets/RunFoldRow-C_bcTSjA.js +5 -0
  18. package/dist/assets/web/dist/assets/RunRoutePage-BWTi_6PG.js +10 -0
  19. package/dist/assets/web/dist/assets/RunsIndexPage-H6GkSv0a.js +1 -0
  20. package/dist/assets/web/dist/assets/{ScheduledPage-dfrjQ5E9.js → ScheduledPage-BLcPzm1S.js} +1 -1
  21. package/dist/assets/web/dist/assets/{StatusDot-DexKtvUC.js → StatusDot-Ct6AtHhS.js} +1 -1
  22. package/dist/assets/web/dist/assets/{Tooltip-DGQ2gu9M.js → Tooltip-qol5Boof.js} +1 -1
  23. package/dist/assets/web/dist/assets/UnitRoutePage-CIhTEwN7.js +1 -0
  24. package/dist/assets/web/dist/assets/{dist-boTdDLF4.js → dist-DwtJj-ou.js} +1 -1
  25. package/dist/assets/web/dist/assets/favicon-BQsePYv5.js +1 -0
  26. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +1 -0
  27. package/dist/assets/web/dist/assets/{main-BHhLVMQh.js → main-CRtlGxRm.js} +2 -2
  28. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +1 -0
  29. package/dist/cli.js +1170 -571
  30. package/package.json +1 -1
  31. package/dist/assets/web/dist/assets/RunRoutePage-Dr867-Ha.js +0 -13
  32. package/dist/assets/web/dist/assets/RunsIndexPage-D_lpXsr8.js +0 -1
  33. package/dist/assets/web/dist/assets/favicon-CWPcvWvp.js +0 -1
  34. package/dist/assets/web/dist/assets/main-CAVqMbiX.css +0 -1
@@ -87,6 +87,7 @@ import { parseReadonly, planReadonlyAttach } from "../../src/execution/residentR
87
87
  import { decideWorktree, parseReuse, type WorktreeFacts } from "../../src/execution/residentReuse.js";
88
88
  import {
89
89
  boundByFor,
90
+ canReturnToDefault,
90
91
  parseOwnPr,
91
92
  parsePushed,
92
93
  parseRefByDefault,
@@ -94,10 +95,12 @@ import {
94
95
  rebindRefused,
95
96
  rebindVerdict,
96
97
  rememberOwnBranches,
98
+ returnToDefault,
97
99
  type BoundBy,
98
100
  type OwnBranch,
99
101
  type OwnPr,
100
102
  type PushedBranch,
103
+ type Returned,
101
104
  type Rebound,
102
105
  type RebindRefused,
103
106
  type RebindTreeFacts,
@@ -112,7 +115,12 @@ import {
112
115
  type DepCacheMaterialization,
113
116
  type ThreadDepsMechanism,
114
117
  } from "../../src/execution/residentDepCache.js";
115
- import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
118
+ import {
119
+ leftBehindOf,
120
+ parseWorktreeCleanliness,
121
+ worktreeCleanlinessScript,
122
+ type LeftBehind,
123
+ } from "../../src/execution/residentCleanliness.js";
116
124
  import {
117
125
  base64LengthOf,
118
126
  chunkPlan,
@@ -994,15 +1002,17 @@ interface ThreadBinding {
994
1002
  * binding made before the field: read as `default` iff `ref` is the default
995
1003
  * branch. Only a `default` binding may ever move. */
996
1004
  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. */
1005
+ /** The move a binding may make (item 16): from the default it was bound to,
1006
+ * onto the branch the thread's own run opened a pull request on. Set, the
1007
+ * binding moves again only after the move is returned (`returnedAt`: the
1008
+ * branch was gone from the mirror and the binding went back to the
1009
+ * default), and then for the next own pull request. */
1000
1010
  rebound?: Rebound;
1001
1011
  /** The branches the thread's own runs pushed and the pull requests they
1002
1012
  * head, as each run's release handed them over (`/detach` `pushed`, item
1003
1013
  * 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
1014
+ * tree: the tree is released the moment its run ends, and this is what
1015
+ * lets the follow-up's attach move onto the branch and provision the tree
1006
1016
  * there. Newest last; the oldest fall off past `OWN_BRANCHES_MAX`. */
1007
1017
  ownBranches?: OwnBranch[];
1008
1018
  /** Allocated OS user (worker2..worker17); "" once evicted (pool released). */
@@ -1088,6 +1098,20 @@ interface AttachOk {
1088
1098
  /** The caller asked for that move and the binding stood: the branch, the PR
1089
1099
  * and why — so the bot can say where the follow-up runs and why. */
1090
1100
  rebindRefused?: RebindRefused;
1101
+ /** This attach moved the binding BACK to the default branch (item 16's
1102
+ * second movement): the branch a rebind had moved it onto is gone from the
1103
+ * mirror, so the tree was provisioned on the default instead of the attach
1104
+ * failing `unknown-ref`. From where, to where, which PR's branch it was. */
1105
+ returned?: Returned;
1106
+ }
1107
+
1108
+ /** What `POST /detach` answers: whether the pool user went back, why not, and
1109
+ * — on a release — what the tree still held (item 16a), now gone with it. */
1110
+ interface DetachAnswer {
1111
+ released: boolean;
1112
+ reason?: string;
1113
+ user?: string;
1114
+ leftBehind?: LeftBehind;
1091
1115
  }
1092
1116
 
1093
1117
  /** Why the caller's `refHint` is what it is (item 16), beside the hint itself:
@@ -1105,17 +1129,11 @@ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1105
1129
 
1106
1130
  /** What the rebind decided under the mutex (item 16): nothing to move (the
1107
1131
  * 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. */
1132
+ * the row; the attach that follows provisions the tree at the moved ref. */
1110
1133
  type RebindOutcome =
1111
1134
  | { kind: "none"; binding: ThreadBinding }
1112
1135
  | { kind: "refuse"; refused: RebindRefused }
1113
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; keepTree: false }
1114
- /** The move was the record alone — a dirty tree already on the branch, no
1115
- * checkout run — with the verdict's `note` for the log line. The attach
1116
- * that follows must keep that tree (`keepTree`, the worktree step's word
1117
- * for it): the promise the note makes is the attach's to keep. */
1118
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; keepTree: true; note: string };
1136
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound };
1119
1137
 
1120
1138
  /** Result of one /op test/build execution. `ok` is the command's
1121
1139
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
@@ -4514,10 +4532,12 @@ export class ResidentDO extends Sandbox<Env> {
4514
4532
 
4515
4533
  // The binding's ref wins for the thread's whole life, with one exception
4516
4534
  // (item 16): a thread bound to the repo default for want of a named branch
4517
- // moves, once and in place, onto the branch its own run opened a pull
4518
- // request on — when that branch is a local branch of this thread's tree and
4519
- // the tree is clean. Decided here, before the ref is chosen, so the rest of
4520
- // the attach (fetch, stale check, deps, credentials) runs on the moved ref.
4535
+ // moves, once per pull request, onto the branch its own run opened a pull
4536
+ // request on — when that branch is the thread's own and the mirror holds
4537
+ // it. A decision about the binding alone, made here, before the ref is
4538
+ // chosen, so the rest of the attach (fetch, the worktree step that
4539
+ // provisions the tree clean at the moved ref, deps, credentials) runs on
4540
+ // the moved ref.
4521
4541
  const rebind = await this.rebindToOwnPr(
4522
4542
  stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined,
4523
4543
  reason.ownPr,
@@ -4573,7 +4593,6 @@ export class ResidentDO extends Sandbox<Env> {
4573
4593
  refHint,
4574
4594
  wantSha,
4575
4595
  reuse,
4576
- keepTree: rebind.keepTree === true,
4577
4596
  slug,
4578
4597
  t0,
4579
4598
  facts,
@@ -4591,54 +4610,36 @@ export class ResidentDO extends Sandbox<Env> {
4591
4610
 
4592
4611
  /** Item 16's one exception to the sticky binding: a thread bound to the repo
4593
4612
  * default for want of a named branch moves onto the branch its own run
4594
- * opened a pull request on (`ownPr`), once, in place. The decision is the
4595
- * pure `rebindPlan` / `rebindVerdict` of src/execution/residentRebind.ts;
4596
- * this method measures the tree as the thread user and runs the verdict: a
4597
- * `git checkout` inside the existing worktree — same path, same pool user,
4598
- * no clone, no `rm -rf`, deps and snapshot lineage untouched — under the
4599
- * mirror mutex so no other attach of the thread touches the tree meanwhile.
4600
- * The rest of the attach then runs on the moved ref: the mirror fetch for
4601
- * the PR head (item 51) and item 17's stale check, which keeps a tree whose
4602
- * HEAD is the ref's tip or a descendant of it. When the verdict moved the
4603
- * record alone — a dirty tree with the branch already checked out — the
4604
- * answer says so (`keepTree`) and the worktree step keeps that tree, dirt
4605
- * included, instead of item 17's dirty wipe: the promise not to touch it is
4606
- * the whole attach's. The binding records the move (`rebound`); the answer
4607
- * carries it, or the named refusal, so the bot can say where the follow-up
4608
- * runs and why. A refusal never fails the attach. */
4613
+ * opened a pull request on (`ownPr`), once per pull request. A decision
4614
+ * about the BINDING alone — the pure `rebindPlan` / `rebindVerdict` of
4615
+ * src/execution/residentRebind.ts: the branch must be the thread's own,
4616
+ * remembered from a release (`ownBranches`) or, when nothing was
4617
+ * remembered, a local branch of the thread's surviving tree, measured as
4618
+ * the thread user — and the mirror must hold it (`moveOntoOwnBranch`), since
4619
+ * the attach that follows provisions the tree at the moved ref from the
4620
+ * mirror as it provisions any tree (item 17: clean at the ref's tip, a tree
4621
+ * left dirty or on the old branch recreated, the binding's path kept). Under
4622
+ * the mirror mutex, on the row as it stands there. The binding records the
4623
+ * move (`rebound`); the answer carries it, or the named refusal, so the bot
4624
+ * can say where the follow-up runs and why. A refusal never fails the attach. */
4609
4625
  private async rebindToOwnPr(
4610
4626
  prior: ThreadBinding | undefined,
4611
4627
  ownPr: OwnPr | null,
4612
4628
  reuse: boolean,
4613
4629
  defaultRef: string,
4614
4630
  slug: string,
4615
- ): Promise<
4616
- | {
4617
- binding: ThreadBinding | undefined;
4618
- rebound?: Rebound;
4619
- rebindRefused?: RebindRefused;
4620
- /** The move was the record alone — the tree is dirty with the branch
4621
- * already checked out — and the attach must keep that tree as it
4622
- * stands: the worktree step's item 17 wipe of a dirty tree would
4623
- * destroy the very work the rebind declined to touch. */
4624
- keepTree?: true;
4625
- }
4626
- | ThreadErr
4627
- > {
4631
+ ): Promise<{ binding: ThreadBinding | undefined; rebound?: Rebound; rebindRefused?: RebindRefused } | ThreadErr> {
4628
4632
  const plan = rebindPlan({ ownPr, reuse, binding: prior, defaultRef });
4629
4633
  if (plan.kind === "none" || prior === undefined) return { binding: prior };
4630
4634
  if (plan.kind === "refuse") return { binding: prior, rebindRefused: plan.refused };
4631
4635
  const key = threadBindingKey(prior.threadKey);
4632
- // A tree that is gone is recreated at the branch by the attach (item 16):
4633
- // the mirror must hold the branch first, and a branch pushed since the
4634
- // last refresh cycle is fetched for — with a token minted here, before
4635
- // the mutex, like the attach's own recovery fetch. Only when the binding
4636
- // allows a recreate at all: an evicted tree, or a live one whose runs
4637
- // pushed the branch (the tree may turn out missing).
4638
- const fetchToken =
4639
- (plan.kind === "recreate" || plan.own) && githubAppConfigured(this.env)
4640
- ? ((await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null)
4641
- : null;
4636
+ // The tree is provisioned at the branch from the mirror, so the mirror
4637
+ // must hold it first: a branch pushed since the last refresh cycle is
4638
+ // fetched for — with a token minted here, before the mutex, like the
4639
+ // attach's own recovery fetch.
4640
+ const fetchToken = githubAppConfigured(this.env)
4641
+ ? ((await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null)
4642
+ : null;
4642
4643
  let outcome: RebindOutcome;
4643
4644
  try {
4644
4645
  outcome = (
@@ -4653,79 +4654,25 @@ export class ResidentDO extends Sandbox<Env> {
4653
4654
  const again = rebindPlan({ ownPr, reuse, binding: current, defaultRef });
4654
4655
  if (again.kind === "none") return { kind: "none", binding: current };
4655
4656
  if (again.kind === "refuse") return again;
4656
- if (again.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4657
- const wt = current.worktreePath;
4658
- // The facts, as the thread user in the thread's own tree: is the
4659
- // branch a local branch here (the physical trace of this thread's
4660
- // run having made it), and are the tracked files clean — judged
4661
- // only off a status git could read.
4662
- const tree: RebindTreeFacts = { exists: (await this.run(["test", "-d", `${wt}/.git`])).exitCode === 0 };
4663
- if (tree.exists) {
4664
- const branch = await this.threadRun(
4665
- current.user,
4666
- wt,
4667
- `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${again.to}`)}`,
4668
- DEFAULT_EXEC_TIMEOUT_MS,
4669
- );
4670
- tree.branchExists = branch.exitCode === 0;
4671
- const status = await this.threadRun(
4672
- current.user,
4673
- wt,
4674
- "git status --porcelain -uno",
4675
- DEFAULT_EXEC_TIMEOUT_MS,
4676
- );
4677
- tree.readable = status.exitCode === 0;
4678
- if (tree.readable) tree.dirty = status.stdout.trim() !== "";
4679
- if (tree.dirty) {
4680
- // A dirty tree is refused unless its HEAD is already the branch
4681
- // (the run made it here and left an edit after pushing): the
4682
- // one fact that tells the two apart, measured only when it
4683
- // decides anything.
4684
- const head = await this.threadRun(
4657
+ if (again.kind === "measure" && !again.own) {
4658
+ // Nothing remembered from a release: the branch must be a local
4659
+ // branch of the thread's surviving tree — the physical trace of
4660
+ // this thread's run having made it — measured as the thread user.
4661
+ const wt = current.worktreePath;
4662
+ const tree: RebindTreeFacts = { exists: (await this.run(["test", "-d", `${wt}/.git`])).exitCode === 0 };
4663
+ if (tree.exists) {
4664
+ const branch = await this.threadRun(
4685
4665
  current.user,
4686
4666
  wt,
4687
- "git rev-parse --abbrev-ref HEAD",
4667
+ `git rev-parse --verify --quiet ${shellQuote(`refs/heads/${again.to}`)}`,
4688
4668
  DEFAULT_EXEC_TIMEOUT_MS,
4689
4669
  );
4690
- if (head.exitCode === 0) tree.head = head.stdout.trim();
4691
- }
4692
- }
4693
- // Judged on the re-read plan: it carries whether the branch is the
4694
- // thread's own, which the pre-lock plan lacks when it was a recreate.
4695
- const verdict = rebindVerdict(again, tree);
4696
- if (verdict.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
4697
- if (verdict.kind !== "rebind") return verdict;
4698
- if (verdict.checkout) {
4699
- const startedAt = systemClock();
4700
- const checkout = await this.threadRun(
4701
- current.user,
4702
- wt,
4703
- `git checkout --quiet ${shellQuote(again.to)}`,
4704
- DEFAULT_EXEC_TIMEOUT_MS,
4705
- );
4706
- this.stepTrace.getStore()?.record("rebind-checkout", {
4707
- startedAt,
4708
- endedAt: systemClock(),
4709
- exitCode: checkout.exitCode,
4710
- timedOut: checkout.timedOut,
4711
- });
4712
- if (checkout.exitCode !== 0) {
4713
- const detail =
4714
- checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
4715
- return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
4670
+ tree.branchExists = branch.exitCode === 0;
4716
4671
  }
4672
+ const verdict = rebindVerdict(again, tree);
4673
+ if (verdict.kind === "refuse") return verdict;
4717
4674
  }
4718
- const rebound: Rebound = {
4719
- from: current.ref,
4720
- to: again.to,
4721
- pr: again.pr,
4722
- at: new Date(systemClock()).toISOString(),
4723
- };
4724
- const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4725
- await this.ctx.storage.put(key, moved);
4726
- return verdict.checkout
4727
- ? { kind: "rebound", moved, rebound, keepTree: false }
4728
- : { kind: "rebound", moved, rebound, keepTree: true, note: verdict.note };
4675
+ return this.moveOntoOwnBranch(current, again, fetchToken);
4729
4676
  }, ATTACH_MUTEX_WAIT_MS)
4730
4677
  ).value;
4731
4678
  } catch (err) {
@@ -4743,28 +4690,22 @@ export class ResidentDO extends Sandbox<Env> {
4743
4690
  return { binding: prior, rebindRefused: outcome.refused };
4744
4691
  }
4745
4692
  const { moved, rebound } = outcome;
4746
- if (!outcome.keepTree) {
4747
- console.log(
4748
- `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4749
- );
4750
- return { binding: moved, rebound };
4751
- }
4752
4693
  console.log(
4753
- `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr}) — ${outcome.note}`,
4694
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr}); the tree is provisioned at it`,
4754
4695
  );
4755
- return { binding: moved, rebound, keepTree: true };
4756
- }
4757
-
4758
- /** The move when the thread's tree is gone (item 16): the binding's ref
4759
- * becomes the branch its own run pushed, and the attach that follows
4760
- * recreates the tree at it — `ensureThreadWorktree` finds no tree and
4761
- * clones the branch from the mirror. So the mirror must hold the branch:
4762
- * one pushed since the last refresh cycle is fetched for here, under the
4763
- * mutex; a branch still missing after the fetch (deleted after a merge, or
4764
- * never pushed) is a `branch-absent` refusal and the binding stands — the
4765
- * attach then goes on at the ref it had, never `unknown-ref`. Nothing on
4766
- * disk is touched: no checkout (there is no tree), no clone (the attach's). */
4767
- private async recreateAtOwnBranch(
4696
+ return { binding: moved, rebound };
4697
+ }
4698
+
4699
+ /** The move itself (item 16): the binding's ref becomes the branch, and the
4700
+ * attach that follows provisions the tree at it — `ensureThreadWorktree`
4701
+ * clones the branch from the mirror when the tree is gone, dirty or on the
4702
+ * old ref. So the mirror must hold the branch: one pushed since the last
4703
+ * refresh cycle is fetched for here, under the mutex; a branch still
4704
+ * missing after the fetch (deleted after a merge, or never pushed) is a
4705
+ * `branch-absent` refusal and the binding stands — the attach then goes on
4706
+ * at the ref it had, never `unknown-ref`. Nothing on disk is touched here:
4707
+ * the row is written, the tree is the attach's. */
4708
+ private async moveOntoOwnBranch(
4768
4709
  current: ThreadBinding,
4769
4710
  plan: { to: string; pr: number },
4770
4711
  fetchToken: string | null,
@@ -4795,7 +4736,7 @@ export class ResidentDO extends Sandbox<Env> {
4795
4736
  refused: rebindRefused(
4796
4737
  plan,
4797
4738
  "branch-absent",
4798
- `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`,
4739
+ `the mirror does not hold ${JSON.stringify(plan.to)} even after a fetch (deleted after a merge, or never pushed); the tree cannot be provisioned at it`,
4799
4740
  ),
4800
4741
  };
4801
4742
  }
@@ -4807,13 +4748,43 @@ export class ResidentDO extends Sandbox<Env> {
4807
4748
  };
4808
4749
  const moved: ThreadBinding = { ...current, ref: plan.to, rebound };
4809
4750
  await this.ctx.storage.put(threadBindingKey(current.threadKey), moved);
4810
- return { kind: "rebound", moved, rebound, keepTree: false };
4751
+ return { kind: "rebound", moved, rebound };
4752
+ }
4753
+
4754
+ /** Item 16's second movement, decided where the fact is established — under
4755
+ * the mirror mutex, after the attach's fetch found the bound ref gone: a
4756
+ * binding a rebind moved onto its own pull request's branch, that branch
4757
+ * now deleted (its pull request merged), goes back to the default it was
4758
+ * bound to, so the run starts clean there (item 17) instead of the thread
4759
+ * failing `unknown-ref` for the rest of its life. The row as it stands is
4760
+ * re-read and re-judged (`canReturnToDefault`: a ref a person named never
4761
+ * returns — that branch is theirs to sort out), the move stamped returned
4762
+ * so the thread may follow its next pull request, and the answer carries
4763
+ * the move back for the card. A row that no longer qualifies — another
4764
+ * attach moved it meanwhile — is answered as it stands, nothing written. */
4765
+ private async returnBindingToDefault(
4766
+ binding: ThreadBinding,
4767
+ defaultRef: string,
4768
+ ): Promise<{ binding: ThreadBinding; returned?: Returned }> {
4769
+ const key = threadBindingKey(binding.threadKey);
4770
+ const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? binding;
4771
+ if (current.rebound === undefined || !canReturnToDefault(current, defaultRef)) return { binding: current };
4772
+ const back = returnToDefault(
4773
+ { ...current, rebound: current.rebound },
4774
+ defaultRef,
4775
+ new Date(systemClock()).toISOString(),
4776
+ );
4777
+ await this.ctx.storage.put(key, back.binding);
4778
+ console.log(
4779
+ `attach ${binding.threadKey}: ${back.returned.from} is gone from the mirror (the thread's own pull request #${back.returned.pr}) — returned to ${back.returned.to}; the tree is provisioned there`,
4780
+ );
4781
+ return back;
4811
4782
  }
4812
4783
 
4813
4784
  /** The release's word on what the run pushed (`/detach` `pushed`, item
4814
- * 16a), remembered on the binding BEFORE the detach decides anything: a
4815
- * clean tree is evicted at once, a dirty one kept, an already-evicted
4816
- * binding answered as such — and in every case the fact must outlive the
4785
+ * 16a), remembered on the binding BEFORE the detach decides anything: the
4786
+ * tree evicted, a busy one kept, an already-evicted binding answered as
4787
+ * such — and in every case the fact must outlive the
4817
4788
  * tree, since it is what a follow-up's rebind onto the thread's own pull
4818
4789
  * request branch reads once the tree is gone. No binding → nothing to
4819
4790
  * remember on (the detach answers its 404 next). */
@@ -4835,10 +4806,6 @@ export class ResidentDO extends Sandbox<Env> {
4835
4806
  refHint: string | null;
4836
4807
  wantSha: string | null;
4837
4808
  reuse: boolean;
4838
- /** The rebind moved the record alone over a dirty tree already on the
4839
- * branch (item 16) and promised not to touch it: the worktree step keeps
4840
- * the tree as it stands instead of item 17's dirty wipe. */
4841
- keepTree: boolean;
4842
4809
  slug: string;
4843
4810
  t0: number;
4844
4811
  facts: RepoFacts;
@@ -4850,8 +4817,14 @@ export class ResidentDO extends Sandbox<Env> {
4850
4817
  rebound?: Rebound;
4851
4818
  rebindRefused?: RebindRefused;
4852
4819
  }): Promise<AttachOk | ThreadErr> {
4853
- const { threadKey, refHint, wantSha, reuse, keepTree, slug, t0, facts, record, binding, mode, rollback } = input;
4820
+ const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, mode, rollback } = input;
4854
4821
  const { rebound, rebindRefused } = input;
4822
+ // Reassigned once, under the lock, when the bound ref turns out gone from
4823
+ // the mirror and the binding goes back to the default (item 16's second
4824
+ // movement): everything after the lock — deps, credentials, the row's
4825
+ // final write, the answer — then speaks of the returned binding.
4826
+ let binding = input.binding;
4827
+ let returned: Returned | undefined;
4855
4828
 
4856
4829
  // Command-level token mint — before the lock so mint latency
4857
4830
  // never holds the mutex, and failure never blocks the attach.
@@ -4882,7 +4855,7 @@ export class ResidentDO extends Sandbox<Env> {
4882
4855
  // The expected head applies to the ref it was resolved for; a sticky
4883
4856
  // binding on another branch drops it (item 51) rather than fetching on
4884
4857
  // every attach of a thread that can never be at that commit.
4885
- const want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
4858
+ let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
4886
4859
  let fetchToken: string | null = token;
4887
4860
  if (!fetchToken && githubAppConfigured(this.env) && (await this.mirrorNeedsFetchFor(binding.ref, want))) {
4888
4861
  fetchToken = (await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null;
@@ -4912,11 +4885,27 @@ export class ResidentDO extends Sandbox<Env> {
4912
4885
  // for every missing ref, so only a fetched mirror needs the re-read.
4913
4886
  // (Under the mirror mutex — the mirror cannot change in between.)
4914
4887
  const refExists = !fetched || (await this.refExists(binding.ref));
4915
- const target = attachTarget({
4888
+ let target = attachTarget({
4916
4889
  refExists,
4917
4890
  wantSha: want,
4918
4891
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
4919
4892
  });
4893
+ if (target.kind === "unknown-ref" && canReturnToDefault(binding, facts.defaultRef)) {
4894
+ // The branch a rebind moved this thread onto is gone even after the
4895
+ // fetch — deleted once its pull request merged. The binding goes
4896
+ // back to the default (item 16's second movement) and the attach
4897
+ // goes on there: the default is always in the mirror, and the
4898
+ // worktree step below provisions the tree at its tip.
4899
+ const back = await this.returnBindingToDefault(binding, facts.defaultRef);
4900
+ binding = back.binding;
4901
+ returned = back.returned;
4902
+ want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
4903
+ target = attachTarget({
4904
+ refExists: await this.refExists(binding.ref),
4905
+ wantSha: want,
4906
+ commitInMirror: false,
4907
+ });
4908
+ }
4920
4909
  if (target.kind === "unknown-ref") {
4921
4910
  throw new StepError(
4922
4911
  "unknown-ref",
@@ -4929,7 +4918,6 @@ export class ResidentDO extends Sandbox<Env> {
4929
4918
  const recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
4930
4919
  detached: target.kind === "sha",
4931
4920
  reuse,
4932
- keepTree,
4933
4921
  });
4934
4922
  return { sha, threadLockKey, recreated };
4935
4923
  }, ATTACH_MUTEX_WAIT_MS);
@@ -5027,20 +5015,20 @@ export class ResidentDO extends Sandbox<Env> {
5027
5015
  trace: this.currentSteps(),
5028
5016
  ...(rebound !== undefined ? { rebound } : {}),
5029
5017
  ...(rebindRefused !== undefined ? { rebindRefused } : {}),
5018
+ ...(returned !== undefined ? { returned } : {}),
5030
5019
  };
5031
5020
  }
5032
5021
 
5033
5022
  /** Ensure the worktree exists. A provisioning attach wipes dirty/stale
5034
- * trees; a reusing attach (`opts.reuse`, item 66) keeps a readable tree
5035
- * exactly as it stands (the dirt and the HEAD are the resumed run's own
5036
- * work) and throws `ReuseRefusedError` for one it cannot keep, touching
5037
- * nothing; a provisioning attach whose own rebind moved the record alone
5038
- * over a dirty tree already on the branch (`opts.keepTree`, item 16) keeps
5039
- * that tree as it stands too — the dirt is why the rebind declined to touch
5040
- * it — and provisions one that turns out not to be there. The decision is
5041
- * the pure `decideWorktree`; this method measures the facts and runs the
5042
- * verdict. Returns true when the tree was (re)created. MUST be called
5043
- * holding the mirror mutex: the clone reads the mirror.
5023
+ * trees — a run starts from a clean tree at the bound ref's tip (item 17),
5024
+ * and a rebind that moved the binding leaves a tree on the old branch that
5025
+ * this discipline recreates like any stale one; a reusing attach
5026
+ * (`opts.reuse`, item 66) keeps a readable tree exactly as it stands (the
5027
+ * dirt and the HEAD are the resumed run's own work) and throws
5028
+ * `ReuseRefusedError` for one it cannot keep, touching nothing. The
5029
+ * decision is the pure `decideWorktree`; this method measures the facts
5030
+ * and runs the verdict. Returns true when the tree was (re)created. MUST be
5031
+ * called holding the mirror mutex: the clone reads the mirror.
5044
5032
  *
5045
5033
  * "Dirty" is tracked-file dirt (`status --porcelain -uno`): untracked
5046
5034
  * scratch files are the thread's own state and survive re-attach.
@@ -5063,11 +5051,8 @@ export class ResidentDO extends Sandbox<Env> {
5063
5051
  /** `detached`: the bound ref is gone from the mirror and `sha` is the
5064
5052
  * expected commit it still holds (item 51) — the tree is checked out at
5065
5053
  * that commit, detached, instead of at a branch. `reuse`: a resumed
5066
- * run's attach (item 66): keep the tree as it stands, or refuse.
5067
- * `keepTree`: this attach's rebind moved the record alone over a dirty
5068
- * tree already on the branch (item 16): keep a readable tree as it
5069
- * stands, provision one that is not there. */
5070
- opts: { detached: boolean; reuse: boolean; keepTree: boolean } = { detached: false, reuse: false, keepTree: false },
5054
+ * run's attach (item 66): keep the tree as it stands, or refuse. */
5055
+ opts: { detached: boolean; reuse: boolean } = { detached: false, reuse: false },
5071
5056
  ): Promise<boolean> {
5072
5057
  const wt = binding.worktreePath;
5073
5058
  const threadDir = parentDir(wt);
@@ -5104,14 +5089,7 @@ export class ResidentDO extends Sandbox<Env> {
5104
5089
  }
5105
5090
  }
5106
5091
  }
5107
- const decision = decideWorktree({
5108
- reuse: opts.reuse,
5109
- keepTree: opts.keepTree,
5110
- modeSwitch,
5111
- sha,
5112
- worktreePath: wt,
5113
- facts,
5114
- });
5092
+ const decision = decideWorktree({ reuse: opts.reuse, modeSwitch, sha, worktreePath: wt, facts });
5115
5093
  if (decision.kind === "refuse") throw new ReuseRefusedError(decision.why);
5116
5094
  if (decision.kind === "reuse") return false;
5117
5095
 
@@ -6092,19 +6070,21 @@ export class ResidentDO extends Sandbox<Env> {
6092
6070
 
6093
6071
  /** POST /detach: a run has ended — give the thread's pool user back now
6094
6072
  * instead of holding it until the TTL sweep (the pool is sized for
6095
- * simultaneous runs). `force` releases unconditionally (read-only agents,
6096
- * hard stops): an op still in flight is KILLED first (the bot has
6097
- * already dropped its fetch, the command would otherwise run on and hold
6098
- * the user until the sweep); otherwise a busy thread, or a worktree with
6099
- * uncommitted or unpushed work, is KEPT and the caller learns why. No
6100
- * binding → 404-shaped error; already evicted → a no-op success. Never
6101
- * flips lifecycle state. */
6073
+ * simultaneous runs). A run starts from a clean tree (item 17), so the tree
6074
+ * goes whatever it holds: what it held — uncommitted changes, unpushed
6075
+ * commits — is measured once, as the thread user, and named in the answer
6076
+ * (`leftBehind`) so the loss is never silent. The one thing that keeps a
6077
+ * tree is an op still in flight in it (`busy`) — `force` (read-only agents,
6078
+ * hard stops) KILLS that op first (the bot has already dropped its fetch,
6079
+ * the command would otherwise run on and hold the user until the sweep)
6080
+ * and measures nothing. No binding → 404-shaped error; already evicted → a
6081
+ * no-op success. Never flips lifecycle state. */
6102
6082
  async detachThread(
6103
6083
  threadKey: string,
6104
6084
  force: boolean,
6105
6085
  /** What the ending run pushed (item 16a): remembered on the binding first, whatever the detach then decides. */
6106
6086
  pushed: readonly PushedBranch[] = [],
6107
- ): Promise<{ released: boolean; reason?: string; user?: string } | ThreadErr> {
6087
+ ): Promise<DetachAnswer | ThreadErr> {
6108
6088
  if (pushed.length > 0) await this.rememberOwnBranches(threadKey, pushed);
6109
6089
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
6110
6090
  if (!binding) return { error: `no-binding: ${threadKey} has never attached to this resident`, status: 404 };
@@ -6125,11 +6105,18 @@ export class ResidentDO extends Sandbox<Env> {
6125
6105
  if (left > 0) return { released: false, reason: busyAfterKillReason(left), user: binding.user };
6126
6106
  }
6127
6107
  const active = await this.isRuntimeActive().catch(() => false);
6108
+ // What the tree still holds, for the answer — never a reason to keep it.
6109
+ // Not measured on a force release: a read-only tree holds nothing, and a
6110
+ // hard stop's tree is whatever the killed command left. A probe that
6111
+ // fails names nothing (never a guess) and is a log line.
6112
+ let leftBehind: LeftBehind | undefined;
6128
6113
  if (!force && active) {
6129
- const c = await this.worktreeCleanliness(binding);
6130
- if (!c.clean) return { released: false, reason: `${c.reason} — kept`, user: binding.user };
6114
+ const measured = await this.worktreeCleanliness(binding);
6115
+ leftBehind = leftBehindOf(measured);
6116
+ if (!measured.clean && leftBehind === undefined)
6117
+ console.log(`detach: ${threadKey}: the tree could not be measured before its release (${measured.reason})`);
6131
6118
  }
6132
- // Re-check right before removal: the clean check above awaited (the DO
6119
+ // Re-check right before removal: the measurement above awaited (the DO
6133
6120
  // yields at each await), so an exec that arrived mid-detach would otherwise
6134
6121
  // have its tree removed under it.
6135
6122
  const busyNow = this.threadOpsInFlight.get(threadKey) ?? 0;
@@ -6139,22 +6126,27 @@ export class ResidentDO extends Sandbox<Env> {
6139
6126
  reason: `busy: ${busyNow} operation(s) started during detach — kept`,
6140
6127
  user: binding.user,
6141
6128
  };
6142
- // Same re-read as the sweep: a re-attach during the clean check means a
6129
+ // Same re-read as the sweep: a re-attach during the measurement means a
6143
6130
  // fresh tree we must not remove from a stale snapshot.
6144
6131
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
6145
6132
  if (!current || current.evicted) return { released: false, reason: "already-evicted" };
6146
6133
  if (current.lastAttachAt !== binding.lastAttachAt)
6147
- return { released: false, reason: "re-attached during the clean check — kept", user: current.user };
6134
+ return { released: false, reason: "re-attached during the detach — kept", user: current.user };
6148
6135
  const user = current.user;
6149
- // Same as the sweep: `active` was read before the clean check's awaits; a
6136
+ // Same as the sweep: `active` was read before the measurement's awaits; a
6150
6137
  // container that woke meanwhile must get the rm, not an orphaned tree.
6151
6138
  const activeNow = await this.isRuntimeActive().catch(() => false);
6152
6139
  if (!(await this.evictBinding(current, activeNow, `detach`, "detach"))) {
6153
6140
  return { released: false, reason: "re-attached during eviction — kept", user };
6154
6141
  }
6142
+ if (leftBehind) {
6143
+ console.log(
6144
+ `detach: ${threadKey} released — left behind ${leftBehind.uncommittedChanges} uncommitted change(s) and ${leftBehind.unpushedCommits} unpushed commit(s), discarded with the tree`,
6145
+ );
6146
+ }
6155
6147
  // Item 55: the tree is gone; the gauge catches up at the next refresh
6156
6148
  // instance's `measure` step, and the admission's `df` sees the space now.
6157
- return { released: true, user };
6149
+ return { released: true, user, ...(leftBehind !== undefined ? { leftBehind } : {}) };
6158
6150
  }
6159
6151
 
6160
6152
  /** Force-detach's kill: end every process owned by the pool user —
@@ -6254,35 +6246,29 @@ export class ResidentDO extends Sandbox<Env> {
6254
6246
  const ttlDays = record?.worktreeTtlDays ?? WORKTREE_TTL_DAYS_DEFAULT;
6255
6247
  const cutoff = systemClock() - ttlDays * 86_400_000;
6256
6248
  const all = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
6257
- const active = await this.isRuntimeActive().catch(() => false);
6258
6249
  const idleCutoff = systemClock() - CLEAN_IDLE_RELEASE_S * 1000;
6259
6250
  for (const binding of all.values()) {
6260
6251
  if (binding.evicted || !binding.user) continue;
6261
6252
  const last = Date.parse(binding.lastAttachAt);
6262
6253
  if (last >= cutoff) {
6263
- // Not past the TTL. Still release it if it has been idle for an hour,
6264
- // nothing is running on it, and the tree is provably clean — the run
6265
- // that used it is over and there is nothing to preserve. A slept
6266
- // container has NO tree any more (sleep destroys the disk), so an
6267
- // idle binding on an inactive runtime is releasable outright: there is
6268
- // nothing left to protect, only a pool user to give back. (Keeping
6269
- // them would leave idle bindings on a sleeping resident until the
6270
- // 7-day TTL.)
6254
+ // Not past the TTL. Still release it if it has been idle for an hour
6255
+ // and nothing is running on it: the run that used it is over, and
6256
+ // whatever its tree holds has no future — the next attach provisions
6257
+ // a clean tree (item 17) — so there is nothing to keep it for. This is
6258
+ // what drains the bindings of runs whose release never came (a
6259
+ // resident that was sick at the run's end, a run older than
6260
+ // `/detach`). A live run is protected by its op in flight, not by its
6261
+ // dirt. A slept container has no tree any more anyway (sleep destroys
6262
+ // the disk).
6271
6263
  const busy = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6272
- const cleanIdle =
6273
- last < idleCutoff && busy === 0 && (!active || (await this.worktreeCleanliness(binding)).clean);
6274
- // Re-read right before removal: the clean check awaited (the DO
6275
- // yields), so an exec that arrived meanwhile would otherwise have
6276
- // its tree removed under it — same guard as detachThread.
6277
- const busyNow = this.threadOpsInFlight.get(binding.threadKey) ?? 0;
6278
- if (!cleanIdle || busyNow > 0) {
6264
+ if (last >= idleCutoff || busy > 0) {
6279
6265
  kept++;
6280
6266
  continue;
6281
6267
  }
6282
6268
  }
6283
- // Re-read the binding too: a re-attach that completed inside the
6284
- // clean-check await bumped lastAttachAt and rebuilt the tree — evicting
6285
- // from this loop's stale snapshot would rm the fresh tree.
6269
+ // Re-read the binding: earlier iterations awaited (the DO yields), so a
6270
+ // re-attach that completed meanwhile bumped lastAttachAt and rebuilt the
6271
+ // tree — evicting from this loop's stale snapshot would rm the fresh tree.
6286
6272
  const current = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(binding.threadKey));
6287
6273
  if (!current || current.evicted || current.lastAttachAt !== binding.lastAttachAt) {
6288
6274
  kept++;