pi-better-harness 0.36.0 → 0.37.1
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/node_modules/pi-better-subagents/README.md +1 -0
- package/node_modules/pi-better-subagents/child-codemode.ts +17 -0
- package/node_modules/pi-better-subagents/cleanup.ts +448 -23
- package/node_modules/pi-better-subagents/config.json +1 -1
- package/node_modules/pi-better-subagents/config.ts +1 -1
- package/node_modules/pi-better-subagents/continuation.ts +944 -0
- package/node_modules/pi-better-subagents/docs/usage.md +65 -5
- package/node_modules/pi-better-subagents/extensions.mjs +2 -1
- package/node_modules/pi-better-subagents/index.ts +369 -40
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +66 -4
- package/node_modules/pi-better-subagents/task-guard.ts +5 -2
- package/package.json +2 -2
|
@@ -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.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Native Pi codemode, explicitly loaded because children disable extension discovery. */
|
|
2
|
+
import * as SDK from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
/** Older supported SDKs have no native codemode; keep their existing tool set. */
|
|
6
|
+
export function installChildCodemode(pi: ExtensionAPI, confined = false): boolean {
|
|
7
|
+
const factory = (SDK as any).createCodemodeExtension;
|
|
8
|
+
if (typeof factory !== "function") return false;
|
|
9
|
+
// Model globals make direct provider calls outside nested tool hooks.
|
|
10
|
+
// Confined children get only tool dispatch, which passes through the guard.
|
|
11
|
+
factory(confined ? { models: false } : {})(pi);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function childCodemode(pi: ExtensionAPI): void {
|
|
16
|
+
installChildCodemode(pi);
|
|
17
|
+
}
|
|
@@ -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
|
-
|
|
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 (!
|
|
207
|
-
if (
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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
|
|
616
|
+
if (childEmpty
|
|
617
|
+
&& !pathMentionsActiveSession(path, active)
|
|
618
|
+
&& !pathOverlapsProtected(path, protectedPaths)) {
|
|
258
619
|
const mtimeMs = safeStatMtime(path);
|
|
259
|
-
if (mtimeMs !== undefined && mtimeMs < cutoff &&
|
|
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 &&
|
|
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
|
|
299
|
-
|
|
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
|
|
397
|
-
|
|
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
|
|
401
|
-
if (
|
|
402
|
-
else if (
|
|
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
|
|
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 */ }
|
|
@@ -85,7 +85,7 @@ export function normalizeConcurrencyCap(value: unknown): number {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** Built-in default tool set when config.json sets nothing. */
|
|
88
|
-
export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch";
|
|
88
|
+
export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch, codemode";
|
|
89
89
|
/** Safe default for a hermetic (clean) child where extension tools don't exist. */
|
|
90
90
|
export const SAFE_CLEAN_TOOLS = "read, bash";
|
|
91
91
|
|