@ferris1225/pi-subagents 4.1.15 → 4.1.16
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/README.md +333 -302
- package/agents/cleaner.md +23 -29
- package/agents/documenter.md +21 -18
- package/agents/explorer.md +6 -1
- package/agents/reviewer.md +82 -85
- package/agents/worker.md +45 -37
- package/package.json +1 -1
- package/src/announcements.ts +59 -61
- package/src/background.ts +26 -10
- package/src/completion.ts +7 -1
- package/src/dispatch.ts +48 -16
- package/src/durable.ts +72 -7
- package/src/format.ts +179 -175
- package/src/monitor.ts +4 -2
- package/src/prompt.ts +2 -2
- package/src/runtime.ts +3 -3
- package/src/spawn.ts +650 -642
- package/src/temp-hygiene.ts +0 -28
- package/src/thread-lifecycle.ts +42 -33
- package/src/tools.ts +712 -708
- package/src/widget.ts +9 -0
- package/src/workflow.ts +199 -200
- package/src/worktree.ts +64 -37
package/src/announcements.ts
CHANGED
|
@@ -1,61 +1,59 @@
|
|
|
1
|
-
/** Session-start recovery, stale-config migration, and widget installation. */
|
|
2
|
-
|
|
3
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { loadConfig, saveConfig } from "./config.ts";
|
|
6
|
-
import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
|
|
7
|
-
import { announceRecoveryRecords } from "./recovery.ts";
|
|
8
|
-
import type { SubagentRuntime } from "./runtime.ts";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
});
|
|
61
|
-
}
|
|
1
|
+
/** Session-start recovery, stale-config migration, and widget installation. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
6
|
+
import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
|
|
7
|
+
import { announceRecoveryRecords } from "./recovery.ts";
|
|
8
|
+
import type { SubagentRuntime } from "./runtime.ts";
|
|
9
|
+
import { installActiveRunsWidget } from "./widget.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One-time-per-stale-override migration: keep agent model selections Pi still
|
|
13
|
+
* reports as available, drop the rest back to dynamic main-model routing, and
|
|
14
|
+
* tell the user what was removed. Saving the cleaned config is what makes it
|
|
15
|
+
* one-time — the dropped refs no longer exist to re-trigger the notice.
|
|
16
|
+
*/
|
|
17
|
+
async function migrateUnavailableAgentModels(
|
|
18
|
+
ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
|
|
19
|
+
runtime: SubagentRuntime,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
const config = await loadConfig(runtime.configPath);
|
|
23
|
+
const overrides = Object.entries(config.agentModels);
|
|
24
|
+
if (overrides.length === 0) return;
|
|
25
|
+
const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
|
|
26
|
+
if (dropped.length === 0) return;
|
|
27
|
+
await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
|
|
28
|
+
const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
|
|
29
|
+
ctx.ui.notify(
|
|
30
|
+
`pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
|
|
31
|
+
"warning",
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
/* migration failures are non-fatal */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
39
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
40
|
+
if (!existsSync(runtime.configPath)) {
|
|
41
|
+
ctx.ui.notify(
|
|
42
|
+
"pi-subagents: no configuration yet — run /subagents-setup to pick agents, models, and thinking strengths. Defaults (all five agents on the main model) apply until then.",
|
|
43
|
+
"info",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
await announceRecoveryRecords(runtime.configPath, ctx);
|
|
47
|
+
await migrateUnavailableAgentModels(ctx, runtime);
|
|
48
|
+
if (!runtime.restoredNotified && runtime.restoredRunIds.length > 0) {
|
|
49
|
+
runtime.restoredNotified = true;
|
|
50
|
+
const ids = runtime.restoredRunIds.map((id) => `#${id}`).join(", ");
|
|
51
|
+
ctx.ui.notify(
|
|
52
|
+
`pi-subagents: restored ${runtime.restoredRunIds.length} interrupted thread${runtime.restoredRunIds.length === 1 ? "" : "s"} from the previous session (${ids}). subagent_status lists them; subagent_control resume continues one.`,
|
|
53
|
+
"info",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (ctx.mode !== "tui") return;
|
|
57
|
+
installActiveRunsWidget(ctx);
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/background.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* the user and the main agent instead of it vanishing into the queue.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import { cpus } from "node:os";
|
|
13
|
+
|
|
14
|
+
export type BackgroundTask = (signal: AbortSignal, controller: AbortController) => Promise<void>;
|
|
13
15
|
|
|
14
16
|
interface PendingTask {
|
|
15
17
|
task: BackgroundTask;
|
|
@@ -27,13 +29,14 @@ interface PendingTask {
|
|
|
27
29
|
onError?: (error: unknown) => void | Promise<void>;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/** How many sub-agent processes may run at once
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
/** How many sub-agent processes may run at once, derived from the host instead
|
|
33
|
+
* of being fixed: children wait on model I/O far more than on CPU, so the pool
|
|
34
|
+
* scales with cores while the bounds keep tiny machines usable and huge ones
|
|
35
|
+
* from fanning out into an API-rate-limit wall. Pacing only — the queue never
|
|
36
|
+
* rejects work; a wider parallel `subagent` call simply waits for a slot. */
|
|
37
|
+
export function resolveSubagentConcurrency(cpuCount: number = cpus().length): number {
|
|
38
|
+
return Math.min(16, Math.max(4, Math.floor(cpuCount / 2)));
|
|
39
|
+
}
|
|
37
40
|
|
|
38
41
|
export class BackgroundTaskQueue {
|
|
39
42
|
private concurrency: number;
|
|
@@ -77,9 +80,22 @@ export class BackgroundTaskQueue {
|
|
|
77
80
|
return this.completions.get(controller) ?? Promise.resolve();
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
/** Slot count, exposed so dispatch/status output can state the real pacing
|
|
84
|
+
* limit instead of leaving queued work looking like an unexplained cap. */
|
|
85
|
+
get capacity(): number {
|
|
86
|
+
return this.concurrency;
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
/** Stop counting a running task toward the concurrency limit. Its body keeps
|
|
81
90
|
* running under the same abort signal; completion still releases everything
|
|
82
|
-
* waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
|
|
91
|
+
* waitForTask/waitForIdle promise. Frees a slot for queued work immediately.
|
|
92
|
+
*
|
|
93
|
+
* Used by tasks whose execution is serialized elsewhere anyway (managed
|
|
94
|
+
* workflow continuations, shared-checkout writers waiting on the repository
|
|
95
|
+
* lane): letting such a task also hold a global slot would let waiters
|
|
96
|
+
* starve independent work that could start right away. The controller is
|
|
97
|
+
* handed to the task body directly, so a task can always suspend itself
|
|
98
|
+
* without racing the enqueue() caller's assignment. */
|
|
83
99
|
suspend(controller: AbortController | undefined): void {
|
|
84
100
|
if (!controller || this.stopped) return;
|
|
85
101
|
if (!this.active.delete(controller)) return;
|
|
@@ -149,7 +165,7 @@ export class BackgroundTaskQueue {
|
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
this.active.add(entry.controller);
|
|
152
|
-
void entry.task(entry.controller.signal)
|
|
168
|
+
void entry.task(entry.controller.signal, entry.controller)
|
|
153
169
|
.catch(async (error: unknown) => {
|
|
154
170
|
// Cancellation is not a failure: aborted work (e.g. session
|
|
155
171
|
// shutdown) must never be reported as an exception.
|
package/src/completion.ts
CHANGED
|
@@ -139,6 +139,9 @@ export interface ActiveRunFoot {
|
|
|
139
139
|
agent: string;
|
|
140
140
|
/** Optional content label (task-derived) shown next to the agent name. */
|
|
141
141
|
label?: string;
|
|
142
|
+
/** True when the run is waiting for a free process slot rather than
|
|
143
|
+
* executing; stated so pacing is never mistaken for a stall. */
|
|
144
|
+
queued?: boolean;
|
|
142
145
|
}
|
|
143
146
|
|
|
144
147
|
/**
|
|
@@ -153,7 +156,10 @@ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed
|
|
|
153
156
|
if (runs.length === 0) return "";
|
|
154
157
|
const listed = runs.slice(0, maxListed);
|
|
155
158
|
const items = listed
|
|
156
|
-
.map((run) =>
|
|
159
|
+
.map((run) => {
|
|
160
|
+
const tagged = run.queued ? " (queued, starts when a slot frees)" : "";
|
|
161
|
+
return `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}${tagged}`;
|
|
162
|
+
})
|
|
157
163
|
.join(", ");
|
|
158
164
|
const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
|
|
159
165
|
return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
|
package/src/dispatch.ts
CHANGED
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
-
import { resolve } from "node:path";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
|
-
import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
15
|
-
import { getStateRoot } from "./durable.ts";
|
|
14
|
+
import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
16
15
|
import { loadConfig } from "./config.ts";
|
|
17
16
|
import { formatUsage, queuedResult } from "./format.ts";
|
|
18
17
|
import {
|
|
@@ -35,6 +34,7 @@ import {
|
|
|
35
34
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
36
35
|
import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
|
|
37
36
|
import {
|
|
37
|
+
getProjectRoot,
|
|
38
38
|
getResultOutput,
|
|
39
39
|
isFailedResult,
|
|
40
40
|
reviewVerdict,
|
|
@@ -84,9 +84,23 @@ const SubagentParams = Type.Object({
|
|
|
84
84
|
isolation: IsolationSchema,
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
/** Roles that default to worktree isolation in parallel dispatches even when
|
|
88
|
+
* the live catalog cannot be consulted (render-only call sites). Custom
|
|
89
|
+
* write-capable agents join them via isWriteCapableAgent on the execute path. */
|
|
90
|
+
const WORKTREE_DEFAULT_AGENTS = new Set(["worker", "cleaner", "documenter"]);
|
|
91
|
+
|
|
92
|
+
/** Resolve the default isolation for a dispatch. Parallel write-capable agents
|
|
93
|
+
* get a detached worktree: shared writers serialize on the repository lane, so
|
|
94
|
+
* defaulting them to shared would turn one parallel batch into a convoy that
|
|
95
|
+
* also parks process slots. Explicit requests always win. */
|
|
96
|
+
export function defaultIsolationMode(
|
|
97
|
+
mode: "single" | "parallel",
|
|
98
|
+
agentName: string,
|
|
99
|
+
requested?: IsolationMode,
|
|
100
|
+
writeCapable = WORKTREE_DEFAULT_AGENTS.has(agentName),
|
|
101
|
+
): IsolationMode {
|
|
88
102
|
if (requested) return requested;
|
|
89
|
-
return mode === "parallel" &&
|
|
103
|
+
return mode === "parallel" && writeCapable ? "worktree" : "shared";
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
function workflowStageStatus(result: SingleResult, relation?: string): WorkflowStageStatus {
|
|
@@ -100,12 +114,13 @@ function workflowStageStatus(result: SingleResult, relation?: string): WorkflowS
|
|
|
100
114
|
|
|
101
115
|
/** The runtime-granted write continuation of a failed gate: same reviewer
|
|
102
116
|
* role, model, and retained session, but the read-only boundary is lifted for
|
|
103
|
-
* this one stage so it applies its own fix instructions.
|
|
117
|
+
* this one stage so it applies its own fix instructions. One line only: the
|
|
118
|
+
* full fix-stage contract is already in the retained session's prompt. */
|
|
104
119
|
function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
|
|
105
120
|
return {
|
|
106
121
|
...agent,
|
|
107
122
|
tools: undefined,
|
|
108
|
-
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as
|
|
123
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\nRuntime workflow context: FIX STAGE — your gate just returned REVIEW_FAIL. Your read-only boundary is lifted for this stage only: apply your own fix instructions exactly as specified, verify, and report what changed; never edit during a review and never emit a verdict here.`,
|
|
109
124
|
};
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -187,6 +202,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
187
202
|
(mode: "single" | "parallel", background = false) =>
|
|
188
203
|
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
189
204
|
|
|
205
|
+
/** Pacing note appended to dispatch confirmations whenever runs are actually
|
|
206
|
+
* waiting: states the real slot capacity so ordinary queueing is never
|
|
207
|
+
* mistaken for a hard dispatch limit. Empty when everything is running. */
|
|
208
|
+
const queuePacingNote = (): string => {
|
|
209
|
+
const runs = monitor.getRuns();
|
|
210
|
+
const waiting = runs.filter((run) => run.status === "queued").length;
|
|
211
|
+
if (waiting === 0) return "";
|
|
212
|
+
const running = runs.filter((run) => run.status === "running" || run.status === "interrupting").length;
|
|
213
|
+
return ` Pacing: ${running} running · ${waiting} waiting for a free process slot (capacity ${runtime.backgroundQueue.capacity}); waiting runs start automatically as slots free — keep dispatching independent units.`;
|
|
214
|
+
};
|
|
215
|
+
|
|
190
216
|
/** Launch one workflow-internal child in a fresh model context. It sees the
|
|
191
217
|
* parent's exact repository/worktree state and is registered by its own id,
|
|
192
218
|
* but never enters top-level lifecycle policy or completion delivery.
|
|
@@ -240,7 +266,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
240
266
|
onLive,
|
|
241
267
|
makeDetails: makeDetails("single", true),
|
|
242
268
|
idleTimeoutMs: stageConfig.idleTimeoutSec * 1000,
|
|
243
|
-
sessionRoot:
|
|
269
|
+
sessionRoot: join(getProjectRoot(runtime.configPath, request.executionCwd), "sessions"),
|
|
244
270
|
...(stage.session
|
|
245
271
|
? { sessionId: stage.session.sessionId, sessionDir: stage.session.sessionDir, stdinText: task }
|
|
246
272
|
: {}),
|
|
@@ -372,10 +398,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
372
398
|
"final review",
|
|
373
399
|
reviewStage,
|
|
374
400
|
);
|
|
375
|
-
// The failing gate owns its fixes
|
|
401
|
+
// The failing gate owns its fixes: the same retained
|
|
376
402
|
// reviewer session applies its own fix instructions with write access,
|
|
377
|
-
// then a
|
|
378
|
-
// loop has to guess what satisfies the gate; the cap only stops
|
|
403
|
+
// then a converging re-review verifies the fixes. The cap only stops
|
|
379
404
|
// pathological burn and hands the still-failing gate to the main agent.
|
|
380
405
|
for (let round = 1; round <= MAX_REVIEW_FIX_ROUNDS; round++) {
|
|
381
406
|
const gateSession = gateReview.sessionId && gateReview.sessionDir
|
|
@@ -430,8 +455,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
430
455
|
label: "Subagent",
|
|
431
456
|
description: [
|
|
432
457
|
"Dispatch enabled agents as isolated leaf Pi child processes, singly or in parallel. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
|
|
433
|
-
"Put every genuinely independent unit in one `tasks` array (no per-call cap
|
|
434
|
-
"
|
|
458
|
+
"Put every genuinely independent unit in one `tasks` array (no per-call cap). Process slots scale with the machine; when all slots are busy the extra runs simply wait and start automatically as slots free — waiting is pacing, never a rejection or a limit on how much you may dispatch.",
|
|
459
|
+
"Every parallel write-capable agent (worker, cleaner, documenter, custom writers) defaults to a detached Git worktree, so writers run concurrently; shared mode serializes same-repository writers. Explicit `shared` keeps the caller's checkout; setup failure never silently falls back to shared.",
|
|
460
|
+
"Successful worker/cleaner runs get one automatic reviewer gate; a failing gate is fixed by the reviewer itself in a write-enabled continuation of the same session and re-reviewed in bounded, converging rounds. A REVIEW_FAIL from a gate you dispatched directly returns its findings to you — fix them inline or via a briefed worker without waiting for the user.",
|
|
435
461
|
"A configured child-model failure continues the retained session on the current main model. Resume a parked or settled thread with subagent_control by run id; use subagent_stop for destructive cancellation.",
|
|
436
462
|
].join(" "),
|
|
437
463
|
promptSnippet:
|
|
@@ -504,11 +530,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
504
530
|
// Preserve caller order (and deterministic completion batching) while
|
|
505
531
|
// preparing each isolated filesystem before its queue entry can start.
|
|
506
532
|
for (const item of params.tasks) {
|
|
533
|
+
const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
|
|
507
534
|
results.push(await startBackground(
|
|
508
535
|
item.agent,
|
|
509
536
|
item.task,
|
|
510
537
|
item.cwd,
|
|
511
|
-
defaultIsolationMode(
|
|
538
|
+
defaultIsolationMode(
|
|
539
|
+
"parallel",
|
|
540
|
+
item.agent,
|
|
541
|
+
item.isolation as IsolationMode | undefined,
|
|
542
|
+
catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
|
|
543
|
+
),
|
|
512
544
|
));
|
|
513
545
|
}
|
|
514
546
|
const startedRuns = results.filter((result) => result.exitCode === -1);
|
|
@@ -533,7 +565,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
533
565
|
...(failureLines.length > 0
|
|
534
566
|
? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
|
|
535
567
|
: []),
|
|
536
|
-
].join("\n");
|
|
568
|
+
].join("\n") + queuePacingNote();
|
|
537
569
|
return {
|
|
538
570
|
content: [{ type: "text", text }],
|
|
539
571
|
details: makeDetails("parallel", true)(results),
|
|
@@ -551,7 +583,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
551
583
|
}
|
|
552
584
|
const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
|
|
553
585
|
return {
|
|
554
|
-
content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle
|
|
586
|
+
content: [{ type: "text", text: `Started ${runRef} in the background. It never blocks you — dispatch more independent units now or keep working; its result resumes you automatically when you are idle.${queuePacingNote()}` }],
|
|
555
587
|
details: makeDetails("single", true)([result]),
|
|
556
588
|
};
|
|
557
589
|
|
package/src/durable.ts
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { existsSync } from "node:fs";
|
|
16
|
+
import { existsSync, type Dirent, readdirSync, statSync } from "node:fs";
|
|
17
17
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import type { UsageStats } from "./rpc-run.ts";
|
|
20
20
|
import type { SubagentThread } from "./runtime.ts";
|
|
21
|
-
import { getResultOutput, isFailedResult, type SingleResult } from "./spawn.ts";
|
|
21
|
+
import { getResultOutput, isFailedResult, getProjectRoot, PROJECT_ROOTS_DIR_NAME, type SingleResult } from "./spawn.ts";
|
|
22
22
|
import {
|
|
23
|
+
isPathInside,
|
|
23
24
|
restoreWorktreeIsolation,
|
|
24
25
|
type IsolationMode,
|
|
25
26
|
normalizeWorktreeSnapshot,
|
|
@@ -29,7 +30,12 @@ import {
|
|
|
29
30
|
|
|
30
31
|
export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
|
|
31
32
|
const THREADS_MANIFEST_VERSION = 1;
|
|
32
|
-
|
|
33
|
+
|
|
34
|
+
/** Project directories whose newest file has not been touched for this long
|
|
35
|
+
* are deleted wholesale on load, so per-project sessions/worktrees/results
|
|
36
|
+
* can never accumulate forever. Parked threads' manifest references always
|
|
37
|
+
* win over the age rule. */
|
|
38
|
+
export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
33
39
|
|
|
34
40
|
/** Fixed retention: parked work (which may hold unintegrated changes) stops
|
|
35
41
|
* being resumable after a month. Older manifests may still carry settled
|
|
@@ -76,10 +82,6 @@ interface ThreadsManifest {
|
|
|
76
82
|
records: ThreadRecord[];
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
export function getStateRoot(configPath: string): string {
|
|
80
|
-
return join(dirname(configPath), STATE_DIR_NAME);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
85
|
export function getThreadsManifestPath(configPath: string): string {
|
|
84
86
|
return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
|
|
85
87
|
}
|
|
@@ -335,3 +337,66 @@ export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<st
|
|
|
335
337
|
}
|
|
336
338
|
return paths;
|
|
337
339
|
}
|
|
340
|
+
|
|
341
|
+
/** Newest modification time anywhere under root (directories count via their
|
|
342
|
+
* own entries); undefined when root cannot be read. */
|
|
343
|
+
function newestMtimeMs(root: string, now: number = Date.now()): number | undefined {
|
|
344
|
+
let newest: number | undefined;
|
|
345
|
+
const stack: string[] = [root];
|
|
346
|
+
while (stack.length > 0) {
|
|
347
|
+
const dir = stack.pop()!;
|
|
348
|
+
let entries: Dirent[];
|
|
349
|
+
try {
|
|
350
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const path = join(dir, entry.name);
|
|
356
|
+
let mtime: number;
|
|
357
|
+
try {
|
|
358
|
+
mtime = statSync(path).mtimeMs;
|
|
359
|
+
} catch {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (mtime > 0 && mtime <= now && (newest === undefined || mtime > newest)) newest = mtime;
|
|
363
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) stack.push(path);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return newest;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Delete project directories under the ferris-pi-subagents root that have
|
|
370
|
+
* been idle past PROJECT_ROOT_MAX_AGE_MS. A directory containing any path the
|
|
371
|
+
* threads manifest still references is never touched, so parked work outlives
|
|
372
|
+
* the age rule. Returns the removed directory names. */
|
|
373
|
+
export async function pruneStaleProjectRoots(configPath: string, options: { now?: number } = {}): Promise<string[]> {
|
|
374
|
+
const now = options.now ?? Date.now();
|
|
375
|
+
const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
|
|
376
|
+
const referenced = referencedDurablePaths(records);
|
|
377
|
+
const root = join(dirname(configPath), PROJECT_ROOTS_DIR_NAME);
|
|
378
|
+
let projects: Dirent[];
|
|
379
|
+
try {
|
|
380
|
+
projects = readdirSync(root, { withFileTypes: true });
|
|
381
|
+
} catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
const removed: string[] = [];
|
|
385
|
+
for (const project of projects) {
|
|
386
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
387
|
+
const projectDir = join(root, project.name);
|
|
388
|
+
if (containsReferencedPath(projectDir, referenced)) continue;
|
|
389
|
+
const newest = newestMtimeMs(projectDir, now);
|
|
390
|
+
if (newest === undefined || now - newest <= PROJECT_ROOT_MAX_AGE_MS) continue;
|
|
391
|
+
await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);
|
|
392
|
+
if (!existsSync(projectDir)) removed.push(project.name);
|
|
393
|
+
}
|
|
394
|
+
return removed;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function containsReferencedPath(projectDir: string, referenced: ReadonlySet<string>): boolean {
|
|
398
|
+
for (const path of referenced) {
|
|
399
|
+
if (isPathInside(projectDir, path)) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|