pi-better-harness 0.37.0 → 0.38.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/node_modules/pi-better-subagents/README.md +1 -1
- package/node_modules/pi-better-subagents/batch.mjs +12 -3
- package/node_modules/pi-better-subagents/catalog-runtime.ts +6 -2
- package/node_modules/pi-better-subagents/child-codemode.ts +17 -0
- package/node_modules/pi-better-subagents/cleanup.ts +118 -26
- 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 +43 -11
- package/node_modules/pi-better-subagents/docs/usage.md +83 -63
- package/node_modules/pi-better-subagents/extensions.mjs +2 -1
- package/node_modules/pi-better-subagents/index.ts +278 -188
- package/node_modules/pi-better-subagents/list.mjs +19 -3
- package/node_modules/pi-better-subagents/list.ts +1 -0
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +133 -17
- package/node_modules/pi-better-subagents/session-selection.ts +35 -0
- package/node_modules/pi-better-subagents/session-store.ts +410 -0
- package/node_modules/pi-better-subagents/task-guard.ts +5 -2
- package/node_modules/pi-better-subagents/tools.ts +22 -23
- package/node_modules/pi-better-subagents/workspace-scope.ts +50 -0
- package/package.json +2 -2
|
@@ -12,7 +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
|
-
-
|
|
15
|
+
- Continue a saved conversation with `subagent_spawn({ session_id, prompt })`, or use `session_name` for a unique name in the current folder. Sessions persist by default. Lists show session IDs and search locally; `all:true` searches globally. Batch jobs use the same selectors. See [continuing a saved conversation](docs/usage.md#continuing-a-saved-conversation).
|
|
16
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).
|
|
17
17
|
- Opt-in OS task sandboxing on macOS and Linux.
|
|
18
18
|
- Explicit tool allowlists for child sessions.
|
|
@@ -25,7 +25,7 @@ export const batchLaunchOutputSchema = {
|
|
|
25
25
|
type: "array",
|
|
26
26
|
items: {
|
|
27
27
|
type: "object",
|
|
28
|
-
properties: { job: { type: "integer", minimum: 1 }, name: { type: "string" }, id: { type: "string" }, modelNote: { type: "string" } },
|
|
28
|
+
properties: { job: { type: "integer", minimum: 1 }, name: { type: "string" }, id: { type: "string" }, sessionId: { type: "string" }, continuedFrom: { type: "string" }, modelNote: { type: "string" } },
|
|
29
29
|
required: ["job", "name", "id"],
|
|
30
30
|
additionalProperties: false,
|
|
31
31
|
},
|
|
@@ -94,6 +94,10 @@ export function mergeJobOptions(shared, job) {
|
|
|
94
94
|
git_clone_workspace: job.git_clone_workspace ?? shared?.git_clone_workspace,
|
|
95
95
|
approve: job.approve ?? shared?.approve,
|
|
96
96
|
allow_nested: job.allow_nested ?? shared?.allow_nested,
|
|
97
|
+
// Native session id selected for this JOB only. A shared value would make
|
|
98
|
+
// every job contend for the same conversation writer, so it is not inherited.
|
|
99
|
+
session_id: typeof job.session_id === "string" ? job.session_id : undefined,
|
|
100
|
+
session_name: typeof job.session_name === "string" ? job.session_name : undefined,
|
|
97
101
|
// Harness timing; an explicit null means "not set", so it falls through to shared.
|
|
98
102
|
deadline_minutes: job.deadline_minutes ?? shared?.deadline_minutes,
|
|
99
103
|
grace_minutes: job.grace_minutes ?? shared?.grace_minutes,
|
|
@@ -149,6 +153,11 @@ export function validateBatchPlan({ shared, jobs, onCapacity, config }) {
|
|
|
149
153
|
if (typeof job.prompt !== "string" || job.prompt.trim() === "") {
|
|
150
154
|
throw new Error(`job ${i + 1} is missing a non-empty prompt.`);
|
|
151
155
|
}
|
|
156
|
+
if (job.session_id !== undefined && job.session_name !== undefined) throw new Error(`job ${i + 1}: pass either session_id or session_name, not both.`);
|
|
157
|
+
if (job.session_name !== undefined && (typeof job.session_name !== "string" || job.session_name.trim() === "")) throw new Error(`job ${i + 1} session_name must be a non-empty name.`);
|
|
158
|
+
if (job.session_id !== undefined && (typeof job.session_id !== "string" || job.session_id.trim() === "")) {
|
|
159
|
+
throw new Error(`job ${i + 1} session_id must be a non-empty native session id.`);
|
|
160
|
+
}
|
|
152
161
|
if (seenPrompts.has(job.prompt)) {
|
|
153
162
|
// Duplicate prompts are allowed, but a batch with every prompt
|
|
154
163
|
// identical is a common mistake; warn via the error path only when
|
|
@@ -273,8 +282,8 @@ export function formatBatchLaunchResponse({ batchId, batchName, launched, skippe
|
|
|
273
282
|
lines.push(
|
|
274
283
|
`Batch ${label} launched ${launched.length} subagent(s):`,
|
|
275
284
|
);
|
|
276
|
-
for (const { name, id, modelNote } of launched) {
|
|
277
|
-
lines.push(`• ${name} → ${id}${modelNote ? ` · ${modelNote}` : ""}`);
|
|
285
|
+
for (const { name, id, sessionId, modelNote } of launched) {
|
|
286
|
+
lines.push(`• ${name} → ${id}${sessionId && sessionId !== id ? ` (session ${sessionId})` : ""}${modelNote ? ` · ${modelNote}` : ""}`);
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
289
|
|
|
@@ -222,12 +222,16 @@ export async function clarifyCatalogRequest(
|
|
|
222
222
|
const normalized = jobs.map((job, index) => ({
|
|
223
223
|
job: { ...job, catalogJobIndex: index },
|
|
224
224
|
index,
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
continuation: job.session_id !== undefined || job.session_name !== undefined,
|
|
226
|
+
agent: job.session_id !== undefined || job.session_name !== undefined ? undefined : agentIdOf(job),
|
|
227
|
+
roles: job.session_id !== undefined || job.session_name !== undefined ? [] : roleIdsOf(job),
|
|
227
228
|
}));
|
|
228
229
|
const clean: CatalogJobFields[] = [];
|
|
229
230
|
const pending: { job: CatalogJobFields; index: number; agent?: string; roles: string[] }[] = [];
|
|
230
231
|
for (const item of normalized) {
|
|
232
|
+
// Saved conversations keep their launch recipe. Repeated selectors are
|
|
233
|
+
// checked against that recipe at admission, never clarified or expanded.
|
|
234
|
+
if (item.continuation) { clean.push(item.job); continue; }
|
|
231
235
|
if (item.roles.length > 1 || (item.agent && item.roles.length > 0)) pending.push(item);
|
|
232
236
|
else clean.push(withSelector(item.job, { agent: item.agent, role: item.roles[0] }));
|
|
233
237
|
}
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,7 @@ import { join, relative, sep } from "node:path";
|
|
|
11
11
|
import {
|
|
12
12
|
baseDir,
|
|
13
13
|
listMetas,
|
|
14
|
+
observeArchivePublication,
|
|
14
15
|
ownedByThisParent,
|
|
15
16
|
orphanedTaskRuntimeProvenance,
|
|
16
17
|
processStartToken,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
withConversationMutation,
|
|
36
37
|
} from "./continuation.ts";
|
|
37
38
|
import { processExists } from "./spawn.ts";
|
|
39
|
+
import { archiveCompletedAttempt, archivePublicationPending, listArchivedAttempts } from "./session-store.ts";
|
|
38
40
|
|
|
39
41
|
export const DEFAULT_CLEANUP_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
40
42
|
|
|
@@ -346,6 +348,70 @@ function planCleanupOccupancy(
|
|
|
346
348
|
interface CleanupScope {
|
|
347
349
|
occupancy: CleanupOccupancy;
|
|
348
350
|
runIdsBySession: Map<string, string[]>;
|
|
351
|
+
/**
|
|
352
|
+
* Conversations whose saved session and launch recipe live in durable
|
|
353
|
+
* storage. Their history and owned clone workspace never age out; only the
|
|
354
|
+
* volatile log, metadata, and task scratch are reclaimed.
|
|
355
|
+
*/
|
|
356
|
+
persistent: ReadonlySet<string>;
|
|
357
|
+
/**
|
|
358
|
+
* Completed attempts whose compact durable record is still unpublished. The
|
|
359
|
+
* volatile run directory holds the only copy of the recipe, so both bounds
|
|
360
|
+
* retain the whole run until a sweep can publish it.
|
|
361
|
+
*/
|
|
362
|
+
archivePending: ReadonlySet<string>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Conversations with a durable continuation record: a completed attempt saved
|
|
367
|
+
* its compact metadata and frozen recipe under the Pi agent directory. Their
|
|
368
|
+
* saved history and owned clone workspace are kept indefinitely; only the
|
|
369
|
+
* volatile log, metadata, and task scratch are reclaimed.
|
|
370
|
+
*
|
|
371
|
+
* A completed durable-layout attempt whose compact record could not be published
|
|
372
|
+
* belongs here too: it is not resumable yet, but its recipe, saved history, and
|
|
373
|
+
* clone are the only copy of that conversation.
|
|
374
|
+
*
|
|
375
|
+
* Legacy attempts (recipes pointing at the old temporary session directory, or
|
|
376
|
+
* fixtures without a recipe) never publish that record and keep the existing
|
|
377
|
+
* reclaimable behavior. A conversation that only ever failed is not resumable,
|
|
378
|
+
* so its artifacts stay ordinary aged history and do not pin a clone forever.
|
|
379
|
+
*/
|
|
380
|
+
function persistentSessionIds(
|
|
381
|
+
metas: readonly RunMeta[],
|
|
382
|
+
archivePending: ReadonlySet<string>,
|
|
383
|
+
): Set<string> {
|
|
384
|
+
const sessions = new Set(listArchivedAttempts().map((meta) => meta.sessionId));
|
|
385
|
+
for (const meta of metas) {
|
|
386
|
+
if (archivePending.has(meta.id)) sessions.add(meta.sessionId);
|
|
387
|
+
}
|
|
388
|
+
return sessions;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Retry publication of every completed attempt whose compact durable record is
|
|
393
|
+
* missing, BEFORE either bound decides what to delete.
|
|
394
|
+
*
|
|
395
|
+
* The volatile run directory is the only place a completed attempt's recipe
|
|
396
|
+
* lives until that record is published, so this pass is what makes the age and
|
|
397
|
+
* size bounds safe: while it keeps failing, the runs it names are retained (see
|
|
398
|
+
* `CleanupScope.archivePending`), and once it succeeds they are ordinary
|
|
399
|
+
* persistent conversations whose raw logs may be reclaimed.
|
|
400
|
+
*
|
|
401
|
+
* Returns the run ids still unpublished.
|
|
402
|
+
*/
|
|
403
|
+
function retryPendingArchives(metas: readonly RunMeta[]): Set<string> {
|
|
404
|
+
const pending = new Set<string>();
|
|
405
|
+
for (const meta of metas) {
|
|
406
|
+
if (!archivePublicationPending(meta)) continue;
|
|
407
|
+
try {
|
|
408
|
+
if (archiveCompletedAttempt(meta)) observeArchivePublication(meta.id);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
observeArchivePublication(meta.id, error);
|
|
411
|
+
}
|
|
412
|
+
if (archivePublicationPending(meta)) pending.add(meta.id);
|
|
413
|
+
}
|
|
414
|
+
return pending;
|
|
349
415
|
}
|
|
350
416
|
|
|
351
417
|
function runIdsBySession(metas: readonly RunMeta[]): Map<string, string[]> {
|
|
@@ -457,7 +523,7 @@ function cleanRunDirs(cutoff: number, scope: CleanupScope, errors: string[]): nu
|
|
|
457
523
|
const dir = runDir(id);
|
|
458
524
|
const meta = readMeta(id);
|
|
459
525
|
if (meta) {
|
|
460
|
-
if (!ageSweepEligible(meta, cutoff, retained, scope
|
|
526
|
+
if (!ageSweepEligible(meta, cutoff, retained, scope)) continue;
|
|
461
527
|
if (removeAgedRunArtifacts(meta, cutoff, retained, scope, errors)) removed += 1;
|
|
462
528
|
continue;
|
|
463
529
|
}
|
|
@@ -480,13 +546,15 @@ function ageSweepEligible(
|
|
|
480
546
|
meta: RunMeta,
|
|
481
547
|
cutoff: number,
|
|
482
548
|
retained: ReadonlySet<string>,
|
|
483
|
-
|
|
549
|
+
scope: CleanupScope,
|
|
484
550
|
): boolean {
|
|
485
551
|
if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) return false;
|
|
486
552
|
if (retained.has(meta.sessionId)) return false;
|
|
553
|
+
// The run directory still holds the only copy of this conversation's recipe.
|
|
554
|
+
if (scope.archivePending.has(meta.id)) return false;
|
|
487
555
|
// A released conversation can still be in use: a later attempt may run
|
|
488
556
|
// inside this root's clone, or a stop/kill may not have landed.
|
|
489
|
-
if (occupancy.runIds.has(meta.id)) return false;
|
|
557
|
+
if (scope.occupancy.runIds.has(meta.id)) return false;
|
|
490
558
|
return terminalTimestamp(meta) < cutoff;
|
|
491
559
|
}
|
|
492
560
|
|
|
@@ -515,8 +583,8 @@ function removeAgedRunArtifacts(
|
|
|
515
583
|
return withConversationSerialization(meta.sessionId, () => {
|
|
516
584
|
if (!conversationQuiescentUnderLock(meta.sessionId, runIds)) return false;
|
|
517
585
|
const fresh = readMeta(meta.id) ?? meta;
|
|
518
|
-
if (!ageSweepEligible(fresh, cutoff, retained, scope
|
|
519
|
-
if (removeMetaArtifacts(fresh)) return true;
|
|
586
|
+
if (!ageSweepEligible(fresh, cutoff, retained, scope)) return false;
|
|
587
|
+
if (removeMetaArtifacts(fresh, { keepWorkspace: scope.persistent.has(fresh.sessionId) })) return true;
|
|
520
588
|
errors.push(`${runDir(meta.id)}: could not remove run artifacts`);
|
|
521
589
|
return false;
|
|
522
590
|
}, () => false);
|
|
@@ -528,8 +596,8 @@ function removeAgedRunArtifacts(
|
|
|
528
596
|
* `<timestamp>_<sessionId>.jsonl` name, or the bare conversation name. History
|
|
529
597
|
* that resolves to nothing this sweep knows stays on the ordinary path.
|
|
530
598
|
*/
|
|
531
|
-
function sessionIdForPath(path: string, serialized: ReadonlySet<string>): string | undefined {
|
|
532
|
-
const rel = relative(
|
|
599
|
+
function sessionIdForPath(root: string, path: string, serialized: ReadonlySet<string>): string | undefined {
|
|
600
|
+
const rel = relative(root, path);
|
|
533
601
|
if (!rel || rel.startsWith("..")) return undefined;
|
|
534
602
|
const known = [...new Set(pathNameCandidates(rel.split(sep)))].filter((candidate) => serialized.has(candidate));
|
|
535
603
|
// Two different tracked conversations in one path is not a layout this
|
|
@@ -553,12 +621,13 @@ function* pathNameCandidates(parts: readonly string[]): Generator<string> {
|
|
|
553
621
|
* when this sweep tracks that conversation.
|
|
554
622
|
*/
|
|
555
623
|
function removeSessionPath(
|
|
624
|
+
root: string,
|
|
556
625
|
path: string,
|
|
557
626
|
serialized: ReadonlySet<string>,
|
|
558
627
|
scope: CleanupScope,
|
|
559
628
|
errors: string[],
|
|
560
629
|
): boolean {
|
|
561
|
-
const sessionId = sessionIdForPath(path, serialized);
|
|
630
|
+
const sessionId = sessionIdForPath(root, path, serialized);
|
|
562
631
|
if (!sessionId) return removePath(path, errors);
|
|
563
632
|
const runIds = scope.runIdsBySession.get(sessionId) ?? [];
|
|
564
633
|
return withConversationSerialization(sessionId, () => {
|
|
@@ -567,7 +636,7 @@ function removeSessionPath(
|
|
|
567
636
|
}, () => false);
|
|
568
637
|
}
|
|
569
638
|
|
|
570
|
-
function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy): Set<string> {
|
|
639
|
+
function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy, persistent: ReadonlySet<string>): Set<string> {
|
|
571
640
|
const active = new Set<string>(occupancy.sessions);
|
|
572
641
|
for (const meta of metas) {
|
|
573
642
|
if (meta.status === "running" || meta.status === "orphaned") active.add(meta.sessionId);
|
|
@@ -575,12 +644,14 @@ function activeSessionIds(metas: RunMeta[], occupancy: CleanupOccupancy): Set<st
|
|
|
575
644
|
// A retained completed conversation is not active work, but its saved
|
|
576
645
|
// session file is the history a continuation reopens.
|
|
577
646
|
for (const sessionId of retainedSessionIds()) active.add(sessionId);
|
|
647
|
+
// Durable conversations never expire with the volatile sweeps either.
|
|
648
|
+
for (const sessionId of persistent) active.add(sessionId);
|
|
578
649
|
return active;
|
|
579
650
|
}
|
|
580
651
|
|
|
581
|
-
function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
|
|
652
|
+
function pathMentionsActiveSession(root: string, path: string, active: Set<string>): boolean {
|
|
582
653
|
if (active.size === 0) return false;
|
|
583
|
-
const rel = relative(
|
|
654
|
+
const rel = relative(root, path);
|
|
584
655
|
for (const sessionId of active) {
|
|
585
656
|
if (sessionId && rel.includes(sessionId)) return true;
|
|
586
657
|
}
|
|
@@ -588,6 +659,7 @@ function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
|
|
|
588
659
|
}
|
|
589
660
|
|
|
590
661
|
function cleanSessionTree(
|
|
662
|
+
root: string,
|
|
591
663
|
cutoff: number,
|
|
592
664
|
active: Set<string>,
|
|
593
665
|
protectedPaths: readonly string[],
|
|
@@ -595,7 +667,6 @@ function cleanSessionTree(
|
|
|
595
667
|
serialized: ReadonlySet<string>,
|
|
596
668
|
errors: string[],
|
|
597
669
|
): number {
|
|
598
|
-
const root = sessionsDir();
|
|
599
670
|
if (!existsSync(root)) return 0;
|
|
600
671
|
let removed = 0;
|
|
601
672
|
|
|
@@ -614,10 +685,10 @@ function cleanSessionTree(
|
|
|
614
685
|
if (entry.isDirectory()) {
|
|
615
686
|
const childEmpty = visit(path);
|
|
616
687
|
if (childEmpty
|
|
617
|
-
&& !pathMentionsActiveSession(path, active)
|
|
688
|
+
&& !pathMentionsActiveSession(root, path, active)
|
|
618
689
|
&& !pathOverlapsProtected(path, protectedPaths)) {
|
|
619
690
|
const mtimeMs = safeStatMtime(path);
|
|
620
|
-
if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
|
|
691
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(root, path, serialized, scope, errors)) {
|
|
621
692
|
removed += 1;
|
|
622
693
|
continue;
|
|
623
694
|
}
|
|
@@ -626,12 +697,12 @@ function cleanSessionTree(
|
|
|
626
697
|
continue;
|
|
627
698
|
}
|
|
628
699
|
|
|
629
|
-
if (pathMentionsActiveSession(path, active) || pathOverlapsProtected(path, protectedPaths)) {
|
|
700
|
+
if (pathMentionsActiveSession(root, path, active) || pathOverlapsProtected(path, protectedPaths)) {
|
|
630
701
|
empty = false;
|
|
631
702
|
continue;
|
|
632
703
|
}
|
|
633
704
|
const mtimeMs = safeStatMtime(path);
|
|
634
|
-
if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(path, serialized, scope, errors)) {
|
|
705
|
+
if (mtimeMs !== undefined && mtimeMs < cutoff && removeSessionPath(root, path, serialized, scope, errors)) {
|
|
635
706
|
removed += 1;
|
|
636
707
|
continue;
|
|
637
708
|
}
|
|
@@ -656,19 +727,33 @@ export function runDailyCleanupOnce(options: DailyCleanupOptions = {}): DailyCle
|
|
|
656
727
|
const terminalRetention = retentionMs(options.terminalRunRetentionMs, options.config?.cleanupTerminalRunRetentionMs);
|
|
657
728
|
const sessionRetention = retentionMs(options.sessionRetentionMs, options.config?.cleanupSessionRetentionMs);
|
|
658
729
|
const metas = listMetas();
|
|
730
|
+
// Publish any outstanding compact record before this sweep reclaims the only
|
|
731
|
+
// copy of a completed attempt's recipe.
|
|
732
|
+
const archivePending = retryPendingArchives(metas);
|
|
733
|
+
const persistent = persistentSessionIds(metas, archivePending);
|
|
659
734
|
const scope: CleanupScope = {
|
|
660
735
|
occupancy: planCleanupOccupancy(metas, options.protect),
|
|
661
736
|
runIdsBySession: runIdsBySession(metas),
|
|
737
|
+
persistent,
|
|
738
|
+
archivePending,
|
|
662
739
|
};
|
|
663
740
|
const removedRunDirs = cleanRunDirs(now - terminalRetention, scope, errors);
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
741
|
+
// Native saved history is durable regardless of the latest attempt status.
|
|
742
|
+
// Sweep only legacy temporary sessions; eligibility to resume must never
|
|
743
|
+
// determine whether a durable native conversation is kept.
|
|
744
|
+
const sessionRoots = [join(baseDir(), "sessions")];
|
|
745
|
+
let removedSessionPaths = 0;
|
|
746
|
+
for (const root of sessionRoots) {
|
|
747
|
+
removedSessionPaths += cleanSessionTree(
|
|
748
|
+
root,
|
|
749
|
+
now - sessionRetention,
|
|
750
|
+
activeSessionIds(metas, scope.occupancy, persistent),
|
|
751
|
+
scope.occupancy.paths,
|
|
752
|
+
scope,
|
|
753
|
+
serializedConversations(metas),
|
|
754
|
+
errors,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
672
757
|
|
|
673
758
|
try {
|
|
674
759
|
writeCleanupState({ lastLocalDate: dateKey, lastRunAt: now });
|
|
@@ -767,6 +852,10 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
|
|
|
767
852
|
?? (Number.isFinite(configured) && configured! > 0 ? configured! : DEFAULT_MAX_REGISTRY_BYTES);
|
|
768
853
|
|
|
769
854
|
const errors: string[] = [];
|
|
855
|
+
// Same ordering as the age sweep: retry archival first, so a run whose
|
|
856
|
+
// recipe only exists in its volatile directory is never a removal candidate.
|
|
857
|
+
const metas = listMetas();
|
|
858
|
+
const archivePending = retryPendingArchives(metas);
|
|
770
859
|
const entries = collectSizeCapEntries();
|
|
771
860
|
// Retained conversations and their workspaces are exempt from the byte
|
|
772
861
|
// bound until the owner releases them; a size sweep must not delete the
|
|
@@ -776,17 +865,20 @@ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCa
|
|
|
776
865
|
// Occupancy costs a metadata scan and lease reads, so it is only computed
|
|
777
866
|
// when this sweep would actually remove something.
|
|
778
867
|
const totalBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
779
|
-
const metas = listMetas();
|
|
780
868
|
const occupancy = totalBytes > maxBytes ? planCleanupOccupancy(metas, options.protect) : undefined;
|
|
781
869
|
const protectedIds = new Set([
|
|
782
870
|
...(options.protectedIds ?? ownRunIds(metas)),
|
|
783
871
|
...retainedRunIds(metas),
|
|
784
872
|
...(occupancy?.runIds ?? []),
|
|
873
|
+
// An unpublished compact record is the recipe's only copy.
|
|
874
|
+
...archivePending,
|
|
785
875
|
]);
|
|
786
876
|
const plan = planRegistrySizeCap(entries, { maxBytes, protectedIds });
|
|
787
877
|
const scope: CleanupScope = {
|
|
788
878
|
occupancy: occupancy ?? { sessions: new Set(), runIds: new Set(), paths: [] },
|
|
789
879
|
runIdsBySession: runIdsBySession(metas),
|
|
880
|
+
persistent: persistentSessionIds(metas, archivePending),
|
|
881
|
+
archivePending,
|
|
790
882
|
};
|
|
791
883
|
const bytesById = new Map(entries.map((entry) => [entry.id, entry.bytes]));
|
|
792
884
|
const removed: string[] = [];
|
|
@@ -830,7 +922,7 @@ function removeSizeCappedRun(
|
|
|
830
922
|
const fresh = readMeta(id) ?? meta;
|
|
831
923
|
if (!TERMINAL_CLEANUP_STATUSES.has(fresh.status)) return "retained";
|
|
832
924
|
if (scope.occupancy.runIds.has(fresh.id)) return "retained";
|
|
833
|
-
if (removeMetaArtifacts(fresh)) return "removed";
|
|
925
|
+
if (removeMetaArtifacts(fresh, { keepWorkspace: scope.persistent.has(fresh.sessionId) })) return "removed";
|
|
834
926
|
errors.push(`${runDir(id)}: could not remove run artifacts`);
|
|
835
927
|
return "failed";
|
|
836
928
|
}, () => "retained");
|
|
@@ -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
|
|
|
@@ -18,9 +18,19 @@
|
|
|
18
18
|
* clean/nesting/approval intent, confinement, model, effort, role identity,
|
|
19
19
|
* workspace intent) on run metadata.
|
|
20
20
|
* 3. **Explicit retention.** `retain_session` records a bounded promise that a
|
|
21
|
-
* completed conversation and its workspace stay out of cleanup until
|
|
22
|
-
*
|
|
23
|
-
* system-temp reaping can still remove it; a missing
|
|
21
|
+
* completed conversation and its workspace stay out of cleanup until a
|
|
22
|
+
* foreground session releases it. Retention lives in the existing runtime
|
|
23
|
+
* storage, so system-temp reaping can still remove it; a missing
|
|
24
|
+
* conversation refuses.
|
|
25
|
+
*
|
|
26
|
+
* Foreground-session ownership is provenance, not an authorization boundary:
|
|
27
|
+
* Pi sessions are ordinary files on disk, so any foreground session that can
|
|
28
|
+
* name a completed attempt with a verifiable saved conversation, workspace, and
|
|
29
|
+
* launch recipe may continue it (or release its optional retention). The new
|
|
30
|
+
* attempt records the INVOKING foreground as its callback origin and its
|
|
31
|
+
* permission admission is re-resolved from the current profile, so continuing
|
|
32
|
+
* transfers the run to the caller instead of borrowing the original owner's
|
|
33
|
+
* authority.
|
|
24
34
|
* 4. **Conversation writer lease.** A cross-process token lease held from
|
|
25
35
|
* admission through child lifetime and finalization. Duplicate continuations
|
|
26
36
|
* of one conversation admit exactly one writer. Lease-path and retention
|
|
@@ -66,6 +76,7 @@ import {
|
|
|
66
76
|
type RunStatus,
|
|
67
77
|
} from "./registry.ts";
|
|
68
78
|
import { processExists } from "./spawn.ts";
|
|
79
|
+
import { durableSubagentRoot } from "./session-store.ts";
|
|
69
80
|
|
|
70
81
|
const nodeRequire = createRequire(import.meta.url);
|
|
71
82
|
|
|
@@ -231,7 +242,11 @@ export interface RetentionRecord {
|
|
|
231
242
|
ownerRunId: string;
|
|
232
243
|
/** Canonical workspace kept available for continuation. */
|
|
233
244
|
workspace: string;
|
|
234
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Foreground session that first retained this conversation. Provenance for
|
|
247
|
+
* diagnostics only: retention is optional convenience, not a tenancy
|
|
248
|
+
* boundary, so any foreground session may release it.
|
|
249
|
+
*/
|
|
235
250
|
ownerOrigin: RunCallbackOrigin;
|
|
236
251
|
retainedAt: number;
|
|
237
252
|
lastAttemptId: string;
|
|
@@ -398,7 +413,14 @@ export function markRetentionReleased(
|
|
|
398
413
|
|
|
399
414
|
// ---- ownership and continuation plan ------------------------------------
|
|
400
415
|
|
|
401
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* True when `origin` is the foreground session recorded on the run.
|
|
418
|
+
*
|
|
419
|
+
* This is a provenance/labelling helper, deliberately NOT an admission gate: a
|
|
420
|
+
* different foreground session may continue a completed attempt (see the
|
|
421
|
+
* module header). Callers that need to route a notification use it to decide
|
|
422
|
+
* which foreground a run belongs to, not to deny an operation.
|
|
423
|
+
*/
|
|
402
424
|
export function ownedByOrigin(meta: RunMeta, origin: RunCallbackOrigin | undefined): boolean {
|
|
403
425
|
const recorded = meta.callbackOrigin;
|
|
404
426
|
if (!origin?.sessionId || typeof origin.cwd !== "string") return false;
|
|
@@ -441,8 +463,12 @@ export function planContinuation(options: {
|
|
|
441
463
|
}): ContinuationPlan {
|
|
442
464
|
const { id, prior, origin, others } = options;
|
|
443
465
|
if (!prior) throw refusal(id, "no such run id.");
|
|
466
|
+
// The invoking foreground is still required, because the new attempt's
|
|
467
|
+
// completion callback and navigator entry are routed to it. It is not
|
|
468
|
+
// required to be the attempt's original owner: the saved session, workspace,
|
|
469
|
+
// and recipe are the thing being continued, and Pi sessions are shared
|
|
470
|
+
// on-disk state rather than a per-foreground boundary.
|
|
444
471
|
if (!origin?.sessionId) throw refusal(id, "the active foreground session identity is unavailable.");
|
|
445
|
-
if (!ownedByOrigin(prior, origin)) throw refusal(id, "only its owning foreground session may continue it.");
|
|
446
472
|
if (prior.status !== "completed") {
|
|
447
473
|
throw refusal(id, `its status is ${prior.status}; only a completed run can be continued. Stop or launch a new run instead.`);
|
|
448
474
|
}
|
|
@@ -490,13 +516,13 @@ export function planRetentionRelease(options: {
|
|
|
490
516
|
}): RetentionRecord {
|
|
491
517
|
const { id, prior, origin, others } = options;
|
|
492
518
|
if (!prior) throw new ContinuationError(`Cannot release ${id}: no such run id.`);
|
|
519
|
+
// Release is the same on-disk convenience promise as continuation: any
|
|
520
|
+
// foreground that can name the attempt may drop optional retention. The
|
|
521
|
+
// invoking identity is still required, because the record keeps `releasedBy`
|
|
522
|
+
// and the decision must be attributable to a real foreground session.
|
|
493
523
|
if (!origin?.sessionId) throw new ContinuationError(`Cannot release ${id}: the active foreground session identity is unavailable.`);
|
|
494
|
-
if (!ownedByOrigin(prior, origin)) throw new ContinuationError(`Cannot release ${id}: only its owning foreground session may change retention.`);
|
|
495
524
|
const record = readRetention(prior.sessionId);
|
|
496
525
|
if (!record) throw new ContinuationError(`Cannot release ${id}: it was not launched with retain_session, so it has no retained conversation.`);
|
|
497
|
-
if (!record.ownerOrigin.sessionId || record.ownerOrigin.sessionId !== origin.sessionId || record.ownerOrigin.cwd !== origin.cwd) {
|
|
498
|
-
throw new ContinuationError(`Cannot release ${id}: only the foreground session that retained this conversation may release it.`);
|
|
499
|
-
}
|
|
500
526
|
if (record.releasedAt !== undefined) {
|
|
501
527
|
throw new ContinuationError(`Cannot release ${id}: its retention was already released at ${new Date(record.releasedAt).toISOString()}.`);
|
|
502
528
|
}
|
|
@@ -541,8 +567,14 @@ const ADMISSION_LOCK_WAIT_MS = 0;
|
|
|
541
567
|
const RELEASE_LOCK_WAIT_MS = 1_000;
|
|
542
568
|
const RETENTION_LOCK_WAIT_MS = 1_000;
|
|
543
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Stable writer-lease root, independent of TMPDIR: a conversation is ordinary
|
|
572
|
+
* on-disk Pi state, so a foreground that restarts under a different temp root
|
|
573
|
+
* must still see the one writer slot. Retention records stay in the volatile
|
|
574
|
+
* registry as before.
|
|
575
|
+
*/
|
|
544
576
|
export function conversationLeaseDir(): string {
|
|
545
|
-
return join(
|
|
577
|
+
return join(durableSubagentRoot(), "conversation-leases");
|
|
546
578
|
}
|
|
547
579
|
|
|
548
580
|
function leasePathFor(sessionId: string): string {
|