@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.
- package/dist/assets/deploy/cloudflare-resident/gc.ts +6 -9
- package/dist/assets/deploy/cloudflare-resident/worker.ts +310 -277
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +82 -13
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +13 -7
- package/dist/assets/src/core/provider.ts +9 -6
- package/dist/assets/src/core/runEvents.ts +25 -0
- package/dist/assets/src/execution/residentCleanliness.ts +69 -5
- package/dist/assets/src/execution/residentDiskBudget.ts +7 -6
- package/dist/assets/src/execution/residentRebind.ts +92 -77
- package/dist/assets/src/execution/residentReuse.ts +7 -18
- package/dist/assets/src/execution/residentSteps.ts +0 -1
- package/dist/assets/src/execution/sandboxErrors.ts +5 -0
- package/dist/assets/src/execution/sandboxIdle.ts +220 -0
- package/dist/assets/web/dist/.vite/manifest.json +68 -28
- package/dist/assets/web/dist/assets/{ResidentDetailPage-V4K35SYX.js → ResidentDetailPage-DSpsye5e.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-3HuYaDwD.js → ResidentsIndexPage-CT8fI1M3.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +9 -0
- package/dist/assets/web/dist/assets/RunRoutePage-oN9GkVEA.js +6 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +1 -0
- package/dist/assets/web/dist/assets/{ScheduledPage-dfrjQ5E9.js → ScheduledPage-Tz__zLzi.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DexKtvUC.js → StatusDot-D14iJMy7.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DGQ2gu9M.js → Tooltip-B0Ob5MQ4.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +1 -0
- package/dist/assets/web/dist/assets/{dist-boTdDLF4.js → dist-pOdUzj6D.js} +1 -1
- package/dist/assets/web/dist/assets/favicon-BQsePYv5.js +1 -0
- package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +1 -0
- package/dist/assets/web/dist/assets/{main-BHhLVMQh.js → main-D3lG-yQ9.js} +2 -2
- package/dist/assets/web/dist/assets/main-Dm11o0hc.css +1 -0
- package/dist/cli.js +1231 -586
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/RunRoutePage-Dr867-Ha.js +0 -13
- package/dist/assets/web/dist/assets/RunsIndexPage-D_lpXsr8.js +0 -1
- package/dist/assets/web/dist/assets/favicon-CWPcvWvp.js +0 -1
- 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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// 5 minutes
|
|
176
|
-
//
|
|
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.
|
|
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
|
-
|
|
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.
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
20035
|
+
"version": "1.235.0",
|
|
20036
20036
|
"license": "Apache-2.0",
|
|
20037
20037
|
"dependencies": {
|
|
20038
20038
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
-
* `
|
|
100
|
-
* starts other runs) opts out of the table
|
|
101
|
-
* router's compound form alone, with its parts
|
|
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
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* {type: "
|
|
45
|
-
*
|
|
46
|
-
|
|
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 {
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
323
|
-
*
|
|
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;
|