@esso0428/pi-subagents 0.17.1 → 0.17.3
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/CHANGELOG.md +19 -0
- package/README.md +5 -4
- package/dist/agent-history.d.ts +6 -0
- package/dist/agent-history.d.ts.map +1 -1
- package/dist/agent-history.js +38 -0
- package/dist/agent-history.js.map +1 -1
- package/dist/agent-manager.d.ts +23 -6
- package/dist/agent-manager.d.ts.map +1 -1
- package/dist/agent-manager.js +165 -27
- package/dist/agent-manager.js.map +1 -1
- package/dist/agent-recovery.d.ts +37 -0
- package/dist/agent-recovery.d.ts.map +1 -0
- package/dist/agent-recovery.js +171 -0
- package/dist/agent-recovery.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -25
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +100 -23
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +1 -1
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +6 -0
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +79 -7
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-history.ts +38 -0
- package/src/agent-manager.ts +170 -28
- package/src/agent-recovery.ts +185 -0
- package/src/index.ts +103 -23
- package/src/ui/agent-widget.ts +102 -23
- package/src/ui/conversation-timeline.ts +4 -1
- package/src/ui/conversation-viewer.ts +97 -7
package/src/agent-manager.ts
CHANGED
|
@@ -11,6 +11,14 @@ import { statSync } from "node:fs";
|
|
|
11
11
|
import { isAbsolute } from "node:path";
|
|
12
12
|
import type { Model } from "@earendil-works/pi-ai";
|
|
13
13
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { readAgentHistory } from "./agent-history.js";
|
|
15
|
+
import {
|
|
16
|
+
type AgentRecoveryCheckpoint,
|
|
17
|
+
type AgentRecoveryStatus,
|
|
18
|
+
readAgentRecoveryCheckpoints,
|
|
19
|
+
removeAgentRecoveryCheckpoint,
|
|
20
|
+
writeAgentRecoveryCheckpoint,
|
|
21
|
+
} from "./agent-recovery.js";
|
|
14
22
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
15
23
|
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
16
24
|
import { addUsage } from "./usage.js";
|
|
@@ -190,6 +198,8 @@ interface SpawnOptions {
|
|
|
190
198
|
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
191
199
|
/** Called when the session successfully compacts. */
|
|
192
200
|
onCompaction?: (info: CompactionInfo) => void;
|
|
201
|
+
/** Called synchronously after the record exists, before it is queued or started. */
|
|
202
|
+
onSpawned?: (id: string) => void;
|
|
193
203
|
}
|
|
194
204
|
|
|
195
205
|
export class AgentManager {
|
|
@@ -202,11 +212,15 @@ export class AgentManager {
|
|
|
202
212
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
|
203
213
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
|
204
214
|
private worktreeRepos = new Set<string>();
|
|
215
|
+
/** Project cwd for each record's durable checkpoint. */
|
|
216
|
+
private recoveryCwds = new Map<string, string>();
|
|
205
217
|
|
|
206
218
|
/** Queue of background agents waiting to start. */
|
|
207
219
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
208
220
|
/** Number of currently running background agents. */
|
|
209
221
|
private runningBackground = 0;
|
|
222
|
+
/** Prevent late promise settlement from decrementing a replacement run. */
|
|
223
|
+
private runningBackgroundIds = new Set<string>();
|
|
210
224
|
|
|
211
225
|
constructor(
|
|
212
226
|
onComplete?: OnAgentComplete,
|
|
@@ -234,6 +248,99 @@ export class AgentManager {
|
|
|
234
248
|
return this.maxConcurrent;
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
private finishBackground(id: string): void {
|
|
252
|
+
if (!this.runningBackgroundIds.delete(id)) return;
|
|
253
|
+
this.runningBackground = Math.max(0, this.runningBackground - 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private checkpointStatus(record: AgentRecord): AgentRecoveryStatus {
|
|
257
|
+
return record.status;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private makeCheckpoint(record: AgentRecord): AgentRecoveryCheckpoint {
|
|
261
|
+
const checkpoint: AgentRecoveryCheckpoint = {
|
|
262
|
+
version: 1,
|
|
263
|
+
id: record.id,
|
|
264
|
+
type: record.type,
|
|
265
|
+
description: record.description,
|
|
266
|
+
status: this.checkpointStatus(record),
|
|
267
|
+
startedAt: record.startedAt,
|
|
268
|
+
toolUses: record.toolUses,
|
|
269
|
+
lifetimeUsage: { ...record.lifetimeUsage },
|
|
270
|
+
compactionCount: record.compactionCount,
|
|
271
|
+
...(record.completedAt !== undefined && { completedAt: record.completedAt }),
|
|
272
|
+
// A durable transcript is the source of truth for partial/full output.
|
|
273
|
+
// Avoid duplicating potentially sensitive or very large result text.
|
|
274
|
+
...(!record.transcriptPath && record.result !== undefined && { result: record.result }),
|
|
275
|
+
...(record.error !== undefined && { error: record.error }),
|
|
276
|
+
...(record.transcriptPath !== undefined && { transcriptPath: record.transcriptPath }),
|
|
277
|
+
...(record.invocation !== undefined && { invocation: cloneInvocation(record.invocation) }),
|
|
278
|
+
};
|
|
279
|
+
return checkpoint;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private checkpoint(record: AgentRecord): void {
|
|
283
|
+
const cwd = this.recoveryCwds.get(record.id);
|
|
284
|
+
if (!cwd) return;
|
|
285
|
+
writeAgentRecoveryCheckpoint(cwd, this.makeCheckpoint(record));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private flushOutput(record: AgentRecord): void {
|
|
289
|
+
if (!record.outputCleanup) return;
|
|
290
|
+
try { record.outputCleanup(); } catch { /* recovery must remain best effort */ }
|
|
291
|
+
record.outputCleanup = undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Set the durable transcript locator and checkpoint the current state. */
|
|
295
|
+
setTranscript(id: string, historyFile: string, transcriptPath: string, cwd?: string): void {
|
|
296
|
+
const record = this.agents.get(id);
|
|
297
|
+
if (!record) return;
|
|
298
|
+
record.historyFile = historyFile;
|
|
299
|
+
record.transcriptPath = transcriptPath;
|
|
300
|
+
if (cwd) this.recoveryCwds.set(id, cwd);
|
|
301
|
+
this.checkpoint(record);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Checkpoint one record explicitly (used after transcript setup). */
|
|
305
|
+
checkpointRecord(id: string): void {
|
|
306
|
+
const record = this.agents.get(id);
|
|
307
|
+
if (record) this.checkpoint(record);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Reload durable records from this project's checkpoint directory. A
|
|
312
|
+
* running/queued checkpoint means the process was killed before it could
|
|
313
|
+
* write its stopped state; treat it as stopped and retain its transcript.
|
|
314
|
+
* SIGKILL cannot run a final flush/checkpoint, so this active snapshot is
|
|
315
|
+
* necessarily the last recoverable state.
|
|
316
|
+
*/
|
|
317
|
+
restoreRecovered(cwd: string): void {
|
|
318
|
+
for (const checkpoint of readAgentRecoveryCheckpoints(cwd)) {
|
|
319
|
+
if (!checkpoint.transcriptPath || !readAgentHistory(cwd, checkpoint.transcriptPath)) continue;
|
|
320
|
+
const status: RestorableAgentStatus = checkpoint.status === "running" || checkpoint.status === "queued"
|
|
321
|
+
? "stopped"
|
|
322
|
+
: checkpoint.status;
|
|
323
|
+
const completedAt = checkpoint.completedAt ?? Date.now();
|
|
324
|
+
const existing = this.agents.get(checkpoint.id);
|
|
325
|
+
if (existing) {
|
|
326
|
+
// Parent-branch records can still carry an unread in-memory result.
|
|
327
|
+
// Never replace that richer record with the checkpoint's transcript
|
|
328
|
+
// stub during the same session. Merge only durable locator metadata.
|
|
329
|
+
if (!existing.transcriptPath && checkpoint.transcriptPath) {
|
|
330
|
+
existing.transcriptPath = checkpoint.transcriptPath;
|
|
331
|
+
}
|
|
332
|
+
this.recoveryCwds.set(checkpoint.id, cwd);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
this.agents.set(checkpoint.id, this.createRestoredRecord({
|
|
336
|
+
...checkpoint,
|
|
337
|
+
status,
|
|
338
|
+
completedAt,
|
|
339
|
+
}));
|
|
340
|
+
this.recoveryCwds.set(checkpoint.id, cwd);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
237
344
|
/**
|
|
238
345
|
* Spawn an agent and return its ID immediately (for background use).
|
|
239
346
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -271,6 +378,19 @@ export class AgentManager {
|
|
|
271
378
|
invocation: options.invocation,
|
|
272
379
|
};
|
|
273
380
|
this.agents.set(id, record);
|
|
381
|
+
this.recoveryCwds.set(id, ctx.cwd);
|
|
382
|
+
// Give callers a chance to create the durable transcript before the first
|
|
383
|
+
// checkpoint. This closes the small spawn→attach window in which a queued
|
|
384
|
+
// or running agent could be left recoverable only as metadata.
|
|
385
|
+
try {
|
|
386
|
+
options.onSpawned?.(id);
|
|
387
|
+
this.checkpoint(record);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
this.agents.delete(id);
|
|
390
|
+
this.recoveryCwds.delete(id);
|
|
391
|
+
removeAgentRecoveryCheckpoint(ctx.cwd, id);
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
274
394
|
|
|
275
395
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
276
396
|
|
|
@@ -286,6 +406,8 @@ export class AgentManager {
|
|
|
286
406
|
this.startAgent(id, record, args);
|
|
287
407
|
} catch (err) {
|
|
288
408
|
this.agents.delete(id);
|
|
409
|
+
this.recoveryCwds.delete(id);
|
|
410
|
+
removeAgentRecoveryCheckpoint(ctx.cwd, id);
|
|
289
411
|
throw err;
|
|
290
412
|
}
|
|
291
413
|
return id;
|
|
@@ -327,7 +449,11 @@ export class AgentManager {
|
|
|
327
449
|
|
|
328
450
|
record.status = "running";
|
|
329
451
|
record.startedAt = Date.now();
|
|
330
|
-
|
|
452
|
+
this.checkpoint(record);
|
|
453
|
+
if (options.isBackground) {
|
|
454
|
+
this.runningBackground++;
|
|
455
|
+
this.runningBackgroundIds.add(id);
|
|
456
|
+
}
|
|
331
457
|
this.onStart?.(record);
|
|
332
458
|
|
|
333
459
|
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
@@ -386,9 +512,13 @@ export class AgentManager {
|
|
|
386
512
|
record.pendingSteers = undefined;
|
|
387
513
|
}
|
|
388
514
|
options.onSessionCreated?.(session);
|
|
515
|
+
this.checkpoint(record);
|
|
389
516
|
},
|
|
390
517
|
})
|
|
391
518
|
.then(({ responseText, session, aborted, steered, failure }) => {
|
|
519
|
+
// A disposed manager no longer owns this run. Avoid late callbacks
|
|
520
|
+
// mutating a dead session or emitting completion side effects.
|
|
521
|
+
if (this.agents.get(id) !== record) return responseText;
|
|
392
522
|
// Don't overwrite status if externally stopped via abort()
|
|
393
523
|
if (record.status !== "stopped") {
|
|
394
524
|
// Precedence: a hard abort keeps "aborted"; then a failed final turn
|
|
@@ -427,6 +557,7 @@ export class AgentManager {
|
|
|
427
557
|
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`;
|
|
428
558
|
}
|
|
429
559
|
}
|
|
560
|
+
this.checkpoint(record);
|
|
430
561
|
|
|
431
562
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
432
563
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
@@ -434,13 +565,16 @@ export class AgentManager {
|
|
|
434
565
|
record.resultConsumed = true;
|
|
435
566
|
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
436
567
|
} else {
|
|
437
|
-
this.
|
|
568
|
+
this.finishBackground(id);
|
|
438
569
|
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
439
570
|
this.drainQueue();
|
|
440
571
|
}
|
|
441
572
|
return responseText;
|
|
442
573
|
})
|
|
443
574
|
.catch((err) => {
|
|
575
|
+
// A disposed manager no longer owns this run. Avoid late callbacks
|
|
576
|
+
// mutating a dead session or emitting completion side effects.
|
|
577
|
+
if (this.agents.get(id) !== record) return "";
|
|
444
578
|
// Don't overwrite status if externally stopped via abort()
|
|
445
579
|
if (record.status !== "stopped") {
|
|
446
580
|
record.status = "error";
|
|
@@ -463,6 +597,7 @@ export class AgentManager {
|
|
|
463
597
|
record.worktreeResult = wtResult;
|
|
464
598
|
} catch { /* ignore cleanup errors */ }
|
|
465
599
|
}
|
|
600
|
+
this.checkpoint(record);
|
|
466
601
|
|
|
467
602
|
// Fire onComplete for foreground agents too — lifecycle symmetry.
|
|
468
603
|
// Mark resultConsumed so the callback skips notifications (result returned inline).
|
|
@@ -470,7 +605,7 @@ export class AgentManager {
|
|
|
470
605
|
record.resultConsumed = true;
|
|
471
606
|
this.onComplete?.(record);
|
|
472
607
|
} else {
|
|
473
|
-
this.
|
|
608
|
+
this.finishBackground(id);
|
|
474
609
|
this.onComplete?.(record);
|
|
475
610
|
this.drainQueue();
|
|
476
611
|
}
|
|
@@ -478,11 +613,6 @@ export class AgentManager {
|
|
|
478
613
|
});
|
|
479
614
|
|
|
480
615
|
record.promise = promise;
|
|
481
|
-
|
|
482
|
-
// Notify caller that spawn is complete (record is in the map, promise is set).
|
|
483
|
-
// Called synchronously — onSessionCreated fires asynchronously inside runAgent.
|
|
484
|
-
// Used by spawnAndWait to let the caller set up output files before streaming starts.
|
|
485
|
-
this.onSpawned?.(id);
|
|
486
616
|
}
|
|
487
617
|
|
|
488
618
|
/** Start queued agents up to the concurrency limit. */
|
|
@@ -499,18 +629,12 @@ export class AgentManager {
|
|
|
499
629
|
record.status = "error";
|
|
500
630
|
record.error = err instanceof Error ? err.message : String(err);
|
|
501
631
|
record.completedAt = Date.now();
|
|
632
|
+
this.checkpoint(record);
|
|
502
633
|
this.onComplete?.(record);
|
|
503
634
|
}
|
|
504
635
|
}
|
|
505
636
|
}
|
|
506
637
|
|
|
507
|
-
/**
|
|
508
|
-
* Called synchronously right after spawn, before onSessionCreated fires.
|
|
509
|
-
* Lets the caller set up the output file path on the record.
|
|
510
|
-
* The record is guaranteed to be in this.agents at this point.
|
|
511
|
-
*/
|
|
512
|
-
private onSpawned?: (id: string) => void;
|
|
513
|
-
|
|
514
638
|
/**
|
|
515
639
|
* Spawn an agent and wait for completion (foreground use).
|
|
516
640
|
* Foreground agents bypass the concurrency queue.
|
|
@@ -527,17 +651,14 @@ export class AgentManager {
|
|
|
527
651
|
options: Omit<SpawnOptions, "isBackground">,
|
|
528
652
|
onSpawned?: (id: string) => void,
|
|
529
653
|
): Promise<{ id: string; record: AgentRecord }> {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
} finally {
|
|
539
|
-
this.onSpawned = prevOnSpawned;
|
|
540
|
-
}
|
|
654
|
+
const id = this.spawn(pi, ctx, type, prompt, {
|
|
655
|
+
...options,
|
|
656
|
+
isBackground: false,
|
|
657
|
+
onSpawned,
|
|
658
|
+
});
|
|
659
|
+
const record = this.agents.get(id)!;
|
|
660
|
+
await record.promise;
|
|
661
|
+
return { id, record };
|
|
541
662
|
}
|
|
542
663
|
|
|
543
664
|
/**
|
|
@@ -562,6 +683,7 @@ export class AgentManager {
|
|
|
562
683
|
...(resumedModel && { effectiveModelName: resumedModel.name ?? resumedModel.id }),
|
|
563
684
|
effectiveThinking: record.session.thinkingLevel,
|
|
564
685
|
};
|
|
686
|
+
this.checkpoint(record);
|
|
565
687
|
|
|
566
688
|
try {
|
|
567
689
|
const { text, failure } = await resumeAgent(record.session, prompt, {
|
|
@@ -583,10 +705,12 @@ export class AgentManager {
|
|
|
583
705
|
if (failure) record.error = failure;
|
|
584
706
|
record.result = text;
|
|
585
707
|
record.completedAt = Date.now();
|
|
708
|
+
this.checkpoint(record);
|
|
586
709
|
} catch (err) {
|
|
587
710
|
record.status = "error";
|
|
588
711
|
record.error = err instanceof Error ? err.message : String(err);
|
|
589
712
|
record.completedAt = Date.now();
|
|
713
|
+
this.checkpoint(record);
|
|
590
714
|
}
|
|
591
715
|
|
|
592
716
|
return record;
|
|
@@ -652,6 +776,7 @@ export class AgentManager {
|
|
|
652
776
|
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
653
777
|
transcriptPath?: string;
|
|
654
778
|
invocation?: AgentInvocation;
|
|
779
|
+
compactionCount?: number;
|
|
655
780
|
}): AgentRecord {
|
|
656
781
|
return {
|
|
657
782
|
id: record.id,
|
|
@@ -668,7 +793,7 @@ export class AgentManager {
|
|
|
668
793
|
lifetimeUsage: record.lifetimeUsage
|
|
669
794
|
? { ...record.lifetimeUsage }
|
|
670
795
|
: { input: 0, output: 0, cacheWrite: 0 },
|
|
671
|
-
compactionCount: 0,
|
|
796
|
+
compactionCount: record.compactionCount ?? 0,
|
|
672
797
|
};
|
|
673
798
|
}
|
|
674
799
|
|
|
@@ -681,6 +806,7 @@ export class AgentManager {
|
|
|
681
806
|
this.queue = this.queue.filter(q => q.id !== id);
|
|
682
807
|
record.status = "stopped";
|
|
683
808
|
record.completedAt = Date.now();
|
|
809
|
+
this.checkpoint(record);
|
|
684
810
|
return true;
|
|
685
811
|
}
|
|
686
812
|
|
|
@@ -688,6 +814,10 @@ export class AgentManager {
|
|
|
688
814
|
record.abortController?.abort();
|
|
689
815
|
record.status = "stopped";
|
|
690
816
|
record.completedAt = Date.now();
|
|
817
|
+
this.flushOutput(record);
|
|
818
|
+
this.checkpoint(record);
|
|
819
|
+
this.finishBackground(id);
|
|
820
|
+
this.drainQueue();
|
|
691
821
|
return true;
|
|
692
822
|
}
|
|
693
823
|
|
|
@@ -714,6 +844,10 @@ export class AgentManager {
|
|
|
714
844
|
record.outputCleanup = undefined;
|
|
715
845
|
record.outputFile = undefined;
|
|
716
846
|
record.historyFile = undefined;
|
|
847
|
+
// The durable transcript is the source of truth after the TTL. Keep
|
|
848
|
+
// only the small identity/status record in memory; get_subagent_result
|
|
849
|
+
// reloads the final answer from transcriptPath on demand.
|
|
850
|
+
record.result = undefined;
|
|
717
851
|
continue;
|
|
718
852
|
}
|
|
719
853
|
this.removeRecord(id, record);
|
|
@@ -750,16 +884,21 @@ export class AgentManager {
|
|
|
750
884
|
if (record) {
|
|
751
885
|
record.status = "stopped";
|
|
752
886
|
record.completedAt = Date.now();
|
|
887
|
+
this.checkpoint(record);
|
|
753
888
|
count++;
|
|
754
889
|
}
|
|
755
890
|
}
|
|
756
891
|
this.queue = [];
|
|
757
|
-
// Abort running agents
|
|
892
|
+
// Abort running agents. Flush before checkpointing so a catchable
|
|
893
|
+
// shutdown/session switch leaves the latest assistant message available.
|
|
758
894
|
for (const record of this.agents.values()) {
|
|
759
895
|
if (record.status === "running") {
|
|
760
896
|
record.abortController?.abort();
|
|
761
897
|
record.status = "stopped";
|
|
762
898
|
record.completedAt = Date.now();
|
|
899
|
+
this.flushOutput(record);
|
|
900
|
+
this.finishBackground(record.id);
|
|
901
|
+
this.checkpoint(record);
|
|
763
902
|
count++;
|
|
764
903
|
}
|
|
765
904
|
}
|
|
@@ -789,6 +928,9 @@ export class AgentManager {
|
|
|
789
928
|
record.session?.dispose();
|
|
790
929
|
}
|
|
791
930
|
this.agents.clear();
|
|
931
|
+
this.recoveryCwds.clear();
|
|
932
|
+
this.runningBackgroundIds.clear();
|
|
933
|
+
this.runningBackground = 0;
|
|
792
934
|
// Prune any orphaned git worktrees (crash recovery)
|
|
793
935
|
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
794
936
|
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Durable checkpoints for agents that may be interrupted by a catchable lifecycle event. */
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ensureSubagentsGitignore } from "./agent-history.js";
|
|
7
|
+
import type { AgentInvocation } from "./types.js";
|
|
8
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
9
|
+
|
|
10
|
+
const SUBAGENTS_DIR = ".pi-subagents";
|
|
11
|
+
const CHECKPOINTS_DIR = "agent-checkpoints";
|
|
12
|
+
const CHECKPOINT_VERSION = 1;
|
|
13
|
+
const TERMINAL_STATUSES = new Set(["completed", "steered", "stopped", "aborted", "error"] as const);
|
|
14
|
+
const THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "off"]);
|
|
15
|
+
|
|
16
|
+
type ActiveStatus = "running" | "queued";
|
|
17
|
+
type TerminalStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
|
|
18
|
+
export type AgentRecoveryStatus = ActiveStatus | TerminalStatus;
|
|
19
|
+
|
|
20
|
+
export interface AgentRecoveryCheckpoint {
|
|
21
|
+
version: 1;
|
|
22
|
+
id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
description: string;
|
|
25
|
+
status: AgentRecoveryStatus;
|
|
26
|
+
startedAt: number;
|
|
27
|
+
completedAt?: number;
|
|
28
|
+
result?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
toolUses: number;
|
|
31
|
+
lifetimeUsage: LifetimeUsage;
|
|
32
|
+
compactionCount: number;
|
|
33
|
+
transcriptPath?: string;
|
|
34
|
+
invocation?: AgentInvocation;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isSafeString(value: unknown, maxLength: number): value is string {
|
|
38
|
+
return typeof value === "string" && value.length > 0 && value.length <= maxLength && !/[\0\r\n]/.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isFiniteTimestamp(value: unknown): value is number {
|
|
42
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isUsage(value: unknown): value is LifetimeUsage {
|
|
46
|
+
if (!value || typeof value !== "object") return false;
|
|
47
|
+
const usage = value as Record<string, unknown>;
|
|
48
|
+
return ["input", "output", "cacheWrite"].every((key) => {
|
|
49
|
+
const n = usage[key];
|
|
50
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isInvocation(value: unknown): value is AgentInvocation {
|
|
55
|
+
if (!value || typeof value !== "object") return false;
|
|
56
|
+
const invocation = value as Record<string, unknown>;
|
|
57
|
+
for (const key of ["modelName", "effectiveModelName"]) {
|
|
58
|
+
if (invocation[key] !== undefined && !isSafeString(invocation[key], 512)) return false;
|
|
59
|
+
}
|
|
60
|
+
for (const key of ["thinking", "effectiveThinking"]) {
|
|
61
|
+
if (invocation[key] !== undefined && (typeof invocation[key] !== "string" || !THINKING_LEVELS.has(invocation[key]))) return false;
|
|
62
|
+
}
|
|
63
|
+
if (invocation.maxTurns !== undefined && (!Number.isInteger(invocation.maxTurns) || (invocation.maxTurns as number) < 0)) return false;
|
|
64
|
+
for (const key of ["isolated", "inheritContext", "runInBackground"]) {
|
|
65
|
+
if (invocation[key] !== undefined && typeof invocation[key] !== "boolean") return false;
|
|
66
|
+
}
|
|
67
|
+
return invocation.isolation === undefined || invocation.isolation === "worktree";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isActiveStatus(value: AgentRecoveryStatus): value is ActiveStatus {
|
|
71
|
+
return value === "running" || value === "queued";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTerminalStatus(value: AgentRecoveryStatus): value is TerminalStatus {
|
|
75
|
+
return TERMINAL_STATUSES.has(value as TerminalStatus);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isSafeTranscriptPath(value: unknown): value is string {
|
|
79
|
+
return typeof value === "string"
|
|
80
|
+
&& /^\.pi-subagents\/agent-transcripts\/[^/]+\.jsonl$/.test(value)
|
|
81
|
+
&& !value.includes("..")
|
|
82
|
+
&& !value.includes("\\")
|
|
83
|
+
&& !value.includes("\0");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Validate untrusted JSON before it can enter the manager or UI. */
|
|
87
|
+
export function isAgentRecoveryCheckpoint(value: unknown): value is AgentRecoveryCheckpoint {
|
|
88
|
+
if (!value || typeof value !== "object") return false;
|
|
89
|
+
const checkpoint = value as Record<string, unknown>;
|
|
90
|
+
if (checkpoint.version !== CHECKPOINT_VERSION
|
|
91
|
+
|| !isSafeString(checkpoint.id, 256)
|
|
92
|
+
|| !isSafeString(checkpoint.type, 256)
|
|
93
|
+
|| !isSafeString(checkpoint.description, 4096)
|
|
94
|
+
|| typeof checkpoint.status !== "string"
|
|
95
|
+
|| !isFiniteTimestamp(checkpoint.startedAt)
|
|
96
|
+
|| !Number.isInteger(checkpoint.toolUses)
|
|
97
|
+
|| (checkpoint.toolUses as number) < 0
|
|
98
|
+
|| !isUsage(checkpoint.lifetimeUsage)
|
|
99
|
+
|| !Number.isInteger(checkpoint.compactionCount)
|
|
100
|
+
|| (checkpoint.compactionCount as number) < 0) return false;
|
|
101
|
+
|
|
102
|
+
const status = checkpoint.status as AgentRecoveryStatus;
|
|
103
|
+
if (!isActiveStatus(status) && !isTerminalStatus(status)) return false;
|
|
104
|
+
if (checkpoint.completedAt !== undefined && !isFiniteTimestamp(checkpoint.completedAt)) return false;
|
|
105
|
+
if (isTerminalStatus(status)
|
|
106
|
+
&& (checkpoint.completedAt === undefined || checkpoint.completedAt < checkpoint.startedAt)) return false;
|
|
107
|
+
if (isActiveStatus(status) && checkpoint.completedAt !== undefined) return false;
|
|
108
|
+
if (checkpoint.result !== undefined && !isSafeString(checkpoint.result, 2_000_000)) return false;
|
|
109
|
+
if (checkpoint.error !== undefined && !isSafeString(checkpoint.error, 64_000)) return false;
|
|
110
|
+
if (checkpoint.transcriptPath !== undefined && !isSafeTranscriptPath(checkpoint.transcriptPath)) return false;
|
|
111
|
+
if (checkpoint.invocation !== undefined && !isInvocation(checkpoint.invocation)) return false;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkpointDirectory(cwd: string): string {
|
|
116
|
+
return join(cwd, SUBAGENTS_DIR, CHECKPOINTS_DIR);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Return the on-disk path for an agent's single deduplicated checkpoint. */
|
|
120
|
+
export function agentRecoveryCheckpointPath(cwd: string, agentId: string): string {
|
|
121
|
+
const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
|
|
122
|
+
return join(checkpointDirectory(cwd), `${safeId}.json`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Atomically write one checkpoint. Repeated writes for the same agent replace
|
|
127
|
+
* the same file; identical payloads are skipped, so shutdown + abort callbacks
|
|
128
|
+
* cannot create duplicate recovery records.
|
|
129
|
+
*/
|
|
130
|
+
export function removeAgentRecoveryCheckpoint(cwd: string, agentId: string): boolean {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(agentRecoveryCheckpointPath(cwd, agentId));
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function writeAgentRecoveryCheckpoint(cwd: string, checkpoint: AgentRecoveryCheckpoint): boolean {
|
|
140
|
+
if (!isAgentRecoveryCheckpoint(checkpoint)) return false;
|
|
141
|
+
try {
|
|
142
|
+
ensureSubagentsGitignore(cwd);
|
|
143
|
+
const directory = checkpointDirectory(cwd);
|
|
144
|
+
mkdirSync(directory, { recursive: true });
|
|
145
|
+
const path = agentRecoveryCheckpointPath(cwd, checkpoint.id);
|
|
146
|
+
const contents = `${JSON.stringify(checkpoint)}\n`;
|
|
147
|
+
try {
|
|
148
|
+
if (readFileSync(path, "utf8") === contents) return false;
|
|
149
|
+
} catch {
|
|
150
|
+
// The file is new, missing, or unreadable; replace it below.
|
|
151
|
+
}
|
|
152
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
153
|
+
writeFileSync(temporary, contents, { encoding: "utf8", mode: 0o600 });
|
|
154
|
+
renameSync(temporary, path);
|
|
155
|
+
return true;
|
|
156
|
+
} catch {
|
|
157
|
+
// Recovery must never make a spawn or shutdown fail. A later checkpoint
|
|
158
|
+
// gets another chance to persist if the filesystem becomes available.
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Load valid checkpoints, ignoring orphan, malformed, and corrupt files. */
|
|
164
|
+
export function readAgentRecoveryCheckpoints(cwd: string): AgentRecoveryCheckpoint[] {
|
|
165
|
+
const directory = checkpointDirectory(cwd);
|
|
166
|
+
if (!existsSync(directory)) return [];
|
|
167
|
+
let names: string[];
|
|
168
|
+
try {
|
|
169
|
+
names = readdirSync(directory).filter((name) => /^[A-Za-z0-9._-]+\.json$/.test(name));
|
|
170
|
+
} catch {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const latest = new Map<string, AgentRecoveryCheckpoint>();
|
|
175
|
+
for (const name of names) {
|
|
176
|
+
try {
|
|
177
|
+
const value: unknown = JSON.parse(readFileSync(join(directory, name), "utf8"));
|
|
178
|
+
if (!isAgentRecoveryCheckpoint(value)) continue;
|
|
179
|
+
latest.set(value.id, value);
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore a partially written/corrupt checkpoint and continue indexing.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...latest.values()];
|
|
185
|
+
}
|