pi-better-harness 0.36.0 → 0.37.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.
@@ -12,6 +12,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
12
12
 
13
13
  ## Core Features
14
14
  - Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
15
+ - Completed-subagent continuation: `subagent_resume` continues a saved single or batch conversation under a new attempt id. Retention is optional cleanup protection: `retain_session` pins artifacts, and `subagent_release` removes that protection without invalidating history that still exists. See [continuing a completed subagent](docs/usage.md#continuing-a-completed-subagent).
15
16
  - Typed foreground `subagent_spawn_batch` launch receipts for native Pi codemode (Pi 0.99.1+), preserving direct tool calls, per-run callbacks, and batch capacity/catalog guarantees. See [codemode batch launches](docs/usage.md#codemode-batch-launches).
16
17
  - Opt-in OS task sandboxing on macOS and Linux.
17
18
  - Explicit tool allowlists for child sessions.
@@ -7,12 +7,13 @@ import {
7
7
  statSync,
8
8
  writeFileSync,
9
9
  } from "node:fs";
10
- import { join, relative } from "node:path";
10
+ import { join, relative, sep } from "node:path";
11
11
  import {
12
12
  baseDir,
13
13
  listMetas,
14
14
  ownedByThisParent,
15
15
  orphanedTaskRuntimeProvenance,
16
+ processStartToken,
16
17
  readMeta,
17
18
  removeMetaArtifacts,
18
19
  removeRunDirectory,
@@ -23,6 +24,17 @@ import {
23
24
  type RunStatus,
24
25
  } from "./registry.ts";
25
26
  import type { SubagentConfig } from "./config.ts";
27
+ import {
28
+ ConversationBusyError,
29
+ ConversationLockUnavailableError,
30
+ conversationLeaseDir,
31
+ readConversationLease,
32
+ readRetention,
33
+ retainedRunIds,
34
+ retainedSessionIds,
35
+ withConversationMutation,
36
+ } from "./continuation.ts";
37
+ import { processExists } from "./spawn.ts";
26
38
 
27
39
  export const DEFAULT_CLEANUP_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
28
40
 
@@ -46,6 +58,17 @@ export const DEFAULT_MAX_REGISTRY_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
46
58
  */
47
59
  export const SIZE_SWEEP_INTERVAL_MS = 10 * 60 * 1000; // 10min
48
60
 
61
+ /**
62
+ * How long one conversation's cleanup step may wait for its mutation lock.
63
+ * Admission fails fast (0) and release/retention wait a second; cleanup is a
64
+ * background sweep, so it waits just long enough to lose gracefully to an
65
+ * in-flight admission and then retains the resource.
66
+ */
67
+ const CLEANUP_LOCK_WAIT_MS = 250;
68
+
69
+ /** Conversation ids the per-conversation mutation lock accepts. */
70
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
71
+
49
72
  interface CleanupState {
50
73
  lastLocalDate?: string;
51
74
  lastRunAt?: number;
@@ -65,6 +88,29 @@ export interface DailyCleanupOptions {
65
88
  config?: Pick<SubagentConfig, "cleanupTerminalRunRetentionMs" | "cleanupSessionRetentionMs">;
66
89
  terminalRunRetentionMs?: number;
67
90
  sessionRetentionMs?: number;
91
+ /**
92
+ * Artifacts a caller has already validated and is about to use. A launch
93
+ * path that opens a saved conversation or runs inside a retained workspace
94
+ * passes them so its own in-process sweep cannot remove what it just checked.
95
+ *
96
+ * Protection is a caller promise, not a lease: the caller owns the lifetime
97
+ * it is asserting and passes nothing once the work is quiescent.
98
+ */
99
+ protect?: CleanupProtection;
100
+ }
101
+
102
+ /**
103
+ * Artifacts one sweep must keep, identified by the three things continuation
104
+ * cares about: the conversation, the individual attempt/root, and the exact
105
+ * paths on disk (saved session file, shared workspace, task scratch).
106
+ */
107
+ export interface CleanupProtection {
108
+ /** Native session ids whose runs, saved history, and workspaces are in use. */
109
+ sessionIds?: Iterable<string>;
110
+ /** Individual run ids (attempt or root) that must not be removed. */
111
+ runIds?: Iterable<string>;
112
+ /** Exact paths to keep, e.g. a saved conversation file or a shared workspace. */
113
+ paths?: Iterable<string>;
68
114
  }
69
115
 
70
116
  const TERMINAL_CLEANUP_STATUSES = new Set<RunStatus>(["completed", "failed", "killed", "lost"]);
@@ -190,7 +236,210 @@ function removePath(path: string, errors: string[]): boolean {
190
236
  }
191
237
  }
192
238
 
193
- function cleanRunDirs(cutoff: number, errors: string[]): number {
239
+ /** True when `path` is `root` itself or lies beneath it. */
240
+ function pathWithin(path: string, root: string): boolean {
241
+ return path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
242
+ }
243
+
244
+ function pathOverlapsProtected(path: string, protectedPaths: readonly string[]): boolean {
245
+ for (const protectedPath of protectedPaths) {
246
+ if (pathWithin(path, protectedPath) || pathWithin(protectedPath, path)) return true;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ /**
252
+ * Whether this attempt's recorded child may still be writing its conversation.
253
+ *
254
+ * A `running`/`orphaned` record is occupancy by itself. `killed` is recorded as
255
+ * soon as the stop policy decides, while the child can still be alive and
256
+ * appending; `lost` means supervision lost track, not that the process proved
257
+ * gone. So only an actual process-identity match counts there, and an
258
+ * unavailable start token proves nothing and counts as alive.
259
+ */
260
+ function recordedChildMayWrite(meta: RunMeta): boolean {
261
+ if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) return true;
262
+ if (meta.status !== "killed" && meta.status !== "lost") return false;
263
+ const pid = meta.pid;
264
+ if (typeof pid !== "number" || pid <= 0 || !processExists(pid)) return false;
265
+ if (meta.pidStartTime === undefined) return true;
266
+ const current = processStartToken(pid);
267
+ return current === undefined || current === meta.pidStartTime;
268
+ }
269
+
270
+ /**
271
+ * Whether a writer lease says this conversation is in use.
272
+ *
273
+ * The lease is taken by the supervising foreground *before* the detached child
274
+ * is spawned and before the attempt's metadata exists, so it is the only
275
+ * evidence of occupancy during that admission window. This reads the same
276
+ * record continuation admission uses without recovering or deleting it. A dead
277
+ * supervisor can have spawned a child before publishing its attempt metadata;
278
+ * missing writer evidence therefore keeps the shared workspace protected.
279
+ */
280
+ function leaseMayHaveWriter(sessionId: string): boolean {
281
+ const lease = readConversationLease(sessionId);
282
+ if (!lease) return false;
283
+ if (lease.pid === process.pid) return true;
284
+ if (processExists(lease.pid)) {
285
+ if (lease.start === undefined) return true;
286
+ const current = processStartToken(lease.pid);
287
+ if (current === undefined || current === lease.start) return true;
288
+ }
289
+ const writer = readMeta(lease.attemptId);
290
+ return !writer || recordedChildMayWrite(writer);
291
+ }
292
+
293
+ interface CleanupOccupancy {
294
+ /** Conversations with a possible writer, plus caller-protected conversations. */
295
+ sessions: Set<string>;
296
+ /** Every run id of an occupied conversation, plus caller-protected runs. */
297
+ runIds: Set<string>;
298
+ /** Caller-protected paths that neither sweep may remove. */
299
+ paths: string[];
300
+ }
301
+
302
+ /**
303
+ * Decide which conversations are live enough that no run of theirs (the shared
304
+ * root clone/scratch and saved history included) may be retired yet.
305
+ *
306
+ * Continuation made the workspace and saved session outlive the attempt that
307
+ * created them: a later attempt runs inside the root's clone and appends to the
308
+ * root's history. Age and size sweeps therefore protect conversation-level
309
+ * occupancy, not attempt-local status alone.
310
+ */
311
+ function planCleanupOccupancy(
312
+ metas: readonly RunMeta[],
313
+ protect: CleanupProtection | undefined,
314
+ ): CleanupOccupancy {
315
+ const runIds = new Set<string>(protect?.runIds ?? []);
316
+ const paths = [...(protect?.paths ?? [])];
317
+
318
+ // A protected path may be the shared workspace or something inside it. The
319
+ // run whose removal would delete it is protected instead, because
320
+ // `removeMetaArtifacts` removes an owned workspace/scratch with its run.
321
+ for (const meta of metas) {
322
+ if (ownedResources(meta).some((owned) => paths.some((keep) => pathWithin(keep, owned)))) {
323
+ runIds.add(meta.id);
324
+ }
325
+ }
326
+
327
+ const sessions = new Set<string>(protect?.sessionIds ?? []);
328
+ for (const meta of metas) {
329
+ if (runIds.has(meta.id)) sessions.add(meta.sessionId);
330
+ if (recordedChildMayWrite(meta) || leaseMayHaveWriter(meta.sessionId)) {
331
+ sessions.add(meta.sessionId);
332
+ }
333
+ }
334
+ for (const meta of metas) {
335
+ if (sessions.has(meta.sessionId)) runIds.add(meta.id);
336
+ }
337
+ return { sessions, runIds, paths };
338
+ }
339
+
340
+ /**
341
+ * What one sweep needs to make a removal decision that outlives its snapshot:
342
+ * the run ids it knows per conversation (for a fresh re-read inside that
343
+ * conversation's lock) and the snapshot occupancy the caller's protection was
344
+ * folded into.
345
+ */
346
+ interface CleanupScope {
347
+ occupancy: CleanupOccupancy;
348
+ runIdsBySession: Map<string, string[]>;
349
+ }
350
+
351
+ function runIdsBySession(metas: readonly RunMeta[]): Map<string, string[]> {
352
+ const bySession = new Map<string, string[]>();
353
+ for (const meta of metas) {
354
+ const ids = bySession.get(meta.sessionId);
355
+ if (ids) ids.push(meta.id);
356
+ else bySession.set(meta.sessionId, [meta.id]);
357
+ }
358
+ return bySession;
359
+ }
360
+
361
+ /** Session ids with a published writer lease file. */
362
+ function leasedSessionIds(): string[] {
363
+ let names: string[];
364
+ try {
365
+ names = readdirSync(conversationLeaseDir());
366
+ } catch {
367
+ return [];
368
+ }
369
+ return names
370
+ .filter((name) => name.endsWith(".lock"))
371
+ .map((name) => name.slice(0, -".lock".length))
372
+ .filter((id) => SESSION_ID_PATTERN.test(id));
373
+ }
374
+
375
+ /**
376
+ * Conversations this sweep must serialize with admission: every conversation it
377
+ * has a run record for, plus every retained and leased one. Admission for a
378
+ * conversation always has either the run record of the attempt it continues or
379
+ * the writer lease it just took, so history that resolves to none of these is
380
+ * foreign and keeps the ordinary, unserialized cleanup path.
381
+ */
382
+ function serializedConversations(metas: readonly RunMeta[]): Set<string> {
383
+ const sessions = new Set<string>();
384
+ for (const meta of metas) sessions.add(meta.sessionId);
385
+ for (const sessionId of retainedSessionIds()) sessions.add(sessionId);
386
+ for (const sessionId of leasedSessionIds()) sessions.add(sessionId);
387
+ return sessions;
388
+ }
389
+
390
+ /**
391
+ * Run `body` inside one conversation's mutation transaction — the same
392
+ * kernel-owned critical section admission, lease release, and retention writes
393
+ * take. A lock that is busy or unavailable retains the resource for this sweep
394
+ * instead of blocking the foreground, stalling forever, or failing every other
395
+ * deletion: keeping work is always the safe direction.
396
+ */
397
+ function withConversationSerialization<T>(
398
+ sessionId: string | undefined,
399
+ body: () => T,
400
+ retained: () => T,
401
+ ): T {
402
+ if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) return body();
403
+ try {
404
+ return withConversationMutation(sessionId, body, { waitMs: CLEANUP_LOCK_WAIT_MS });
405
+ } catch (error) {
406
+ if (error instanceof ConversationBusyError || error instanceof ConversationLockUnavailableError) {
407
+ return retained();
408
+ }
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Is this conversation provably quiescent right now?
415
+ *
416
+ * Snapshot occupancy is not proof. Between the snapshot and a removal another
417
+ * process can publish a writer lease, re-request retention, or re-write an
418
+ * attempt, so this re-reads the same evidence inside the conversation's
419
+ * mutation transaction: a successful admission can therefore only be seen here
420
+ * (and retained) or happen after the removal it now follows.
421
+ *
422
+ * A brand-new attempt of the same conversation is admitted only under that
423
+ * conversation's lease, so re-reading the lease also covers an attempt this
424
+ * sweep has never seen in its snapshot.
425
+ */
426
+ function conversationQuiescentUnderLock(sessionId: string, runIds: readonly string[]): boolean {
427
+ if (leaseMayHaveWriter(sessionId)) return false;
428
+ const retention = readRetention(sessionId);
429
+ if (retention && retention.releasedAt === undefined) return false;
430
+ for (const id of runIds) {
431
+ const meta = readMeta(id);
432
+ if (meta && recordedChildMayWrite(meta)) return false;
433
+ }
434
+ return true;
435
+ }
436
+
437
+ /** Paths whose removal follows this run's artifacts (see `removeMetaArtifacts`). */
438
+ function ownedResources(meta: RunMeta): string[] {
439
+ return meta.taskScratch ? [meta.cwd, meta.taskScratch] : [meta.cwd];
440
+ }
441
+
442
+ function cleanRunDirs(cutoff: number, scope: CleanupScope, errors: string[]): number {
194
443
  let removed = 0;
195
444
  let ids: string[];
196
445
  try {
@@ -198,15 +447,18 @@ function cleanRunDirs(cutoff: number, errors: string[]): number {
198
447
  } catch {
199
448
  return 0;
200
449
  }
450
+ // Explicit retention (`retain_session`) pins every attempt of a
451
+ // conversation, not just the attempt that requested it, so its saved
452
+ // session and owned clone/scratch workspace stay available for a
453
+ // continuation until the owner releases them.
454
+ const retained = retainedSessionIds();
201
455
 
202
456
  for (const id of ids) {
203
457
  const dir = runDir(id);
204
458
  const meta = readMeta(id);
205
459
  if (meta) {
206
- if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) continue;
207
- if (terminalTimestamp(meta) >= cutoff) continue;
208
- if (removeMetaArtifacts(meta)) removed += 1;
209
- else errors.push(`${dir}: could not remove run artifacts`);
460
+ if (!ageSweepEligible(meta, cutoff, retained, scope.occupancy)) continue;
461
+ if (removeAgedRunArtifacts(meta, cutoff, retained, scope, errors)) removed += 1;
210
462
  continue;
211
463
  }
212
464
 
@@ -218,11 +470,111 @@ function cleanRunDirs(cutoff: number, errors: string[]): number {
218
470
  return removed;
219
471
  }
220
472
 
221
- function activeSessionIds(metas: RunMeta[]): Set<string> {
222
- const active = new Set<string>();
473
+ /**
474
+ * Snapshot eligibility for the age bound. Re-applied to the fresh record inside
475
+ * the conversation's lock, because a record can be rewritten between the two:
476
+ * a run that finished later, or one another sweep already retired, is decided
477
+ * on what it says now.
478
+ */
479
+ function ageSweepEligible(
480
+ meta: RunMeta,
481
+ cutoff: number,
482
+ retained: ReadonlySet<string>,
483
+ occupancy: CleanupOccupancy,
484
+ ): boolean {
485
+ if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) return false;
486
+ if (retained.has(meta.sessionId)) return false;
487
+ // A released conversation can still be in use: a later attempt may run
488
+ // inside this root's clone, or a stop/kill may not have landed.
489
+ if (occupancy.runIds.has(meta.id)) return false;
490
+ return terminalTimestamp(meta) < cutoff;
491
+ }
492
+
493
+ /**
494
+ * Remove one aged attempt's artifacts, serialized with its conversation.
495
+ *
496
+ * This is the deletion the snapshot cannot authorize: an admission that
497
+ * published its writer lease after this sweep listed the registry would have
498
+ * its shared workspace, task scratch, and saved history removed underneath it.
499
+ * The recheck inside the lock sees that admission, so the attempt is retained.
500
+ *
501
+ * The removal itself is inside the transaction because it is the commit point:
502
+ * `removeMetaArtifacts` unlinks the shared workspace, and splitting the
503
+ * decision from that unlink would reopen the race. A conversation whose lock is
504
+ * held by an admission or a retention write is therefore retained for this
505
+ * sweep, and the holder's own bounded wait is what bounds this section.
506
+ */
507
+ function removeAgedRunArtifacts(
508
+ meta: RunMeta,
509
+ cutoff: number,
510
+ retained: ReadonlySet<string>,
511
+ scope: CleanupScope,
512
+ errors: string[],
513
+ ): boolean {
514
+ const runIds = scope.runIdsBySession.get(meta.sessionId) ?? [meta.id];
515
+ return withConversationSerialization(meta.sessionId, () => {
516
+ if (!conversationQuiescentUnderLock(meta.sessionId, runIds)) return false;
517
+ const fresh = readMeta(meta.id) ?? meta;
518
+ if (!ageSweepEligible(fresh, cutoff, retained, scope.occupancy)) return false;
519
+ if (removeMetaArtifacts(fresh)) return true;
520
+ errors.push(`${runDir(meta.id)}: could not remove run artifacts`);
521
+ return false;
522
+ }, () => false);
523
+ }
524
+
525
+ /**
526
+ * Conversation a session-tree path names, resolved against conversations this
527
+ * sweep tracks: the per-run session directory `<runId>/`, a
528
+ * `<timestamp>_<sessionId>.jsonl` name, or the bare conversation name. History
529
+ * that resolves to nothing this sweep knows stays on the ordinary path.
530
+ */
531
+ function sessionIdForPath(path: string, serialized: ReadonlySet<string>): string | undefined {
532
+ const rel = relative(sessionsDir(), path);
533
+ if (!rel || rel.startsWith("..")) return undefined;
534
+ const known = [...new Set(pathNameCandidates(rel.split(sep)))].filter((candidate) => serialized.has(candidate));
535
+ // Two different tracked conversations in one path is not a layout this
536
+ // package writes. Refusing to remove it keeps both, instead of serializing
537
+ // one and deleting under the other.
538
+ return known.length === 1 ? known[0] : undefined;
539
+ }
540
+
541
+ function* pathNameCandidates(parts: readonly string[]): Generator<string> {
542
+ yield parts[0]!; // per-run session directory
543
+ const name = parts[parts.length - 1]!;
544
+ const stem = name.endsWith(".jsonl") ? name.slice(0, -".jsonl".length) : name;
545
+ yield stem;
546
+ // `<timestamp>_<sessionId>.jsonl`: the timestamp carries no underscore.
547
+ const separator = stem.indexOf("_");
548
+ if (separator > 0) yield stem.slice(separator + 1);
549
+ }
550
+
551
+ /**
552
+ * Remove one aged session-tree entry, serialized with the conversation it names
553
+ * when this sweep tracks that conversation.
554
+ */
555
+ function removeSessionPath(
556
+ path: string,
557
+ serialized: ReadonlySet<string>,
558
+ scope: CleanupScope,
559
+ errors: string[],
560
+ ): boolean {
561
+ const sessionId = sessionIdForPath(path, serialized);
562
+ if (!sessionId) return removePath(path, errors);
563
+ const runIds = scope.runIdsBySession.get(sessionId) ?? [];
564
+ return withConversationSerialization(sessionId, () => {
565
+ if (!conversationQuiescentUnderLock(sessionId, runIds)) return false;
566
+ return removePath(path, errors);
567
+ }, () => false);
568
+ }
569
+
570
+ function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy): Set<string> {
571
+ const active = new Set<string>(occupancy.sessions);
223
572
  for (const meta of metas) {
224
573
  if (meta.status === "running" || meta.status === "orphaned") active.add(meta.sessionId);
225
574
  }
575
+ // A retained completed conversation is not active work, but its saved
576
+ // session file is the history a continuation reopens.
577
+ for (const sessionId of retainedSessionIds()) active.add(sessionId);
226
578
  return active;
227
579
  }
228
580
 
@@ -235,7 +587,14 @@ function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
235
587
  return false;
236
588
  }
237
589
 
238
- function cleanSessionTree(cutoff: number, active: Set<string>, errors: string[]): number {
590
+ function cleanSessionTree(
591
+ cutoff: number,
592
+ active: Set<string>,
593
+ protectedPaths: readonly string[],
594
+ scope: CleanupScope,
595
+ serialized: ReadonlySet<string>,
596
+ errors: string[],
597
+ ): number {
239
598
  const root = sessionsDir();
240
599
  if (!existsSync(root)) return 0;
241
600
  let removed = 0;
@@ -254,9 +613,11 @@ function cleanSessionTree(cutoff: number, active: Set<string>, errors: string[])
254
613
  const path = join(dir, entry.name);
255
614
  if (entry.isDirectory()) {
256
615
  const childEmpty = visit(path);
257
- if (childEmpty && !pathMentionsActiveSession(path, active)) {
616
+ if (childEmpty
617
+ && !pathMentionsActiveSession(path, active)
618
+ && !pathOverlapsProtected(path, protectedPaths)) {
258
619
  const mtimeMs = safeStatMtime(path);
259
- if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
620
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
260
621
  removed += 1;
261
622
  continue;
262
623
  }
@@ -265,12 +626,12 @@ function cleanSessionTree(cutoff: number, active: Set<string>, errors: string[])
265
626
  continue;
266
627
  }
267
628
 
268
- if (pathMentionsActiveSession(path, active)) {
629
+ if (pathMentionsActiveSession(path, active) || pathOverlapsProtected(path, protectedPaths)) {
269
630
  empty = false;
270
631
  continue;
271
632
  }
272
633
  const mtimeMs = safeStatMtime(path);
273
- if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
634
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
274
635
  removed += 1;
275
636
  continue;
276
637
  }
@@ -295,8 +656,19 @@ export function runDailyCleanupOnce(options: DailyCleanupOptions = {}): DailyCle
295
656
  const terminalRetention = retentionMs(options.terminalRunRetentionMs, options.config?.cleanupTerminalRunRetentionMs);
296
657
  const sessionRetention = retentionMs(options.sessionRetentionMs, options.config?.cleanupSessionRetentionMs);
297
658
  const metas = listMetas();
298
- const removedRunDirs = cleanRunDirs(now - terminalRetention, errors);
299
- const removedSessionPaths = cleanSessionTree(now - sessionRetention, activeSessionIds(metas), errors);
659
+ const scope: CleanupScope = {
660
+ occupancy: planCleanupOccupancy(metas, options.protect),
661
+ runIdsBySession: runIdsBySession(metas),
662
+ };
663
+ const removedRunDirs = cleanRunDirs(now - terminalRetention, scope, errors);
664
+ const removedSessionPaths = cleanSessionTree(
665
+ now - sessionRetention,
666
+ activeSessionIds(metas, scope.occupancy),
667
+ scope.occupancy.paths,
668
+ scope,
669
+ serializedConversations(metas),
670
+ errors,
671
+ );
300
672
 
301
673
  try {
302
674
  writeCleanupState({ lastLocalDate: dateKey, lastRunAt: now });
@@ -361,6 +733,8 @@ export interface SizeCapOptions {
361
733
  /** Bypass the interval marker (tests, explicit operator sweeps). */
362
734
  force?: boolean;
363
735
  protectedIds?: ReadonlySet<string>;
736
+ /** Same caller-owned protection as `DailyCleanupOptions.protect`. */
737
+ protect?: CleanupProtection;
364
738
  }
365
739
 
366
740
  /**
@@ -393,14 +767,38 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
393
767
  ?? (Number.isFinite(configured) && configured! > 0 ? configured! : DEFAULT_MAX_REGISTRY_BYTES);
394
768
 
395
769
  const errors: string[] = [];
396
- const protectedIds = options.protectedIds ?? ownRunIds();
397
- const plan = planRegistrySizeCap(collectSizeCapEntries(), { maxBytes, protectedIds });
770
+ const entries = collectSizeCapEntries();
771
+ // Retained conversations and their workspaces are exempt from the byte
772
+ // bound until the owner releases them; a size sweep must not delete the
773
+ // history or uncommitted workspace a continuation depends on. An occupied
774
+ // conversation is protected the same way: its root clone may be the oldest
775
+ // terminal run in the registry while a new attempt is still running in it.
776
+ // Occupancy costs a metadata scan and lease reads, so it is only computed
777
+ // when this sweep would actually remove something.
778
+ const totalBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
779
+ const metas = listMetas();
780
+ const occupancy = totalBytes > maxBytes ? planCleanupOccupancy(metas, options.protect) : undefined;
781
+ const protectedIds = new Set([
782
+ ...(options.protectedIds ?? ownRunIds(metas)),
783
+ ...retainedRunIds(metas),
784
+ ...(occupancy?.runIds ?? []),
785
+ ]);
786
+ const plan = planRegistrySizeCap(entries, { maxBytes, protectedIds });
787
+ const scope: CleanupScope = {
788
+ occupancy: occupancy ?? { sessions: new Set(), runIds: new Set(), paths: [] },
789
+ runIdsBySession: runIdsBySession(metas),
790
+ };
791
+ const bytesById = new Map(entries.map((entry) => [entry.id, entry.bytes]));
398
792
  const removed: string[] = [];
793
+ let retainedBytes = 0;
399
794
  for (const id of plan.remove) {
400
- const meta = readMeta(id);
401
- if (meta ? removeMetaArtifacts(meta) : removeRun(id, errors)) removed.push(id);
402
- else if (meta) errors.push(`${runDir(id)}: could not remove run artifacts`);
795
+ const outcome = removeSizeCappedRun(id, scope, errors);
796
+ if (outcome === "removed") removed.push(id);
797
+ else if (outcome === "retained") retainedBytes += bytesById.get(id) ?? 0;
403
798
  }
799
+ // A candidate the recheck retained is still on disk, so the byte account and
800
+ // the shortfall it reports stay honest.
801
+ const keptBytes = plan.keptBytes + retainedBytes;
404
802
 
405
803
  try {
406
804
  writeCleanupState({ ...state, lastSizeSweepAt: now });
@@ -408,14 +806,41 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
408
806
  errors.push(`${cleanupStatePath()}: ${err instanceof Error ? err.message : String(err)}`);
409
807
  }
410
808
 
411
- return { ...plan, ran: true, removed, errors };
809
+ return { ...plan, ran: true, removed, keptBytes, overBudget: keptBytes > maxBytes, errors };
810
+ }
811
+
812
+ /**
813
+ * Remove one planned size-cap candidate, serialized with its conversation.
814
+ *
815
+ * The plan is a snapshot of both bytes and occupancy. Re-reading inside the
816
+ * lock keeps a run that became active, retained, or newly leased since then:
817
+ * a continuation admitted after planning runs inside the root the plan chose to
818
+ * delete, and its shared workspace is exactly what a size sweep goes after.
819
+ */
820
+ function removeSizeCappedRun(
821
+ id: string,
822
+ scope: CleanupScope,
823
+ errors: string[],
824
+ ): "removed" | "retained" | "failed" {
825
+ const meta = readMeta(id);
826
+ if (!meta) return removeRun(id, errors) ? "removed" : "failed";
827
+ const runIds = scope.runIdsBySession.get(meta.sessionId) ?? [meta.id];
828
+ return withConversationSerialization(meta.sessionId, () => {
829
+ if (!conversationQuiescentUnderLock(meta.sessionId, runIds)) return "retained";
830
+ const fresh = readMeta(id) ?? meta;
831
+ if (!TERMINAL_CLEANUP_STATUSES.has(fresh.status)) return "retained";
832
+ if (scope.occupancy.runIds.has(fresh.id)) return "retained";
833
+ if (removeMetaArtifacts(fresh)) return "removed";
834
+ errors.push(`${runDir(id)}: could not remove run artifacts`);
835
+ return "failed";
836
+ }, () => "retained");
412
837
  }
413
838
 
414
839
  /** Run ids owned by this pi process, which a size sweep must never remove. */
415
- function ownRunIds(): Set<string> {
840
+ function ownRunIds(metas: readonly RunMeta[] = listMetas()): Set<string> {
416
841
  const own = new Set<string>();
417
842
  try {
418
- for (const meta of listMetas()) {
843
+ for (const meta of metas) {
419
844
  if (ownedByThisParent(meta)) own.add(meta.id);
420
845
  }
421
846
  } catch { /* registry unreadable: protect nothing, remove nothing new */ }