trantor 0.18.117 → 0.18.118

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.117",
3
+ "version": "0.18.118",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -4,7 +4,7 @@ import { call } from "./core.mjs";
4
4
 
5
5
  export const runnerRecord = (ctx, project, agent) => join(ctx.seatDir, `${project}-${agent}.runner.json`);
6
6
 
7
- export function seatProcesses(ctx, project, agent) {
7
+ export function seatProcesses(ctx, project, agent, exec = call) {
8
8
  if (ctx.env.CREW_NO_PROC_KILL === "1") return [];
9
9
  let label = agent, dir = ctx.dir;
10
10
  try {
@@ -12,7 +12,9 @@ export function seatProcesses(ctx, project, agent) {
12
12
  label = record.label;
13
13
  dir = record.dir;
14
14
  } catch (error) { if (error.code !== "ENOENT") throw error; }
15
- const rows = call("ps", ["-axo", "pid=,ppid=,command="]).stdout.split("\n").map(line => {
15
+ const snapshot = exec("ps", ["-axo", "pid=,ppid=,command="]);
16
+ if (snapshot.status !== 0) throw new Error("could not read crew processes");
17
+ const rows = snapshot.stdout.split("\n").map(line => {
16
18
  const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
17
19
  return match ? { pid: Number(match[1]), parent: Number(match[2]), command: match[3] } : null;
18
20
  }).filter(Boolean);
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { seatProcesses, signalProcesses } from "./processes.mjs";
4
4
  import { call, listPids, run } from "./core.mjs";
5
5
  import { hostId } from "../../lib/project.mjs";
6
+ import { removeSeatWorktree } from "../../lib/seat-worktree.mjs";
6
7
  import { signedPost } from "../../hooks/lib/api.mjs";
7
8
  import { statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../../lib/docker-janitor.mjs";
8
9
 
@@ -130,7 +131,7 @@ async function unregisterSeat(ctx, project, persona) {
130
131
  return response.json;
131
132
  }
132
133
 
133
- export async function down(ctx, args, adapters, { unregister = unregisterSeat } = {}) {
134
+ export async function down(ctx, args, adapters, { unregister = unregisterSeat, removeWorktree = removeSeatWorktree, processes = seatProcesses } = {}) {
134
135
  const options = parseDownArgs(ctx, args);
135
136
  if (options.help) return 0;
136
137
  if (options.error) return 1;
@@ -175,6 +176,27 @@ export async function down(ctx, args, adapters, { unregister = unregisterSeat }
175
176
  `${JSON.stringify({ ts: Date.now(), agent: row.persona, project: row.project, stopped: true })}\n`);
176
177
  }
177
178
  }
179
+ // #12269: a stopped seat's worktree goes with it — `git worktree remove`, branch kept; a tree
180
+ // with uncommitted changes stays on disk and is named here so the operator can recover it.
181
+ for (const row of seats.values()) {
182
+ if (!row.project) continue;
183
+ if (!ctx.dry) {
184
+ try {
185
+ const tracked = readRows(ctx).some(current => current.project === row.project && current.persona === row.persona);
186
+ if (tracked || processes(ctx, row.project, row.persona).length) {
187
+ console.log(` worktree kept (crew row or process remains): ${row.persona}:${row.project}`);
188
+ continue;
189
+ }
190
+ } catch (error) {
191
+ console.log(` worktree kept (crew liveness unknown: ${error.message}): ${row.persona}:${row.project}`);
192
+ continue;
193
+ }
194
+ }
195
+ const r = await removeWorktree({ project: row.project, agent: row.persona, home: ctx.home || undefined, env: ctx.env, dry: ctx.dry });
196
+ if (r.status === "removed") console.log(` worktree removed: ${r.path} (branch seat/${row.persona} kept)`);
197
+ else if (r.status === "kept") console.log(` worktree kept (${r.reason}): ${r.path}`);
198
+ else if (r.status === "would-remove") console.log(` [dry] would remove worktree: ${r.path}`);
199
+ }
178
200
  if (failures.length) {
179
201
  console.log(`— hub row removal incomplete; retry Stop: ${failures.join("; ")}`);
180
202
  return 1;
package/bin/disk.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor disk — report where the disk went; --clean runs the safe tier ONLY;
3
- // install wires a Monday 09:00 launchd job; --weekly is what that job runs
4
- // (safe clean, then exactly ONE summary message on the bus for the operator).
3
+ // install wires a daily 09:00 launchd job; --daily (or the legacy --weekly) is what
4
+ // that job runs (safe clean, then exactly ONE summary message on the bus for the operator).
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { join, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -52,7 +52,7 @@ async function install() {
52
52
  // bootout+bootstrap can race an existing job; load -w is the legacy fallback.
53
53
  exec("launchctl", ["load", "-w", plistPath]);
54
54
  }
55
- console.log(`installed: ${plistPath}\nruns every Monday 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
55
+ console.log(`installed: ${plistPath}\nruns every day at 09:00 (safe tier only); result lands as one bus message. Log: ${logPath}`);
56
56
  }
57
57
 
58
58
  function exec(file, args) {
@@ -64,19 +64,23 @@ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
64
64
  console.log(`trantor disk — where the space went, and the provably safe slice to reclaim
65
65
 
66
66
  trantor disk report only (also prints exactly what the safe tier would delete)
67
- trantor disk --dry-run the same report, explicit
68
- trantor disk --clean run the SAFE tier: unavailable simulators, build output and
69
- stale scratch (.agent-bus-out/.scratch entries idle 2+ days) in
70
- seat worktrees with no live turn (ps + the runner's turn stamp),
71
- opencode sessions idle >14 days. Everything else stays.
67
+ trantor disk --dry-run the same report, explicit — never cleans
68
+ trantor disk --clean run the SAFE tier: unavailable simulators, whole seat worktrees
69
+ idle 7+ days with no uncommitted changes (branch kept), build
70
+ output (DerivedData, *.xcresult, target/) idle 3+ days, stale
71
+ scratch (.agent-bus-out/.scratch entries idle 2+ days) and build
72
+ dirs in seat worktrees with no live turn, opencode sessions idle
73
+ >14 days. Everything else stays. Every removal is printed first.
72
74
  trantor disk --json machine-readable report
73
- trantor disk install Monday 09:00 launchd job (runs --weekly: clean + one bus message)`);
75
+ trantor disk install daily 09:00 launchd job (runs --daily: clean + one bus message)`);
74
76
  process.exit(0);
75
77
  }
76
78
  if (args[0] === "install") { await install(); process.exit(0); }
77
79
 
78
- const weekly = args.includes("--weekly");
79
- const passArgs = weekly ? [...args, "--clean"] : args;
80
+ // #12269: daily cadence (--daily; --weekly kept as the old plist's flag). --dry-run wins.
81
+ const periodic = args.includes("--weekly") || args.includes("--daily");
82
+ const dryRun = args.includes("--dry-run");
83
+ const passArgs = periodic && !dryRun ? [...args, "--clean"] : args;
80
84
  const { report, results, json } = await runDisk({ args: passArgs });
81
85
  if (json) {
82
86
  process.stdout.write(`${JSON.stringify({ report, results }, null, 2)}\n`);
@@ -87,4 +91,4 @@ if (json) {
87
91
  for (const r of results.filter((r) => !r.ok)) console.log(` FAILED ${r.type} ${r.path || r.id || ""}: ${r.error}`);
88
92
  }
89
93
  }
90
- if (weekly) await sendWeeklyMessage(summaryLine(report, results));
94
+ if (periodic) await sendWeeklyMessage(summaryLine(report, results));
package/lib/disk.mjs CHANGED
@@ -4,8 +4,10 @@
4
4
  // REPORT-only; our own deletions are fs.rm on paths asserted under $HOME, never rm -rf.
5
5
  import { spawnSync } from "node:child_process";
6
6
  import * as nodeFs from "node:fs";
7
- import { join, resolve, sep, basename, dirname } from "node:path";
7
+ import { join, resolve, sep, basename, dirname, relative } from "node:path";
8
8
  import { homedir } from "node:os";
9
+ import { readRows } from "../bin/crew/state.mjs";
10
+ import { seatProcesses } from "../bin/crew/processes.mjs";
9
11
 
10
12
  export const DAY = 24 * 60 * 60 * 1000;
11
13
  export const BUILD_DIRS = [".next", "target", "desktop/src-tauri/target"];
@@ -14,6 +16,12 @@ export const SESSION_IDLE_MAX = 14 * DAY;
14
16
  // the 10-10 incident was 27 GB of it. Top-level entries untouched this long are safe tier.
15
17
  export const SCRATCH_DIRS = [".agent-bus-out", ".scratch"];
16
18
  export const SCRATCH_IDLE_MAX = 2 * DAY;
19
+ // #12269: whole seat worktrees idle this long with no uncommitted changes are pruned;
20
+ // build output parked inside the scratch dirs gets its own, shorter window.
21
+ export const WORKTREE_IDLE_MAX = 7 * DAY;
22
+ export const BUILD_OUTPUT_IDLE_MAX = 3 * DAY;
23
+ export const BUILD_OUTPUT_NAMES = ["DerivedData", "target"];
24
+ export const BUILD_OUTPUT_SUFFIX = ".xcresult";
17
25
 
18
26
  const GB = 1024 ** 3;
19
27
  const KB = 1024;
@@ -54,10 +62,17 @@ function defaults(d = {}) {
54
62
  home: d.home || homedir(),
55
63
  now: d.now || Date.now(),
56
64
  worktreesRoot: d.worktreesRoot || null,
65
+ log: d.log || console.log,
57
66
  entryBudget: d.entryBudget || 400_000,
58
67
  };
59
68
  }
60
69
 
70
+ // The seat worktrees root: injected in tests, else the real one under HOME. Every delete
71
+ // below this line re-checks its path is under this root before touching anything.
72
+ export function worktreesRootFor(d) {
73
+ return d.worktreesRoot || join(d.home || homedir(), ".agent-bus", "worktrees");
74
+ }
75
+
61
76
  // Recursive size walk. Skips symlinks entirely — a worktree symlink must not make
62
77
  // us measure (or later rm) outside its root. Budget caps pathological trees. Dirs are
63
78
  // statted too: a rename into an old tree updates the DIR mtime, not the file's, and
@@ -109,24 +124,26 @@ export function gitTracked(exec, worktree, rel) {
109
124
  return gitOut(exec, ["-C", worktree, "ls-files", "--", rel]).trim().length > 0;
110
125
  }
111
126
 
112
- // The runner writes ~/.agent-bus/turnstamp-<seat>-<project>.json at turn start and
113
- // unlinks it on every exit — a stamp younger than the scratch window IS a live (or
114
- // just-crashed) turn; a stamp that old cannot be one, since the time box kills in hours.
115
- export function turnStampPath(home, project, seat) {
116
- return join(home, ".agent-bus", `turnstamp-${seat}-${project}.json`);
117
- }
118
-
119
- function seatTurnLive(fs, home, now, project, seat) {
127
+ // #12269: a seat is dead only when both crew ownership and its runner processes are gone.
128
+ function seatRetentionReason(d, project, seat, worktree) {
129
+ const bus = join(d.home, ".agent-bus");
120
130
  try {
121
- const st = fs.statSync(turnStampPath(home, project, seat));
122
- return now - Number(st.mtimeMs) < SCRATCH_IDLE_MAX;
123
- } catch { return false; }
131
+ const ctx = { statePath: join(bus, "crew-windows.txt"), seatDir: join(bus, "seats"), env: {} };
132
+ if (readRows(ctx).some(row => row.project === project && row.persona === seat)) {
133
+ return `KEPT: ${seat}:${project} has a crew state row`;
134
+ }
135
+ const trees = gitOut(d.exec, ["-C", worktree, "worktree", "list", "--porcelain"]);
136
+ ctx.dir = trees.match(/^worktree (.+)$/m)?.[1] || worktree;
137
+ if (seatProcesses(ctx, project, seat, d.exec).length) return `KEPT: ${seat}:${project} has live crew processes`;
138
+ return "";
139
+ } catch (error) {
140
+ return `KEPT: cannot establish crew liveness for ${seat}:${project}: ${error.message}`;
141
+ }
124
142
  }
125
143
 
126
144
  export function scanSeatWorktrees(d) {
127
145
  const { fs, exec, home, now } = d;
128
- const root = d.worktreesRoot || join(home, ".agent-bus", "worktrees");
129
- const live = liveCommandLines(exec);
146
+ const root = worktreesRootFor(d);
130
147
  const seats = [], plan = [], kept = [];
131
148
  let projects;
132
149
  try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
@@ -137,10 +154,8 @@ export function scanSeatWorktrees(d) {
137
154
  catch { continue; }
138
155
  for (const seat of seatDirs) {
139
156
  const wt = join(root, proj.name, seat.name);
140
- const isLive = live.some((l) => l.includes(wt)) || seatTurnLive(fs, home, now, proj.name, seat.name);
141
- const liveReason = live.some((l) => l.includes(wt))
142
- ? "KEPT: a live seat process uses this worktree"
143
- : "KEPT: the seat's turn stamp is fresh — a turn may be live";
157
+ const liveReason = seatRetentionReason(d, proj.name, seat.name, wt);
158
+ const isLive = Boolean(liveReason);
144
159
  const size = dirSize(fs, wt, d);
145
160
  seats.push({ project: proj.name, seat: seat.name, path: wt, bytes: size.bytes, live: isLive });
146
161
  for (const rel of BUILD_DIRS) {
@@ -180,6 +195,101 @@ export function scanSeatWorktrees(d) {
180
195
  return { root, seats, plan, kept };
181
196
  }
182
197
 
198
+ // #12269: old clean worktrees can go only after crew state and processes both release the seat.
199
+ export function scanStaleWorktrees(d) {
200
+ const { fs, exec, home, now } = d;
201
+ const root = worktreesRootFor(d);
202
+ const plan = [], kept = [];
203
+ let projects;
204
+ try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
205
+ catch { return { root, plan, kept }; }
206
+ for (const proj of projects) {
207
+ let seatDirs;
208
+ try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
209
+ catch { continue; }
210
+ for (const seat of seatDirs) {
211
+ const wt = join(root, proj.name, seat.name);
212
+ const reason = seatRetentionReason(d, proj.name, seat.name, wt);
213
+ if (reason) {
214
+ kept.push({ path: wt, reason });
215
+ continue;
216
+ }
217
+ if (gitOut(exec, ["-C", wt, "status", "--porcelain"]).trim()) {
218
+ kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} has uncommitted changes` });
219
+ continue;
220
+ }
221
+ const size = dirSize(fs, wt, d);
222
+ if (size.maxMtime > now - WORKTREE_IDLE_MAX) {
223
+ kept.push({ path: wt, reason: `KEPT: ${proj.name}/${seat.name} was touched within ${WORKTREE_IDLE_MAX / DAY} days` });
224
+ continue;
225
+ }
226
+ plan.push({ type: "seat-worktree", path: wt, project: proj.name, seat: seat.name, bytes: size.bytes });
227
+ }
228
+ }
229
+ plan.sort((a, b) => b.bytes - a.bytes);
230
+ return { root, plan, kept };
231
+ }
232
+
233
+ // #12269: build output parked INSIDE a seat's scratch dirs — DerivedData, target/, *.xcresult —
234
+ // idle past the build-output window is deletable even when it is not a top-level scratch entry
235
+ // (that case the 2-day scratch rule already owns). Top-level entries are never matched here, so
236
+ // the two scans cannot plan the same path. First match wins per subtree; git-tracked paths stay.
237
+ export function scanNestedBuildOutput(d) {
238
+ const { fs, exec, home, now } = d;
239
+ const root = worktreesRootFor(d);
240
+ const plan = [], kept = [];
241
+ const isOutputDir = (name) => BUILD_OUTPUT_NAMES.includes(name) || name.endsWith(BUILD_OUTPUT_SUFFIX);
242
+ let projects;
243
+ try { projects = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory()); }
244
+ catch { return { plan, kept }; }
245
+ for (const proj of projects) {
246
+ let seatDirs;
247
+ try { seatDirs = fs.readdirSync(join(root, proj.name), { withFileTypes: true }).filter((e) => e.isDirectory()); }
248
+ catch { continue; }
249
+ for (const seat of seatDirs) {
250
+ const wt = join(root, proj.name, seat.name);
251
+ const reason = seatRetentionReason(d, proj.name, seat.name, wt);
252
+ if (reason) { kept.push({ path: wt, reason }); continue; }
253
+ for (const scratch of SCRATCH_DIRS) {
254
+ const dir = join(wt, scratch);
255
+ const stack = [];
256
+ try {
257
+ for (const top of fs.readdirSync(dir, { withFileTypes: true })) {
258
+ if (!top.isDirectory()) continue;
259
+ let entries;
260
+ try { entries = fs.readdirSync(join(dir, top.name), { withFileTypes: true }); } catch { continue; }
261
+ for (const e of entries) if (e.isDirectory()) stack.push(join(dir, top.name, e.name));
262
+ }
263
+ } catch { continue; }
264
+ let seen = 0;
265
+ while (stack.length) {
266
+ if (seen++ > d.entryBudget) break;
267
+ const p = stack.pop();
268
+ if (!isOutputDir(basename(p))) {
269
+ let entries;
270
+ try { entries = fs.readdirSync(p, { withFileTypes: true }); } catch { continue; }
271
+ for (const e of entries) if (e.isDirectory()) stack.push(join(p, e.name));
272
+ continue;
273
+ }
274
+ let st;
275
+ try { st = fs.statSync(p); } catch { continue; }
276
+ if (!st.isDirectory()) continue;
277
+ const rel = relative(wt, p);
278
+ if (gitTracked(exec, wt, rel)) { kept.push({ path: p, reason: `KEPT: ${rel} is git-tracked in ${proj.name}/${seat.name}` }); continue; }
279
+ const stat = dirSize(fs, p, d);
280
+ if (stat.maxMtime > now - BUILD_OUTPUT_IDLE_MAX) {
281
+ kept.push({ path: p, reason: `KEPT: ${rel} was touched within ${BUILD_OUTPUT_IDLE_MAX / DAY} days` });
282
+ continue;
283
+ }
284
+ plan.push({ type: "build-output", path: p, worktree: wt, project: proj.name, seat: seat.name, bytes: stat.bytes });
285
+ }
286
+ }
287
+ }
288
+ }
289
+ plan.sort((a, b) => b.bytes - a.bytes);
290
+ return { plan, kept };
291
+ }
292
+
183
293
  export function scanOpencodeSessions(d) {
184
294
  const { exec, now } = d;
185
295
  const live = liveCommandLines(exec);
@@ -345,6 +455,8 @@ export function buildDiskReport(d) {
345
455
  const { fs, home, now } = dep;
346
456
  const space = freeSpace(fs, "/");
347
457
  const seats = scanSeatWorktrees({ ...dep, entryBudget: dep.entryBudget });
458
+ const stale = scanStaleWorktrees({ ...dep, entryBudget: dep.entryBudget });
459
+ const nested = scanNestedBuildOutput({ ...dep, entryBudget: dep.entryBudget });
348
460
  const sessions = scanOpencodeSessions(dep);
349
461
  const sims = scanSimulators(dep);
350
462
  const dbPath = join(home, ".local", "share", "opencode", "opencode.db");
@@ -360,16 +472,17 @@ export function buildDiskReport(d) {
360
472
  hf: reportHfCache(dep),
361
473
  claudeWorktrees: reportClaudeWorktrees(dep),
362
474
  bigFiles: collectBigFiles(dep),
363
- plan: [...sims.plan, ...seats.plan, ...sessions.plan],
364
- kept: [...seats.kept, ...sessions.kept],
475
+ plan: [...sims.plan, ...seats.plan, ...stale.plan, ...nested.plan, ...sessions.plan],
476
+ kept: [...seats.kept, ...stale.kept, ...nested.kept, ...sessions.kept],
365
477
  };
366
478
  return report;
367
479
  }
368
480
 
369
- // Bytes the safe tier's ON-DISK items (build dirs, scratch entries) would free —
370
- // the summary line's number. simctl/opencode items free space inside their own stores.
481
+ // Bytes the safe tier's ON-DISK items (build dirs, scratch entries, whole worktrees, nested
482
+ // build output) would free — the summary line's number. simctl/opencode items free space
483
+ // inside their own stores.
371
484
  export function freedOnDiskBytes(plan) {
372
- return (plan || []).filter((p) => p.type === "build-dir" || p.type === "scratch-entry").reduce((n, p) => n + (p.bytes || 0), 0);
485
+ return (plan || []).filter((p) => ["build-dir", "scratch-entry", "seat-worktree", "build-output"].includes(p.type)).reduce((n, p) => n + (p.bytes || 0), 0);
373
486
  }
374
487
 
375
488
  function fileSize(fs, p) {
@@ -408,25 +521,44 @@ export function formatHuman(r) {
408
521
  return `${L.join("\n")}\n`;
409
522
  }
410
523
 
411
- // Execute the safe tier. simctl/opencode run as their own CLIs (injected in tests);
412
- // build dirs are removed by the injected `remove` (default fs.rmSync) only after
413
- // assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
524
+ // Execute the safe tier. Every planned item is PRINTED before it is acted on (#12269), so the
525
+ // log is a receipt, not a mystery. simctl/opencode run as their own CLIs (injected in tests);
526
+ // on-disk items are removed by the injected `remove` (default fs.rmSync) or `git worktree
527
+ // remove`, only after assertUnderHome AND an is-under-worktrees-root re-check on the exact path.
414
528
  export async function runClean(r, d) {
415
529
  const dep = defaults(d);
416
- const { exec, home } = dep;
530
+ const { exec, home, log } = dep;
417
531
  const remove = dep.remove || ((p, opts) => dep.fs.rmSync(p, opts));
532
+ const root = worktreesRootFor(dep);
418
533
  const results = [];
419
534
  for (const item of r.plan) {
535
+ const label = item.path || (item.type === "opencode-session" ? `session ${item.id}` : item.type === "simulators" ? "unavailable simulators" : item.type);
536
+ log(`janitor: removing ${item.type} ${label}${item.bytes ? ` (~${fmtBytes(item.bytes)})` : ""}`);
420
537
  try {
538
+ if (item.path) {
539
+ const p = assertUnderHome(item.path, home);
540
+ if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
541
+ const [project, seat] = relative(root, p).split(sep);
542
+ if (!project || !seat) throw new Error("refused: path does not identify a seat worktree");
543
+ const reason = seatRetentionReason(dep, project, seat, join(root, project, seat));
544
+ if (reason) throw new Error(reason);
545
+ }
421
546
  if (item.type === "simulators") {
422
547
  exec("xcrun", ["simctl", "delete", "unavailable"]);
423
548
  results.push({ type: item.type, ok: true });
424
549
  } else if (item.type === "opencode-session") {
425
550
  exec("opencode", ["session", "delete", item.id]);
426
551
  results.push({ type: item.type, id: item.id, ok: true });
427
- } else if (item.type === "build-dir" || item.type === "scratch-entry") {
552
+ } else if (item.type === "seat-worktree") {
553
+ const p = assertUnderHome(item.path, home);
554
+ if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
555
+ if (gitOut(exec, ["-C", p, "status", "--porcelain"]).trim()) throw new Error("worktree has uncommitted changes");
556
+ const g = exec("git", ["-C", p, "worktree", "remove", p]);
557
+ if (g.status !== 0) throw new Error(String(g.stderr || "git worktree remove failed").trim().split("\n")[0] || "git worktree remove failed");
558
+ results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
559
+ } else if (item.type === "build-dir" || item.type === "build-output" || item.type === "scratch-entry") {
428
560
  const p = assertUnderHome(item.path, home);
429
- if (!isUnder(p, join(home, ".agent-bus", "worktrees"))) throw new Error(`refused: ${p} is not under the seat worktrees root`);
561
+ if (!isUnder(p, root)) throw new Error(`refused: ${p} is not under the seat worktrees root`);
430
562
  remove(p, { recursive: true, force: true });
431
563
  results.push({ type: item.type, path: p, bytes: item.bytes || 0, ok: true });
432
564
  }
@@ -438,6 +570,7 @@ export async function runClean(r, d) {
438
570
  }
439
571
 
440
572
  export function launchdPlist({ nodePath, scriptPath, logPath }) {
573
+ // #12269: daily at 09:00 — the weekly cadence lost the race against 100 GB/week of seat junk.
441
574
  return `<?xml version="1.0" encoding="UTF-8"?>
442
575
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
443
576
  <plist version="1.0">
@@ -447,11 +580,10 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
447
580
  <array>
448
581
  <string>${nodePath}</string>
449
582
  <string>${scriptPath}</string>
450
- <string>--weekly</string>
583
+ <string>--daily</string>
451
584
  </array>
452
585
  <key>StartCalendarInterval</key>
453
586
  <dict>
454
- <key>Weekday</key><integer>1</integer>
455
587
  <key>Hour</key><integer>9</integer>
456
588
  <key>Minute</key><integer>0</integer>
457
589
  </dict>
@@ -464,7 +596,8 @@ export function launchdPlist({ nodePath, scriptPath, logPath }) {
464
596
 
465
597
  export async function runDisk(d = {}) {
466
598
  const dep = defaults(d);
467
- const clean = d.args?.includes("--clean");
599
+ // --dry-run always wins: report only, never clean — whichever flag asked for cleaning.
600
+ const clean = d.args?.includes("--clean") && !d.args?.includes("--dry-run");
468
601
  const json = d.args?.includes("--json");
469
602
  const report = buildDiskReport(dep);
470
603
  const results = clean ? await runClean(report, dep) : [];
@@ -66,10 +66,24 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
66
66
  }
67
67
  }
68
68
 
69
- const r = spawnSync("git", ["-C", root, "worktree", "add", "--no-track", "-B", branch, seatDir, "HEAD"], {
69
+ // #12269: a branch that already exists is the seat's history — down removed the worktree, not
70
+ // the branch. Reset it to HEAD only when it holds nothing HEAD lacks (fully harvested); else
71
+ // re-create at the branch tip so a re-upped seat name reuses the freed worktree with its commits.
72
+ const branchTip = gitOut(["-C", root, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], root);
73
+ const add = ["worktree", "add"];
74
+ let reuseBranch = false;
75
+ if (branchTip) {
76
+ const merged = spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", branch, "HEAD"], { stdio: "ignore", timeout: 8000 }).status === 0;
77
+ if (merged) add.push("--no-track", "-B", branch, seatDir, "HEAD");
78
+ else { add.push(seatDir, branch); reuseBranch = true; }
79
+ } else {
80
+ add.push("--no-track", "-B", branch, seatDir, "HEAD");
81
+ }
82
+ const r = spawnSync("git", ["-C", root, ...add], {
70
83
  encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
71
84
  });
72
85
  if (r.status === 0) {
86
+ if (reuseBranch) log(`\x1b[2m[runner]\x1b[0m ${branch} re-created at its own tip (${branchTip.slice(0, 7)}) — unintegrated commits kept`);
73
87
  // branch.<b>.base names the real base for the Review lens; push.autoSetupRemote makes the
74
88
  // first plain push set its upstream (#5403).
75
89
  if (base) spawnSync("git", ["-C", seatDir, "config", `branch.${branch}.base`, base], { stdio: "ignore", timeout: 5000 });
@@ -77,10 +91,33 @@ export function ensureSeatWorktree({ sourceDir, project, agent, home = homedir()
77
91
  if (!pushAuto) spawnSync("git", ["-C", seatDir, "config", "push.autoSetupRemote", "true"], { stdio: "ignore", timeout: 5000 });
78
92
  return { dir: seatDir, created: true, root, dirty: false };
79
93
  }
94
+ if (reuseBranch) {
95
+ const reason = String(r.stderr || "").trim().split("\n")[0] || "git worktree add failed";
96
+ log(`\x1b[33m[runner]\x1b[0m ${branch} holds unintegrated commits and could not be re-created (${reason}) — using ${sourceDir} rather than resetting the branch`);
97
+ return { dir: sourceDir, created: false };
98
+ }
80
99
  log(`\x1b[33m[runner]\x1b[0m could not create ${branch} worktree — using ${sourceDir}`);
81
100
  return { dir: sourceDir, created: false };
82
101
  }
83
102
 
103
+ // #12269: down/retire removes a stopped seat's worktree. The branch always stays; a tree with
104
+ // uncommitted changes is kept and named — git's own refusal is the race-free dirty check.
105
+ export function removeSeatWorktree({ project, agent, home = homedir(), env = process.env, dry = false }) {
106
+ const path = seatWorktreeDir(project, agent, home);
107
+ if (!existsSync(path)) return { status: "absent", path };
108
+ if (env?.CREW_NO_PROC_KILL === "1") return { status: "kept", path, reason: "CREW_NO_PROC_KILL=1 (process kill and removals guarded)" };
109
+ if (dry) return { status: "would-remove", path };
110
+ const r = spawnSync("git", ["-C", path, "worktree", "remove", path], {
111
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
112
+ });
113
+ if (r.status === 0) return { status: "removed", path };
114
+ const dirty = spawnSync("git", ["-C", path, "status", "--porcelain"], {
115
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000,
116
+ }).stdout || "";
117
+ if (dirty.trim()) return { status: "kept", path, reason: "uncommitted changes" };
118
+ return { status: "kept", path, reason: String(r.stderr || "git worktree remove failed").trim().split("\n")[0] };
119
+ }
120
+
84
121
  // The declaration: { link: [<relative sibling path>], provision: [{ path, mode, template? }],
85
122
  // preflight: "<shell command>" }. null when the project declares nothing (unchanged behaviour).
86
123
  export function readWorktreeDeclaration(root) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.117",
3
+ "version": "0.18.118",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"