@coreplane/switchboard 1.233.0 → 1.235.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 (37) hide show
  1. package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +310 -277
  3. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +82 -13
  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 +25 -0
  10. package/dist/assets/src/execution/residentCleanliness.ts +69 -5
  11. package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
  12. package/dist/assets/src/execution/residentRebind.ts +92 -77
  13. package/dist/assets/src/execution/residentReuse.ts +7 -18
  14. package/dist/assets/src/execution/residentSteps.ts +0 -1
  15. package/dist/assets/src/execution/sandboxErrors.ts +5 -0
  16. package/dist/assets/src/execution/sandboxIdle.ts +220 -0
  17. package/dist/assets/web/dist/.vite/manifest.json +68 -28
  18. package/dist/assets/web/dist/assets/{ResidentDetailPage-V4K35SYX.js → ResidentDetailPage-DSpsye5e.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ResidentsIndexPage-3HuYaDwD.js → ResidentsIndexPage-CT8fI1M3.js} +1 -1
  20. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +9 -0
  21. package/dist/assets/web/dist/assets/RunRoutePage-oN9GkVEA.js +6 -0
  22. package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +1 -0
  23. package/dist/assets/web/dist/assets/{ScheduledPage-dfrjQ5E9.js → ScheduledPage-Tz__zLzi.js} +1 -1
  24. package/dist/assets/web/dist/assets/{StatusDot-DexKtvUC.js → StatusDot-D14iJMy7.js} +1 -1
  25. package/dist/assets/web/dist/assets/{Tooltip-DGQ2gu9M.js → Tooltip-B0Ob5MQ4.js} +1 -1
  26. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +1 -0
  27. package/dist/assets/web/dist/assets/{dist-boTdDLF4.js → dist-pOdUzj6D.js} +1 -1
  28. package/dist/assets/web/dist/assets/favicon-BQsePYv5.js +1 -0
  29. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +1 -0
  30. package/dist/assets/web/dist/assets/{main-BHhLVMQh.js → main-D3lG-yQ9.js} +2 -2
  31. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +1 -0
  32. package/dist/cli.js +1231 -586
  33. package/package.json +1 -1
  34. package/dist/assets/web/dist/assets/RunRoutePage-Dr867-Ha.js +0 -13
  35. package/dist/assets/web/dist/assets/RunsIndexPage-D_lpXsr8.js +0 -1
  36. package/dist/assets/web/dist/assets/favicon-CWPcvWvp.js +0 -1
  37. package/dist/assets/web/dist/assets/main-CAVqMbiX.css +0 -1
@@ -43,6 +43,7 @@ import {
43
43
  recycledMidCommandMessage,
44
44
  } from "../../src/execution/sandboxLifecycle.js";
45
45
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
+ import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
46
47
  import { RUNTIME_REPLACEMENT_WORDING, isRuntimeUnreachableSignal } from "../../src/execution/residentRefresh.js";
47
48
  import {
48
49
  fleetBusyAnswer,
@@ -166,16 +167,66 @@ interface FileRefusal {
166
167
  reason?: string;
167
168
  }
168
169
 
170
+ /** Durable Object storage key of the idle ledger's served-time. */
171
+ const IDLE_LEDGER_KEY = "switchboard.idle.lastServedAt";
172
+ /** The scheduled-callback name of the idle sweep (a method below). */
173
+ const IDLE_SWEEP_CALLBACK = "idleSweep";
174
+
169
175
  export class SwitchboardSandbox extends Sandbox<Env> {
170
- // Idle lifetime of a thread's container (the SDK's own default is 10 min).
171
- // Idle means idle: the SDK renews the activity timeout every second while a
172
- // command's stream is open (its control connection's busy poll), so a
173
- // running command never counts toward it and the shell-level `timeout` is
174
- // the one deadline a command can hit (docs/reference/specs/execution.md item 2).
175
- // 5 minutes frees the slot sooner while a prompt follow-up still reuses the
176
- // warm workspace.
176
+ // The SDK's idle setting (its own default is 10 min), kept so its alarm
177
+ // loop calls `onActivityExpired` on this cadence — but on the 0.13 line that
178
+ // hook is a question to the runtime ("anything still running?"), not a
179
+ // deadline, and the answer kept twenty-five containers awake for 16 hours.
180
+ // The deadline itself is the guard's (docs/reference/specs/execution.md
181
+ // item 22): 5 minutes after the last request this object served, the
182
+ // container is destroyed, whatever runs inside; the shell-level `timeout`
183
+ // stays the one deadline a command can hit (item 2), since a request in
184
+ // flight is service.
177
185
  sleepAfter = SANDBOX_SLEEP_AFTER;
178
186
 
187
+ private readonly idle: IdleGuard;
188
+
189
+ constructor(...args: ConstructorParameters<typeof Sandbox<Env>>) {
190
+ super(...args);
191
+ this.idle = new IdleGuard(this.idleHost());
192
+ // Every wake, including the one the SDK's own alarm causes on a leaked
193
+ // container: the baseline is read and a sweep armed before any request.
194
+ this.ctx.blockConcurrencyWhile(() => this.idle.wake());
195
+ }
196
+
197
+ /** The guard's view of this object: the platform's running flag, the SDK's
198
+ * schedule table and clean destroy, the platform's kill, and storage. */
199
+ private idleHost(): IdleGuardHost {
200
+ return {
201
+ now: systemClock,
202
+ containerRunning: () => this.ctx.container?.running,
203
+ sweepScheduled: async () => (await this.listSchedules(IDLE_SWEEP_CALLBACK)).length > 0,
204
+ scheduleSweep: async (delayMs) => {
205
+ await this.schedule(Math.ceil(delayMs / 1000), IDLE_SWEEP_CALLBACK);
206
+ },
207
+ destroySandbox: () => this.destroy(),
208
+ killContainer: async () => {
209
+ await this.ctx.container?.destroy();
210
+ },
211
+ loadLastServedAt: () => this.ctx.storage.get<number>(IDLE_LEDGER_KEY),
212
+ saveLastServedAt: (at) => this.ctx.storage.put(IDLE_LEDGER_KEY, at),
213
+ log: (event) => console.log(JSON.stringify({ ...event, thread: this.ctx.id.name ?? this.ctx.id.toString() })),
214
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
215
+ };
216
+ }
217
+
218
+ /** The idle sweep, scheduled by the guard through the SDK's schedule table
219
+ * and re-armed by it while a container is running. */
220
+ async idleSweep(): Promise<void> {
221
+ await this.idle.sweep();
222
+ }
223
+
224
+ /** The SDK's activity expiry, answered by the guard's verdict instead of the
225
+ * runtime's process probes. */
226
+ override async onActivityExpired(): Promise<void> {
227
+ await this.idle.expired();
228
+ }
229
+
179
230
  /** One command as one supervised process (docs/reference/specs/execution.md
180
231
  * item 4): `timeout -k 10 <secs> bash -c 'mkdir -p /workspace && cd
181
232
  * /workspace && <command>'`, the caller's env on the process alone (item 5),
@@ -188,6 +239,16 @@ export class SwitchboardSandbox extends Sandbox<Env> {
188
239
  command: string,
189
240
  execTimeoutSecs: number,
190
241
  envVars: Record<string, string>,
242
+ ): Promise<ExecAnswer | ExecFailure> {
243
+ return this.idle.served(() => this.execute(command, execTimeoutSecs, envVars));
244
+ }
245
+
246
+ /** `runCommand` without the ledger entry: the body, and the internal caller
247
+ * (`readBase64`'s stat) that is already inside a served request. */
248
+ private async execute(
249
+ command: string,
250
+ execTimeoutSecs: number,
251
+ envVars: Record<string, string>,
191
252
  ): Promise<ExecAnswer | ExecFailure> {
192
253
  const startedAt = systemClock();
193
254
  const full = `mkdir -p ${WORKDIR} && cd ${WORKDIR} && ${command}`;
@@ -277,7 +338,9 @@ export class SwitchboardSandbox extends Sandbox<Env> {
277
338
  }
278
339
 
279
340
  async readText(path: string): Promise<{ content: string } | FileRefusal> {
280
- return this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content }));
341
+ return this.idle.served(() =>
342
+ this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
343
+ );
281
344
  }
282
345
 
283
346
  /** `encoding: "base64"` (src/execution/binaryRead.ts): the size first, from
@@ -285,7 +348,11 @@ export class SwitchboardSandbox extends Sandbox<Env> {
285
348
  * decoded bytes to it — an SDK read that came back short would otherwise
286
349
  * pass as the file. A file over the cap is refused by name inside a 200. */
287
350
  async readBase64(path: string): Promise<Base64ReadAnswer | FileRefusal> {
288
- const stat = await this.runCommand(statCommandFor(path), 60, {});
351
+ return this.idle.served(() => this.readBase64Now(path));
352
+ }
353
+
354
+ private async readBase64Now(path: string): Promise<Base64ReadAnswer | FileRefusal> {
355
+ const stat = await this.execute(statCommandFor(path), 60, {});
289
356
  if ("error" in stat) {
290
357
  // The stat's own named failure — a full fleet, a silent control port —
291
358
  // is the read's, token included, so the executor waits as it would
@@ -309,10 +376,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
309
376
  }
310
377
 
311
378
  async write(path: string, content: string): Promise<{ ok: true } | FileRefusal> {
312
- return this.fileOp(async () => {
313
- await this.writeFile(path, content);
314
- return { ok: true as const };
315
- });
379
+ return this.idle.served(() =>
380
+ this.fileOp(async () => {
381
+ await this.writeFile(path, content);
382
+ return { ok: true as const };
383
+ }),
384
+ );
316
385
  }
317
386
  }
318
387
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.233.0",
3
+ "version": "1.235.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.233.0",
9
+ "version": "1.235.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20032,7 +20032,7 @@
20032
20032
  },
20033
20033
  "packages/switchboard": {
20034
20034
  "name": "@coreplane/switchboard",
20035
- "version": "1.233.0",
20035
+ "version": "1.235.0",
20036
20036
  "license": "Apache-2.0",
20037
20037
  "dependencies": {
20038
20038
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.233.0",
3
+ "version": "1.235.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.233.0",
3
- "commit": "4c96f69d476e76e2bc768a9eb6d505b683fddd36",
4
- "builtAt": "2026-09-16T15:18:25.322Z"
2
+ "version": "1.235.0",
3
+ "commit": "091ab2f1f6d89290bde9c84e933d23525f9c8873",
4
+ "builtAt": "2026-09-16T17:37:07.278Z"
5
5
  }
@@ -96,9 +96,10 @@ export interface AgentDef {
96
96
  * router's table is rendered from this registry. `false` keeps a preset
97
97
  * out of the table — structurally: it is absent from the table the model
98
98
  * is shown and refused as a single route even if the model names it.
99
- * `ship` (it holds the merge grant) opts out for good; `conductor` (it
100
- * starts other runs) opts out of the table and is reached through the
101
- * router's compound form alone, with its parts named. */
99
+ * `coding` (a bare write ask deserves the review loop, so `ship` holds its
100
+ * seat) opts out; `conductor` (it starts other runs) opts out of the table
101
+ * and is reached through the router's compound form alone, with its parts
102
+ * named. */
102
103
  routable?: false;
103
104
  /** System prompt variant for resident-repo runs (docs/reference/specs/resident-repos.md):
104
105
  * the workspace is a ready worktree — no cloning, no installs, no repo
@@ -535,6 +536,11 @@ const WORK_PRESETS = {
535
536
  // `config set channel efforts.coding=…`, or `effort:` per request).
536
537
  machine: "repo-resident",
537
538
  identity: "write", // pushes branches and opens pull requests
539
+ // Never routed: a plain write ask deserves the coding → review loop, so
540
+ // the router's table offers `ship` in coding's seat — a routed ship runs
541
+ // a generated one-unit plan whose merge is a person's, never the runner's.
542
+ // A request that wants a bare coding run names it — `agent:coding`.
543
+ routable: false,
538
544
  },
539
545
  review: {
540
546
  name: "review",
@@ -568,10 +574,10 @@ const WORK_PRESETS = {
568
574
  toolset: "full",
569
575
  machine: "repo-resident",
570
576
  identity: "write",
571
- // Never routed: ship is the plan runner and holds the merge grant, so a
572
- // wrong route into it is code landing on main, not a stray pull request.
573
- // A request that wants it names it — `agent:ship`.
574
- routable: false,
577
+ // Routable: a routed ship runs a generated one-unit plan whose merge is a
578
+ // person's (`merge: person`) and never a seeded plan (the hand-off refuses
579
+ // a routed `plan <path>.md` naming `agent:ship`), so a wrong route costs a
580
+ // reviewed pull request, never code landing on main.
575
581
  maxTurns: 1,
576
582
  maxTokens: 16000,
577
583
  maxMinutes: 120,
@@ -38,12 +38,15 @@ export interface CompletionRequest {
38
38
  system?: string;
39
39
  messages: ChatMessage[];
40
40
  tools?: ToolDef[];
41
- /** Force one of `tools`: the model must answer by calling the named tool,
42
- * so the call's input IS the answer and prose cannot occur (the request
43
- * router's shape, routing-and-config item 21). Anthropic: `tool_choice:
44
- * {type: "tool", name}`; Chat Completions: `tool_choice: {type: "function",
45
- * function: {name}}`. Absent → the model chooses. */
46
- toolChoice?: { type: "tool"; name: string };
41
+ /** Force tool calling (the request router's shape, routing-and-config item
42
+ * 21). `{type: "tool", name}` forces the named tool — Anthropic:
43
+ * `tool_choice: {type: "tool", name}`; Chat Completions: `tool_choice:
44
+ * {type: "function", function: {name}}` — so the call's input IS the answer
45
+ * and prose cannot occur. `{type: "any"}` forces one call to some tool of
46
+ * `tools` — spelled `"any"` on Anthropic's Messages API and `"required"` on
47
+ * Chat Completions, with parallel tool calls switched off on the wire so
48
+ * the answer is exactly one call. Absent → the model chooses. */
49
+ toolChoice?: { type: "tool"; name: string } | { type: "any" };
47
50
  maxTokens: number;
48
51
  /** model effort hint; providers apply it only where the model supports it */
49
52
  effort?: Effort;
@@ -137,6 +137,14 @@ export type RunNoteKind =
137
137
  * attach, before the first turn, so the run page explains a follow-up that
138
138
  * runs on the default instead of on its thread's PR. */
139
139
  | "rebind_refused"
140
+ /** The run ended with uncommitted changes or unpushed commits in its
141
+ * workspace, and they do not outlive it: a run starts from a clean tree
142
+ * (docs/reference/specs/resident-repos.md item 17), so the release that
143
+ * follows the reply discards them. The summary names both counts and what
144
+ * to do instead (commit and push). Read off the workspace by the run loop
145
+ * after the model's last turn — the release itself runs after the record
146
+ * is sealed — and set on the card's label too, so the loss is never silent. */
147
+ | "work_left_behind"
140
148
  /** A coding run submitted a PR description but the post-step opened no
141
149
  * pull request because the branch it observed IS the base the pull
142
150
  * request would target (docs/reference/specs/pr-description.md item 5) —
@@ -201,6 +209,7 @@ export const RUN_NOTE_KINDS = [
201
209
  "verdict_turn",
202
210
  "cold_sandbox",
203
211
  "rebind_refused",
212
+ "work_left_behind",
204
213
  "pr_not_opened",
205
214
  "review_not_posted",
206
215
  "compacted",
@@ -358,6 +367,11 @@ export type RunEvent =
358
367
  * past the summary cap. The status card and friction analyzer keep reading
359
368
  * `summary`. */
360
369
  /** `spanId` (docs/reference/specs/tracing.md): the `tool.*` span this call ran under, once the runner emits spans. */
370
+ /** `logIndex` (docs/reference/specs/run-history.md item 53): the row of the run's
371
+ * session log holding the assistant turn this call rode in — on a `tool_result`,
372
+ * the user turn its batch's results make. How a search hit's turn finds its
373
+ * step on the page. Absent for a run without a session, a record from before
374
+ * the field, and a call the mirror could not place. */
361
375
  | {
362
376
  type: "tool_call";
363
377
  tool: string;
@@ -365,6 +379,7 @@ export type RunEvent =
365
379
  command?: string;
366
380
  callId?: string;
367
381
  spanId?: string;
382
+ logIndex?: number;
368
383
  seq?: number;
369
384
  at?: number;
370
385
  }
@@ -384,6 +399,7 @@ export type RunEvent =
384
399
  output?: string;
385
400
  infra?: true;
386
401
  spanId?: string;
402
+ logIndex?: number;
387
403
  seq?: number;
388
404
  at?: number;
389
405
  }
@@ -483,11 +499,20 @@ export type RunEvent =
483
499
  model?: string;
484
500
  /** The request's trace id (docs/reference/specs/tracing.md), once the root exists. */
485
501
  traceId?: string;
502
+ /** The harness the run is driven by (`Harness.name`; docs/reference/specs/harness.md
503
+ * item 8): `pi` today. Absent on a command run, which starts no process,
504
+ * and on a record written before the seam existed. Additive. */
505
+ harness?: string;
486
506
  effort?: string;
487
507
  repo?: string;
488
508
  ref?: string;
489
509
  pr?: number;
490
510
  headSha?: string;
511
+ /** The plan runner instance whose story this record is (agent-ship item
512
+ * 17): written on the pipeline's own record alone, so its page can list
513
+ * the instance's units. A child's instance rides the record's
514
+ * `parentInstanceId`, never here. */
515
+ instanceId?: string;
491
516
  seq?: number;
492
517
  at?: number;
493
518
  }
@@ -5,7 +5,7 @@
5
5
  * shipped code.
6
6
  *
7
7
  * Background: the check used to be three sequential container spawns per
8
- * binding (`test -d`, `su … git status --porcelain`, `su … git rev-list
8
+ * binding (`test -d`, `su … git status --porcelain -uno`, `su … git rev-list
9
9
  * --count HEAD --not --remotes`), and `isIdle()` ran it serially per live
10
10
  * binding — every refresh cycle's idle gate paid 3×N process round-trips.
11
11
  * The three probes fold into one `sh -c` script; the DECISION semantics are
@@ -42,7 +42,7 @@ export function worktreeCleanlinessScript(worktreePath: string, user: string): s
42
42
  const inner = [
43
43
  `t=$(mktemp) || { echo gitrc=1; echo 'giterr=mktemp failed'; exit 0; }`,
44
44
  `cd ${wt} 2>"$t" || { echo gitrc=1; printf 'giterr=%s\\n' "$(grep -m 1 . "$t" || echo 'cd failed')"; rm -f "$t"; exit 0; }`,
45
- `s_out=$(git status --porcelain 2>"$t"); s_rc=$?; s_err=$(grep -m 1 . "$t" || true)`,
45
+ `s_out=$(git status --porcelain -uno 2>"$t"); s_rc=$?; s_err=$(grep -m 1 . "$t" || true)`,
46
46
  `a_out=$(git rev-list --count HEAD --not --remotes 2>"$t"); a_rc=$?; a_err=$(grep -m 1 . "$t" || true)`,
47
47
  `rm -f "$t"`,
48
48
  `if [ "$s_rc" -ne 0 ] || [ "$a_rc" -ne 0 ]; then`,
@@ -66,6 +66,64 @@ export function worktreeCleanlinessScript(worktreePath: string, user: string): s
66
66
  export interface WorktreeCleanliness {
67
67
  clean: boolean;
68
68
  reason?: string;
69
+ /** What the probes counted, when they ran: the tracked files `git status
70
+ * --porcelain -uno` listed as changed — item 17's definition of dirt, the
71
+ * same the run loop counts for its note, so the card and the release log
72
+ * name one number; untracked scratch is the thread's own and not work —
73
+ * and the commits on no remote branch. Absent when the tree is missing or
74
+ * a probe failed. */
75
+ changes?: number;
76
+ unpushed?: number;
77
+ }
78
+
79
+ /** What a release discards (docs/reference/specs/resident-repos.md item 16a):
80
+ * the counts a run left in its tree, named in the detach answer and the
81
+ * bot's log so the loss is never silent. */
82
+ export interface LeftBehind {
83
+ uncommittedChanges: number;
84
+ unpushedCommits: number;
85
+ }
86
+
87
+ /** The counts the probes measured, as what a release leaves behind: nothing
88
+ * when the probes could not run (a tree that is gone or unreadable) and
89
+ * nothing when both are zero — the answer names only a loss. */
90
+ export function leftBehindOf(measured: WorktreeCleanliness): LeftBehind | undefined {
91
+ if (measured.changes === undefined || measured.unpushed === undefined) return undefined;
92
+ if (measured.changes === 0 && measured.unpushed === 0) return undefined;
93
+ return { uncommittedChanges: measured.changes, unpushedCommits: measured.unpushed };
94
+ }
95
+
96
+ /** What an eviction records about the tree it removed
97
+ * (docs/reference/specs/resident-repos.md item 17: dirt never keeps a tree,
98
+ * and nothing is discarded silently), written on the binding beside the
99
+ * cause: the counts when the probes ran and found something, or the probe's
100
+ * first error line when git could not read the tree — nothing readable was
101
+ * kept, but the record must not read as clean. */
102
+ export type EvictedTree = { leftBehind: LeftBehind } | { unmeasured: string };
103
+
104
+ /** The measurement as the eviction's record: `undefined` for a clean tree or
105
+ * one already gone with the disk (nothing was discarded). */
106
+ export function evictedTreeOf(measured: WorktreeCleanliness): EvictedTree | undefined {
107
+ const leftBehind = leftBehindOf(measured);
108
+ if (leftBehind) return { leftBehind };
109
+ if (!measured.clean) return { unmeasured: measured.reason ?? "clean-check failed" };
110
+ return undefined;
111
+ }
112
+
113
+ /** The resident's log line for what an eviction discarded. */
114
+ export function evictedTreeSentence(tree: EvictedTree): string {
115
+ if ("leftBehind" in tree)
116
+ return `left behind ${tree.leftBehind.uncommittedChanges} uncommitted change(s) and ${tree.leftBehind.unpushedCommits} unpushed commit(s), discarded with the tree`;
117
+ return `the tree could not be measured before its removal (${tree.unmeasured})`;
118
+ }
119
+
120
+ /** The bot's word for a discarded tree, in the release log and the run's
121
+ * record: what was left, why it is gone, what to do instead. */
122
+ export function leftBehindSentence(left: LeftBehind): string {
123
+ return (
124
+ `${left.uncommittedChanges} uncommitted change(s) and ${left.unpushedCommits} unpushed commit(s) were left in the worktree; ` +
125
+ "a run starts from a clean tree, so they were discarded — commit and push what must be kept"
126
+ );
69
127
  }
70
128
 
71
129
  /** Decide from the script's tagged output. Unknown (missing/failed tags,
@@ -89,7 +147,13 @@ export function parseWorktreeCleanliness(r: {
89
147
  }
90
148
  const changes = Number(tags.get("changes")) || 0;
91
149
  const unpushed = Number(tags.get("unpushed")) || 0;
92
- if (changes > 0 || unpushed > 0)
93
- return { clean: false, reason: `dirty: ${changes} uncommitted change(s), ${unpushed} unpushed commit(s)` };
94
- return { clean: true };
150
+ if (changes > 0 || unpushed > 0) {
151
+ return {
152
+ clean: false,
153
+ reason: `dirty: ${changes} uncommitted change(s), ${unpushed} unpushed commit(s)`,
154
+ changes,
155
+ unpushed,
156
+ };
157
+ }
158
+ return { clean: true, changes, unpushed };
95
159
  }
@@ -311,17 +311,18 @@ export interface DiskEvictionCandidate {
311
311
 
312
312
  /** Why a tree was kept under disk pressure. `other` is the caller's fallback
313
313
  * for a keep decided outside `orderEvictionCandidates` (a binding that moved
314
- * during the check); free text never becomes a token. */
315
- export type DiskKeepWhy = "busy" | "default-ref" | "recent" | "dirty" | "requesting" | "other";
314
+ * during the check); free text never becomes a token. No token names dirt:
315
+ * a tree is never kept for what it holds (a run starts from a clean tree),
316
+ * the Worker records what it removes instead. */
317
+ export type DiskKeepWhy = "busy" | "default-ref" | "recent" | "requesting" | "other";
316
318
 
317
319
  /** Order the live trees for eviction under pressure and name every one that is
318
320
  * kept: the requesting thread itself, a busy tree, the default branch (the
319
321
  * one most likely re-attached — the same rule as `reclaimDecision`), and a
320
322
  * tree attached within `DISK_EVICT_MIN_IDLE_MS` are never candidates; the
321
- * rest are ordered coldest first (oldest `lastAttachAt`, ties on key).
322
- * Cleanliness is NOT decided here — it needs the container (as the thread
323
- * user), so the Worker checks each candidate in this order and keeps a dirty
324
- * or unreadable one (`dirty`), exactly like the sweep. */
323
+ * rest are ordered coldest first (oldest `lastAttachAt`, ties on key). What
324
+ * each tree holds is not asked — the Worker measures it as the thread user
325
+ * right before the eviction, for the record only. */
325
326
  export function orderEvictionCandidates(input: {
326
327
  candidates: readonly DiskEvictionCandidate[];
327
328
  now: number;