@arhen/pi-core-subagent 1.3.32 → 1.3.34
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/package.json +1 -1
- package/src/agentfile.ts +56 -39
- package/src/format.ts +19 -1
- package/src/index.ts +17 -6
- package/src/manager.ts +182 -44
- package/src/types.ts +11 -0
- package/src/worktree.ts +153 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
package/src/agentfile.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface AgentFileInfo {
|
|
|
13
13
|
model?: string;
|
|
14
14
|
tools?: string[];
|
|
15
15
|
description?: string;
|
|
16
|
+
/** The matched file path — surfaced so the leader can audit which file won. */
|
|
17
|
+
path?: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
const AGENT_DIRS = [".agents/agents", ".claude/agents", ".pi/agents"] as const;
|
|
@@ -41,10 +43,23 @@ const STOP = new Set([
|
|
|
41
43
|
"as",
|
|
42
44
|
"how",
|
|
43
45
|
"what",
|
|
44
|
-
"when",
|
|
45
46
|
"who",
|
|
46
47
|
]);
|
|
47
48
|
|
|
49
|
+
/** A body becomes the child's ENTIRE system prompt — an oversized reference file
|
|
50
|
+
* would blow the context window and kill the session with a cryptic error. */
|
|
51
|
+
const MAX_BODY_CHARS = 64_000;
|
|
52
|
+
|
|
53
|
+
/** Per-cwd memo of the ancestor walk (project dirs then home), since the files
|
|
54
|
+
* can't meaningfully change within one run and the walk costs 3 sync stats per
|
|
55
|
+
* ancestor dir per task otherwise. Keyed per (agentDir + cwd). */
|
|
56
|
+
const walkCache = new Map<string, AgentFileInfo[]>();
|
|
57
|
+
|
|
58
|
+
/** Test/diagnostic hook: flush the walk cache (files changed mid-session). */
|
|
59
|
+
export function clearAgentFileCache(): void {
|
|
60
|
+
walkCache.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
/** Lowercase, split, drop stopwords, strip plural -s/-es. */
|
|
49
64
|
function tokens(text: string): string[] {
|
|
50
65
|
return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
|
|
@@ -68,65 +83,67 @@ function readAgentFile(dir: string): AgentFileInfo[] {
|
|
|
68
83
|
const out: AgentFileInfo[] = [];
|
|
69
84
|
for (const entry of readdirSync(dir)) {
|
|
70
85
|
if (!entry.endsWith(".md")) continue;
|
|
71
|
-
const
|
|
86
|
+
const path = join(dir, entry);
|
|
87
|
+
const { frontmatter, body } = parseFrontmatter(readFileSync(path, "utf8"));
|
|
88
|
+
let trimmed = body;
|
|
89
|
+
if (trimmed.length > MAX_BODY_CHARS) {
|
|
90
|
+
trimmed = `${trimmed.slice(0, MAX_BODY_CHARS)}\n\n[truncated: agent file exceeded ${MAX_BODY_CHARS} chars — slim it down]`;
|
|
91
|
+
}
|
|
92
|
+
const toolsF = frontmatter.tools;
|
|
72
93
|
const tools =
|
|
73
|
-
typeof
|
|
74
|
-
?
|
|
94
|
+
typeof toolsF === "string"
|
|
95
|
+
? toolsF
|
|
75
96
|
.split(",")
|
|
76
97
|
.map((t) => t.trim())
|
|
77
98
|
.filter(Boolean)
|
|
78
|
-
: Array.isArray(
|
|
79
|
-
?
|
|
99
|
+
: Array.isArray(toolsF)
|
|
100
|
+
? toolsF.map(String)
|
|
80
101
|
: undefined;
|
|
81
102
|
out.push({
|
|
82
|
-
body,
|
|
103
|
+
body: trimmed,
|
|
83
104
|
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
|
|
84
105
|
tools: tools?.length ? tools : undefined,
|
|
85
106
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
107
|
+
path,
|
|
86
108
|
});
|
|
87
109
|
}
|
|
88
110
|
return out;
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
* description-overlap score wins; the first dir with a match is returned.
|
|
96
|
-
* `agentDir` is the pi agent dir (`~/.pi/agent`); home is derived from it.
|
|
97
|
-
*/
|
|
98
|
-
export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
|
|
99
|
-
const query = tokens(`${name} ${task}`);
|
|
113
|
+
/** Walk cwd's ancestors, then home, returning files in priority order (nearest
|
|
114
|
+
* first, each dir's 3 subdirs in AGENT_DIRS order). */
|
|
115
|
+
function allAgentFiles(cwd: string, agentDir: string): AgentFileInfo[] {
|
|
116
|
+
const out: AgentFileInfo[] = [];
|
|
100
117
|
let dir = cwd;
|
|
101
118
|
while (true) {
|
|
102
|
-
for (const sub of AGENT_DIRS)
|
|
103
|
-
let best: AgentFileInfo | undefined;
|
|
104
|
-
let bestScore = 0;
|
|
105
|
-
for (const file of readAgentFile(join(dir, sub))) {
|
|
106
|
-
const s = score(query, tokens(file.description ?? ""));
|
|
107
|
-
if (s > bestScore) {
|
|
108
|
-
best = file;
|
|
109
|
-
bestScore = s;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (best) return best; // first dir with any match wins (priority over score)
|
|
113
|
-
}
|
|
119
|
+
for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(dir, sub)));
|
|
114
120
|
const parent = dirname(dir);
|
|
115
121
|
if (parent === dir) break;
|
|
116
122
|
dir = parent;
|
|
117
123
|
}
|
|
118
124
|
const home = dirname(dirname(agentDir)); // ~/.pi/agent → ~
|
|
119
|
-
for (const sub of AGENT_DIRS)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
for (const sub of AGENT_DIRS) out.push(...readAgentFile(join(home, sub)));
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Best agent-file match for a spawn goal. Caches per (agentDir, cwd). */
|
|
130
|
+
export function resolveAgentFile(name: string, task: string, cwd: string, agentDir: string): AgentFileInfo | undefined {
|
|
131
|
+
const query = tokens(`${name} ${task}`);
|
|
132
|
+
const cacheKey = `${agentDir}${cwd}`;
|
|
133
|
+
let files = walkCache.get(cacheKey);
|
|
134
|
+
if (!files) {
|
|
135
|
+
files = allAgentFiles(cwd, agentDir);
|
|
136
|
+
if (walkCache.size >= 512) walkCache.clear();
|
|
137
|
+
walkCache.set(cacheKey, files);
|
|
138
|
+
}
|
|
139
|
+
let best: AgentFileInfo | undefined;
|
|
140
|
+
let bestScore = 0;
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
const s = score(query, tokens(file.description ?? ""));
|
|
143
|
+
if (s > 0 && s > bestScore) {
|
|
144
|
+
best = file;
|
|
145
|
+
bestScore = s;
|
|
128
146
|
}
|
|
129
|
-
if (best) return best;
|
|
130
147
|
}
|
|
131
|
-
return
|
|
148
|
+
return best;
|
|
132
149
|
}
|
package/src/format.ts
CHANGED
|
@@ -196,6 +196,23 @@ export class SubagentsWidget implements Component {
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
/** Blocking-call summary: full text, because the model asked for it. */
|
|
199
|
+
/** Where a write child's edits went: a branch to merge, or straight into the tree. */
|
|
200
|
+
function worktreeLine(task: TaskSnapshot): string {
|
|
201
|
+
const parts: string[] = [];
|
|
202
|
+
if (task.branch) {
|
|
203
|
+
const files = task.changedFiles?.length
|
|
204
|
+
? ` (${task.changedFiles.length} file(s): ${truncateText(task.changedFiles.join(", "), 160)})`
|
|
205
|
+
: "";
|
|
206
|
+
parts.push(`Branch: ${task.branch}${files} — merge with \`git merge --no-ff ${task.branch}\` after review.`);
|
|
207
|
+
} else if (task.isolation === "in-place") {
|
|
208
|
+
parts.push(
|
|
209
|
+
`Applied IN PLACE (no branch) — ${task.isolationReason ?? "worktree unavailable"}. Review the working tree directly.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (task.worktreeError) parts.push(`Worktree: ${task.worktreeError}`);
|
|
213
|
+
return parts.length ? `\n${parts.join("\n")}` : "";
|
|
214
|
+
}
|
|
215
|
+
|
|
199
216
|
export function makeSummary(run: RunSnapshot): string {
|
|
200
217
|
const succeeded = run.tasks.filter((t) => t.status === "completed").length;
|
|
201
218
|
const failed = run.tasks.filter((t) => t.status === "failed").length;
|
|
@@ -209,8 +226,9 @@ export function makeSummary(run: RunSnapshot): string {
|
|
|
209
226
|
for (const task of run.tasks) {
|
|
210
227
|
// Edges are named so the leader can compare what it delegated against what came back.
|
|
211
228
|
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
229
|
+
const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
|
|
212
230
|
lines.push(
|
|
213
|
-
`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${
|
|
231
|
+
`\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task)}`,
|
|
214
232
|
);
|
|
215
233
|
}
|
|
216
234
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
package/src/index.ts
CHANGED
|
@@ -188,15 +188,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
188
|
intercom.push(...awaited.intercom);
|
|
189
189
|
if (awaited.intercom.some((m) => m.kind === "ask")) break;
|
|
190
190
|
}
|
|
191
|
-
|
|
191
|
+
// EVERY ask must surface: siblings that asked in the same wake got no
|
|
192
|
+
// followUp notice (the park swallowed it), so showing only the first
|
|
193
|
+
// leaves the rest blocked until their 10-minute timeout.
|
|
194
|
+
const asks = intercom.filter((m) => m.kind === "ask");
|
|
192
195
|
const heard = intercom.filter((m) => m.kind !== "ask");
|
|
193
196
|
const text = [
|
|
194
197
|
makeSummary(run),
|
|
195
198
|
heard.length > 0
|
|
196
199
|
? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
|
|
197
200
|
: "",
|
|
198
|
-
|
|
199
|
-
? `\
|
|
201
|
+
asks.length > 0
|
|
202
|
+
? `\n${asks.length} child(ren) waiting for your answer:\n${asks
|
|
203
|
+
.map(
|
|
204
|
+
(a) =>
|
|
205
|
+
`- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
|
|
206
|
+
)
|
|
207
|
+
.join("\n")}\nAnswer each, then await_subagent again for the result.`
|
|
200
208
|
: "",
|
|
201
209
|
]
|
|
202
210
|
.filter(Boolean)
|
|
@@ -321,9 +329,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
329
|
`Run ${run.id} — ${run.status}`,
|
|
322
330
|
...tasks.map((t) => {
|
|
323
331
|
const wt = t.branch
|
|
324
|
-
? `\nBranch: ${t.branch}\n${t.diffStat || "(no
|
|
325
|
-
: ""
|
|
326
|
-
|
|
332
|
+
? `\nBranch: ${t.branch}\n${t.diffStat || "(no diff available)"}\nMerge after review: \`git merge --no-ff ${t.branch}\``
|
|
333
|
+
: t.isolation === "in-place"
|
|
334
|
+
? `\nApplied IN PLACE (no branch) — ${t.isolationReason ?? "worktree unavailable"}. The changes are already in your working tree.`
|
|
335
|
+
: "";
|
|
336
|
+
const wtErr = t.worktreeError ? `\nWorktree: ${t.worktreeError}` : "";
|
|
337
|
+
return `\n## ${t.agent} ${statusIcon(t.status)}\nGoal: ${truncateText(t.task, 300)}\n${t.error ? `Error: ${t.error}` : t.finalText || "(no output yet)"}${wt}${wtErr}\n${formatUsage(t.usage)}`;
|
|
327
338
|
}),
|
|
328
339
|
].join("\n");
|
|
329
340
|
return { content: [{ type: "text", text: truncateText(text) }], details: { run: cloneRun(run) } };
|
package/src/manager.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
-
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { join, relative } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
6
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
7
7
|
import {
|
|
@@ -61,6 +61,8 @@ const DEFAULT_RUNTIME_MS = 0;
|
|
|
61
61
|
const DEFAULT_STALL_MS = 180_000; // 3 min: long model thinking streams emit no events, but they're not stalled.
|
|
62
62
|
/** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
|
|
63
63
|
const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
|
|
64
|
+
/** Intercom messages buffered per park before the followUp path takes over. */
|
|
65
|
+
const PARKED_MSG_CAP = 24;
|
|
64
66
|
const READONLY_TOOLS = ["read", "grep", "find", "ls"];
|
|
65
67
|
const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
|
|
66
68
|
/** Tools that can mutate the tree — their presence is what earns a worktree. */
|
|
@@ -302,6 +304,28 @@ export class SubagentManager {
|
|
|
302
304
|
child.dispose();
|
|
303
305
|
}
|
|
304
306
|
this.liveChildren.clear();
|
|
307
|
+
// Mark every non-terminal task aborted BEFORE resolving pendingReplies: the
|
|
308
|
+
// resumed onAskParent closure re-checks status, and a still-awaiting task
|
|
309
|
+
// would flip back to "running" and re-insert the run after the maps clear —
|
|
310
|
+
// a ghost run that widgets re-arm on and persist forever.
|
|
311
|
+
for (const run of this.runs.values()) {
|
|
312
|
+
for (const task of run.tasks) {
|
|
313
|
+
if (!TERMINAL.includes(task.status)) {
|
|
314
|
+
task.status = "aborted";
|
|
315
|
+
task.error = task.error ?? "(session ended)";
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Release anyone parked on a run before the maps go — dropping waiters would
|
|
320
|
+
// leave their promises pending forever (autoAwait / await_subagent hang).
|
|
321
|
+
for (const [runId, waiters] of this.settleWaiters) {
|
|
322
|
+
const run = this.runs.get(runId);
|
|
323
|
+
for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
|
|
324
|
+
}
|
|
325
|
+
for (const pending of this.pendingReplies.values()) {
|
|
326
|
+
pending.resolve("(session ended — stop work immediately)");
|
|
327
|
+
}
|
|
328
|
+
this.parked.clear();
|
|
305
329
|
// Ownership markers stay on disk; the next session reaps those dirs (commit,
|
|
306
330
|
// keep branch, drop dir) once this pid is gone.
|
|
307
331
|
this.liveWorktrees.clear();
|
|
@@ -371,7 +395,13 @@ export class SubagentManager {
|
|
|
371
395
|
const parentFile = getParentSessionFile(ctx);
|
|
372
396
|
if (!parentFile) return;
|
|
373
397
|
const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
|
|
374
|
-
|
|
398
|
+
// Write-then-rename: a plain writeFile can tear on crash and silently drop
|
|
399
|
+
// ALL run history for the session on the next read.
|
|
400
|
+
const tmp = `${sidecar}.tmp`;
|
|
401
|
+
const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
|
|
402
|
+
void writeFile(tmp, payload)
|
|
403
|
+
.then(() => rename(tmp, sidecar))
|
|
404
|
+
.catch(() => {}); // never surface as an unhandled rejection
|
|
375
405
|
} catch {
|
|
376
406
|
/* ignore */
|
|
377
407
|
}
|
|
@@ -519,6 +549,12 @@ export class SubagentManager {
|
|
|
519
549
|
return {
|
|
520
550
|
onAskParent: async (_taskId, question) => {
|
|
521
551
|
const key = `${run.id}:${task.id}`;
|
|
552
|
+
// A tool call already in flight can reach here AFTER the task ended
|
|
553
|
+
// (abort/timeout/cancel). Reviving it would leave a "running" task in a
|
|
554
|
+
// finished run — hasActiveRun() then never clears.
|
|
555
|
+
if (TERMINAL.includes(task.status)) {
|
|
556
|
+
return "(your task has already ended — stop work and return immediately)";
|
|
557
|
+
}
|
|
522
558
|
this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
|
|
523
559
|
this.liveChildren.get(key)?.touchWatchdog();
|
|
524
560
|
// While the leader is parked in await_subagent the question rides the wait
|
|
@@ -534,6 +570,12 @@ export class SubagentManager {
|
|
|
534
570
|
const keepAlive = setInterval(() => this.liveChildren.get(key)?.touchWatchdog(), 30_000);
|
|
535
571
|
try {
|
|
536
572
|
const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
|
|
573
|
+
// Cancel wins over a reply that arrived in the same tick: never move a
|
|
574
|
+
// terminal task back to "running" (that would let a canceled task be
|
|
575
|
+
// reported as completed).
|
|
576
|
+
if (TERMINAL.includes(task.status)) {
|
|
577
|
+
return "(your task was canceled while you waited — stop work and return immediately)";
|
|
578
|
+
}
|
|
537
579
|
this.updateTask(run, task, { status: "running" }, ctx);
|
|
538
580
|
this.liveChildren.get(key)?.touchWatchdog();
|
|
539
581
|
return reply;
|
|
@@ -578,28 +620,32 @@ export class SubagentManager {
|
|
|
578
620
|
private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
|
|
579
621
|
const key = `${runId}:${taskId}`;
|
|
580
622
|
return new Promise<string>((resolve) => {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
this.pendingReplies.delete(key);
|
|
585
|
-
resolve(
|
|
586
|
-
"The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
|
|
587
|
-
);
|
|
588
|
-
}, timeoutMs)
|
|
589
|
-
: undefined;
|
|
590
|
-
this.pendingReplies.set(key, {
|
|
623
|
+
// Identity-tagged: two asks from one child must not delete each other's
|
|
624
|
+
// entry (the loser would hang until its own timer).
|
|
625
|
+
const entry: PendingReply = {
|
|
591
626
|
resolve: (message) => {
|
|
592
627
|
if (timer) clearTimeout(timer);
|
|
628
|
+
if (this.pendingReplies.get(key) === entry) this.pendingReplies.delete(key);
|
|
593
629
|
resolve(message);
|
|
594
630
|
},
|
|
595
|
-
}
|
|
631
|
+
};
|
|
632
|
+
const timer =
|
|
633
|
+
timeoutMs > 0
|
|
634
|
+
? setTimeout(
|
|
635
|
+
() =>
|
|
636
|
+
entry.resolve(
|
|
637
|
+
"The parent did not answer in time. Proceed autonomously with your best judgment and state the assumption you made in your final answer.",
|
|
638
|
+
),
|
|
639
|
+
timeoutMs,
|
|
640
|
+
)
|
|
641
|
+
: undefined;
|
|
642
|
+
this.pendingReplies.set(key, entry);
|
|
596
643
|
});
|
|
597
644
|
}
|
|
598
645
|
deliverReply(runId: string, taskId: string, message: string): boolean {
|
|
599
646
|
const pending = this.pendingReplies.get(`${runId}:${taskId}`);
|
|
600
647
|
if (!pending) return false;
|
|
601
|
-
|
|
602
|
-
pending.resolve(message);
|
|
648
|
+
pending.resolve(message); // clears its own entry + timer
|
|
603
649
|
return true;
|
|
604
650
|
}
|
|
605
651
|
|
|
@@ -687,6 +733,7 @@ export class SubagentManager {
|
|
|
687
733
|
// is authoritative — body = system prompt, frontmatter model/tools win over
|
|
688
734
|
// inline. No match → inline on-demand definition as usual.
|
|
689
735
|
const file = resolveAgentFile(input.agent, input.task, task.cwd, getAgentDir());
|
|
736
|
+
if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
|
|
690
737
|
const prompt = file?.body ?? input.prompt?.trim();
|
|
691
738
|
const thinking = input.thinking;
|
|
692
739
|
// Trust boundary: a file can NARROW the toolset (intersect with the leader's
|
|
@@ -727,11 +774,14 @@ export class SubagentManager {
|
|
|
727
774
|
// non-git repos fall back to in-place. Created BEFORE session start so the
|
|
728
775
|
// child's cwd + AGENTS.md context chain are the worktree's.
|
|
729
776
|
let wt: Worktree | undefined;
|
|
777
|
+
let isolationReason: string | undefined;
|
|
730
778
|
if (canWrite) {
|
|
731
779
|
try {
|
|
732
780
|
wt = createWorktree(task.cwd, run.id, task.id);
|
|
733
|
-
|
|
781
|
+
if (!wt) isolationReason = "not a git repository";
|
|
782
|
+
} catch (err) {
|
|
734
783
|
wt = undefined; // git failure → in-place
|
|
784
|
+
isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
735
785
|
}
|
|
736
786
|
}
|
|
737
787
|
// Map a per-task cwd subpath into the worktree so relative paths stay correct.
|
|
@@ -741,14 +791,28 @@ export class SubagentManager {
|
|
|
741
791
|
let childCwd = wt?.path ?? task.cwd;
|
|
742
792
|
if (wt) {
|
|
743
793
|
const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
|
|
744
|
-
if (rel.startsWith(
|
|
794
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) {
|
|
745
795
|
removeWorktree(wt);
|
|
746
796
|
wt = undefined;
|
|
747
797
|
childCwd = task.cwd;
|
|
798
|
+
isolationReason = "task cwd is outside the repository";
|
|
748
799
|
} else if (rel && rel !== ".") {
|
|
749
800
|
childCwd = join(wt.path, rel);
|
|
801
|
+
// The subpath may be gitignored/untracked, so it won't exist in a fresh
|
|
802
|
+
// checkout — create it rather than fail session start.
|
|
803
|
+
try {
|
|
804
|
+
mkdirSync(childCwd, { recursive: true });
|
|
805
|
+
} catch {
|
|
806
|
+
childCwd = wt.path;
|
|
807
|
+
}
|
|
750
808
|
}
|
|
751
809
|
}
|
|
810
|
+
if (canWrite) {
|
|
811
|
+
// Never let isolation lapse quietly: the leader must know its edits landed
|
|
812
|
+
// straight in the working tree with no branch to review.
|
|
813
|
+
task.isolation = wt ? "worktree" : "in-place";
|
|
814
|
+
task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
|
|
815
|
+
}
|
|
752
816
|
if (wt) {
|
|
753
817
|
claimWorktree(wt); // pid marker: another pi session must not reap this
|
|
754
818
|
this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
|
|
@@ -891,17 +955,12 @@ export class SubagentManager {
|
|
|
891
955
|
// until the branch is merged — cleanupMerged removes both then.
|
|
892
956
|
// Commit/diff failures must NOT downgrade a completed task or destroy
|
|
893
957
|
// its work: the error is reported, the status stays completed.
|
|
958
|
+
let committed: "committed" | "empty" | undefined;
|
|
894
959
|
try {
|
|
895
|
-
commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
run,
|
|
900
|
-
task,
|
|
901
|
-
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
902
|
-
ctx,
|
|
903
|
-
onUpdate,
|
|
904
|
-
);
|
|
960
|
+
committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
|
|
961
|
+
// Only a real commit is worth a branch: an empty one would send the
|
|
962
|
+
// leader off to review and merge nothing.
|
|
963
|
+
keepWorktreeDir = committed === "committed";
|
|
905
964
|
} catch (commitErr) {
|
|
906
965
|
// Never drop a checkout whose work isn't on the branch — it would be
|
|
907
966
|
// unreachable once the base-tip branch is reaped as "merged".
|
|
@@ -911,12 +970,36 @@ export class SubagentManager {
|
|
|
911
970
|
task,
|
|
912
971
|
{
|
|
913
972
|
branch: wt.branch,
|
|
914
|
-
|
|
973
|
+
worktreeError: `commit failed (uncommitted changes remain in ${wt.path}): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`,
|
|
915
974
|
},
|
|
916
975
|
ctx,
|
|
917
976
|
onUpdate,
|
|
918
977
|
);
|
|
919
978
|
}
|
|
979
|
+
// Diff separately: a diff failure must not be reported as a lost commit.
|
|
980
|
+
if (committed === "committed") {
|
|
981
|
+
try {
|
|
982
|
+
const { stat, files } = branchDiff(wt);
|
|
983
|
+
this.updateTask(
|
|
984
|
+
run,
|
|
985
|
+
task,
|
|
986
|
+
{ branch: wt.branch, diffStat: stat || undefined, changedFiles: files.length ? files : undefined },
|
|
987
|
+
ctx,
|
|
988
|
+
onUpdate,
|
|
989
|
+
);
|
|
990
|
+
} catch (diffErr) {
|
|
991
|
+
this.updateTask(
|
|
992
|
+
run,
|
|
993
|
+
task,
|
|
994
|
+
{
|
|
995
|
+
branch: wt.branch,
|
|
996
|
+
worktreeError: `committed, but the diff could not be read: ${diffErr instanceof Error ? diffErr.message : String(diffErr)}`,
|
|
997
|
+
},
|
|
998
|
+
ctx,
|
|
999
|
+
onUpdate,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
920
1003
|
}
|
|
921
1004
|
}
|
|
922
1005
|
} catch (err) {
|
|
@@ -957,12 +1040,16 @@ export class SubagentManager {
|
|
|
957
1040
|
// dir — dropping it would make the work unreachable.
|
|
958
1041
|
if (wt && task.status !== "completed") {
|
|
959
1042
|
await new Promise((r) => setTimeout(r, 250));
|
|
1043
|
+
let partial: "committed" | "empty" | undefined;
|
|
960
1044
|
try {
|
|
961
|
-
commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
1045
|
+
partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
|
|
962
1046
|
} catch {
|
|
963
|
-
keepWorktreeDir = true;
|
|
1047
|
+
keepWorktreeDir = true; // work exists only in the dir — keep it
|
|
1048
|
+
}
|
|
1049
|
+
// A branch is only worth reporting when it actually carries something.
|
|
1050
|
+
if (partial === "committed" || keepWorktreeDir) {
|
|
1051
|
+
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
964
1052
|
}
|
|
965
|
-
this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
|
|
966
1053
|
}
|
|
967
1054
|
if (wt && !keepWorktreeDir) removeWorktree(wt);
|
|
968
1055
|
// Released only after the dir is gone: while it exists, the branch must stay
|
|
@@ -1122,13 +1209,14 @@ export class SubagentManager {
|
|
|
1122
1209
|
// Broken-upstream tasks are detected by the scheduler; mark them after the wave.
|
|
1123
1210
|
for (const s of skipped) {
|
|
1124
1211
|
const task = run.tasks.find((t) => t.id === s.id);
|
|
1125
|
-
if (task) {
|
|
1212
|
+
if (task && !TERMINAL.includes(task.status)) {
|
|
1126
1213
|
this.updateTask(
|
|
1127
1214
|
run,
|
|
1128
1215
|
task,
|
|
1129
1216
|
{
|
|
1130
1217
|
status: "aborted",
|
|
1131
|
-
|
|
1218
|
+
// Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
|
|
1219
|
+
error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
|
|
1132
1220
|
endedAt: Date.now(),
|
|
1133
1221
|
},
|
|
1134
1222
|
ctx,
|
|
@@ -1136,6 +1224,19 @@ export class SubagentManager {
|
|
|
1136
1224
|
);
|
|
1137
1225
|
}
|
|
1138
1226
|
}
|
|
1227
|
+
// Belt and braces: the wave loop breaks out when no frontier is ready, which
|
|
1228
|
+
// would otherwise leave tasks queued inside a terminal run — hasActiveRun()
|
|
1229
|
+
// then never clears and the widget stays pinned.
|
|
1230
|
+
for (const task of run.tasks) {
|
|
1231
|
+
if (TERMINAL.includes(task.status)) continue;
|
|
1232
|
+
this.updateTask(
|
|
1233
|
+
run,
|
|
1234
|
+
task,
|
|
1235
|
+
{ status: "aborted", error: task.error || "Never ran: no runnable wave", endedAt: Date.now() },
|
|
1236
|
+
ctx,
|
|
1237
|
+
onUpdate,
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1139
1240
|
|
|
1140
1241
|
const failed = run.tasks.some((t) => t.status === "failed");
|
|
1141
1242
|
const aborted = run.tasks.some((t) => t.status === "aborted") || Boolean(signal?.aborted);
|
|
@@ -1290,15 +1391,37 @@ export class SubagentManager {
|
|
|
1290
1391
|
}
|
|
1291
1392
|
|
|
1292
1393
|
/** Child→leader messages collected while the parent is parked in await_subagent. */
|
|
1293
|
-
|
|
1394
|
+
/** Every awaiter parked on a run — a SET, so two concurrent awaits can't
|
|
1395
|
+
* overwrite each other's buffer and silently swallow one side's intercom. */
|
|
1396
|
+
private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
|
|
1294
1397
|
|
|
1295
1398
|
/** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
|
|
1296
1399
|
private collectParked(runId: string, msg: ParkedMsg): boolean {
|
|
1297
|
-
const
|
|
1298
|
-
if (!
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1400
|
+
const parked = this.parked.get(runId);
|
|
1401
|
+
if (!parked || parked.size === 0) return false;
|
|
1402
|
+
let delivered = false;
|
|
1403
|
+
for (const p of parked) {
|
|
1404
|
+
if (p.msgs.length < PARKED_MSG_CAP) {
|
|
1405
|
+
p.msgs.push(msg);
|
|
1406
|
+
delivered = true;
|
|
1407
|
+
} else if (msg.kind === "ask") {
|
|
1408
|
+
// An unanswered ask blocks a child for 10 minutes — it must never be the
|
|
1409
|
+
// message that gets dropped by the cap. Evict the oldest NON-ask first
|
|
1410
|
+
// (asks already block children; displacing one hangs the earlier asker).
|
|
1411
|
+
const drop = p.msgs.findIndex((m) => m.kind !== "ask");
|
|
1412
|
+
if (drop !== -1) {
|
|
1413
|
+
p.msgs.splice(drop, 1);
|
|
1414
|
+
p.msgs.push(msg);
|
|
1415
|
+
} else {
|
|
1416
|
+
p.msgs[p.msgs.length - 1] = msg; // all asks — overwrite the oldest ask
|
|
1417
|
+
}
|
|
1418
|
+
delivered = true;
|
|
1419
|
+
}
|
|
1420
|
+
p.wake(); // resolve the parked await early — the leader breathes on every message
|
|
1421
|
+
}
|
|
1422
|
+
// Not buffered anywhere → report undelivered so the caller falls back to a
|
|
1423
|
+
// followUp notice instead of assuming the leader saw it.
|
|
1424
|
+
return delivered;
|
|
1302
1425
|
}
|
|
1303
1426
|
|
|
1304
1427
|
awaitRun(
|
|
@@ -1307,8 +1430,12 @@ export class SubagentManager {
|
|
|
1307
1430
|
): Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined> {
|
|
1308
1431
|
const run = this.runs.get(runId);
|
|
1309
1432
|
if (!run) return Promise.resolve(undefined);
|
|
1433
|
+
let entry: { msgs: ParkedMsg[]; wake: () => void } | undefined;
|
|
1310
1434
|
const finish = (): void => {
|
|
1311
|
-
this.parked.
|
|
1435
|
+
const parked = this.parked.get(runId);
|
|
1436
|
+
if (!parked || !entry) return;
|
|
1437
|
+
parked.delete(entry); // only our own park — a sibling await keeps receiving
|
|
1438
|
+
if (parked.size === 0) this.parked.delete(runId);
|
|
1312
1439
|
};
|
|
1313
1440
|
if (TERMINAL.includes(run.status)) {
|
|
1314
1441
|
run.awaited = true;
|
|
@@ -1331,17 +1458,28 @@ export class SubagentManager {
|
|
|
1331
1458
|
waiters.add(waiter);
|
|
1332
1459
|
// A child→leader message while parked wakes the wait: the leader gets it
|
|
1333
1460
|
// IN the await result, no steering queue, no turn boundary needed.
|
|
1334
|
-
|
|
1461
|
+
entry = { msgs, wake: () => waiter(cloneRun(run)) };
|
|
1462
|
+
let parked = this.parked.get(runId);
|
|
1463
|
+
if (!parked) {
|
|
1464
|
+
parked = new Set();
|
|
1465
|
+
this.parked.set(runId, parked);
|
|
1466
|
+
}
|
|
1467
|
+
parked.add(entry);
|
|
1335
1468
|
});
|
|
1336
1469
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
1470
|
+
let timedOut = false;
|
|
1337
1471
|
return Promise.race([
|
|
1338
1472
|
settled.then((r) => {
|
|
1339
1473
|
finish();
|
|
1340
|
-
|
|
1474
|
+
// Only mark awaited when this call actually hands the run back to the
|
|
1475
|
+
// leader. A slice that already timed out is abandoned — setting it here
|
|
1476
|
+
// would suppress the run's completion notice the leader still needs.
|
|
1477
|
+
if (!timedOut) run.awaited = true;
|
|
1341
1478
|
return { run: r, intercom: msgs };
|
|
1342
1479
|
}),
|
|
1343
1480
|
new Promise<{ run: RunSnapshot | undefined; intercom: ParkedMsg[] } | undefined>((resolve) => {
|
|
1344
1481
|
const timer = setTimeout(() => {
|
|
1482
|
+
timedOut = true;
|
|
1345
1483
|
finish();
|
|
1346
1484
|
resolve(this.runs.get(runId) ? { run: cloneRun(this.runs.get(runId)!), intercom: msgs } : undefined);
|
|
1347
1485
|
}, timeoutMs);
|
package/src/types.ts
CHANGED
|
@@ -41,10 +41,21 @@ export interface TaskSnapshot {
|
|
|
41
41
|
usage: UsageStats;
|
|
42
42
|
/** Sibling addresses for intercom tools (send_agent_message targets). */
|
|
43
43
|
roster?: string;
|
|
44
|
+
/** Path of the agent file that drove this child (inline definition otherwise).
|
|
45
|
+
* Surfaced so the leader can audit which file took over — a description-only
|
|
46
|
+
* match must never be invisible. */
|
|
47
|
+
agentFile?: string;
|
|
44
48
|
/** Git worktree isolation (write agents): branch + diff of the child's changes. */
|
|
45
49
|
branch?: string;
|
|
46
50
|
diffStat?: string;
|
|
47
51
|
changedFiles?: string[];
|
|
52
|
+
/** How a write child's edits were applied. "in-place" means NO branch: the
|
|
53
|
+
* changes are already in the leader's tree — always surfaced, never silent. */
|
|
54
|
+
isolation?: "worktree" | "in-place";
|
|
55
|
+
isolationReason?: string;
|
|
56
|
+
/** Worktree commit/diff trouble. Kept apart from `error` so a completed task
|
|
57
|
+
* still reports its answer. */
|
|
58
|
+
worktreeError?: string;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
export interface RunSnapshot {
|
package/src/worktree.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
-
import {
|
|
14
|
+
import { hostname, uptime } from "node:os";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
15
16
|
|
|
16
17
|
export interface Worktree {
|
|
17
18
|
root: string; // repo root (main tree)
|
|
@@ -22,13 +23,33 @@ export interface Worktree {
|
|
|
22
23
|
|
|
23
24
|
const BRANCH_PREFIX = "subagents/";
|
|
24
25
|
|
|
26
|
+
/** Identity + signing fallbacks: a machine without user.email (CI, fresh box) or
|
|
27
|
+
* with commit.gpgsign set must not fail — or worse, block on a passphrase prompt. */
|
|
28
|
+
const COMMIT_CONFIG = ["-c", "commit.gpgsign=false", "-c", "user.name=pi subagent", "-c", "user.email=subagent@local"];
|
|
29
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
30
|
+
const GIT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
31
|
+
|
|
25
32
|
function git(root: string, args: string[]): string {
|
|
26
|
-
return
|
|
33
|
+
return gitRaw(root, args).trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Untrimmed git output — required for -z parsing, where a path may end in a space. */
|
|
37
|
+
function gitRaw(root: string, args: string[]): string {
|
|
38
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
timeout: GIT_TIMEOUT_MS,
|
|
41
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
42
|
+
});
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
/** Run git directly inside a directory (worktree ops). */
|
|
30
46
|
function gitIn(dir: string, args: string[]): string {
|
|
31
|
-
return execFileSync("git", [...args], {
|
|
47
|
+
return execFileSync("git", [...args], {
|
|
48
|
+
cwd: dir,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
timeout: GIT_TIMEOUT_MS,
|
|
51
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
52
|
+
}).trim();
|
|
32
53
|
}
|
|
33
54
|
|
|
34
55
|
function gitOk(root: string, args: string[]): boolean {
|
|
@@ -67,18 +88,19 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
67
88
|
// --git-common-dir, not "<root>/.git": inside a linked worktree or a submodule
|
|
68
89
|
// `.git` is a FILE, and joining it would make `worktree add` fail (silently
|
|
69
90
|
// dropping isolation).
|
|
70
|
-
|
|
91
|
+
const container = subagentsDir(root);
|
|
92
|
+
if (!container) return undefined;
|
|
71
93
|
let base: string;
|
|
72
94
|
try {
|
|
73
|
-
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
74
|
-
container = join(common, "subagents");
|
|
75
95
|
base = git(root, ["rev-parse", "HEAD"]); // SHA — detached HEAD stays correct
|
|
76
96
|
} catch {
|
|
77
97
|
return undefined; // broken repo — fall back to in-place
|
|
78
98
|
}
|
|
79
99
|
const path = join(container, runId, taskId);
|
|
80
100
|
const branch = `${BRANCH_PREFIX}${runId}/${taskId}`;
|
|
81
|
-
|
|
101
|
+
// Branch from the recorded SHA, not "HEAD" — a concurrent commit in the main
|
|
102
|
+
// tree between the two would otherwise skew every later diff against base.
|
|
103
|
+
git(root, ["worktree", "add", "-b", branch, path, base]);
|
|
82
104
|
// Deps follow the child into the worktree; anything else the task needs is
|
|
83
105
|
// project content already checked out there.
|
|
84
106
|
const nm = join(root, "node_modules");
|
|
@@ -100,14 +122,27 @@ export function createWorktree(cwd: string, runId: string, taskId: string): Work
|
|
|
100
122
|
* work would become unreachable once the base-tip branch is reaped as merged).
|
|
101
123
|
*/
|
|
102
124
|
export function commitWorktree(wt: Worktree, message: string): "committed" | "empty" {
|
|
103
|
-
return commitIn(wt.path, message);
|
|
125
|
+
return commitIn(wt.path, message, wt.branch);
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
function commitIn(dir: string, message: string): "committed" | "empty" {
|
|
128
|
+
function commitIn(dir: string, message: string, expectBranch?: string): "committed" | "empty" {
|
|
129
|
+
// A child that detached HEAD or switched branches would commit somewhere the
|
|
130
|
+
// leader is never told about — refuse rather than report a branch without the work.
|
|
131
|
+
if (expectBranch) {
|
|
132
|
+
// `symbolic-ref` EXITS NON-ZERO on a detached HEAD — catch it rather than
|
|
133
|
+
// letting the raw git failure masquerade as a commit error.
|
|
134
|
+
let head = "detached";
|
|
135
|
+
try {
|
|
136
|
+
head = gitIn(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"]) || "detached";
|
|
137
|
+
} catch {
|
|
138
|
+
head = "detached";
|
|
139
|
+
}
|
|
140
|
+
if (head !== expectBranch) throw new Error(`worktree HEAD is "${head}", expected ${expectBranch}`);
|
|
141
|
+
}
|
|
107
142
|
// Exclude the root dep symlink and any nested node_modules the child created.
|
|
108
143
|
gitIn(dir, ["add", "-A", "--", ".", ":(exclude)node_modules", ":(exclude,glob)**/node_modules/**"]);
|
|
109
144
|
if (gitIn(dir, ["diff", "--cached", "--name-only"]).length === 0) return "empty";
|
|
110
|
-
gitIn(dir, ["commit", "-m", message, "--no-verify"]);
|
|
145
|
+
gitIn(dir, [...COMMIT_CONFIG, "commit", "-m", message, "--no-verify"]);
|
|
111
146
|
return "committed";
|
|
112
147
|
}
|
|
113
148
|
|
|
@@ -134,21 +169,33 @@ export function removeByBranch(cwd: string, branch: string): void {
|
|
|
134
169
|
if (container) dropDir(root, join(container, branch.slice(BRANCH_PREFIX.length)));
|
|
135
170
|
}
|
|
136
171
|
|
|
137
|
-
/** `<git-common-dir>/subagents` — where our worktrees live for this repo.
|
|
172
|
+
/** `<git-common-dir>/subagents` — where our worktrees live for this repo.
|
|
173
|
+
* `--path-format` needs git ≥ 2.31; fall back to resolving the relative form. */
|
|
138
174
|
function subagentsDir(root: string): string | undefined {
|
|
139
175
|
try {
|
|
140
176
|
return join(git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]), "subagents");
|
|
141
177
|
} catch {
|
|
142
|
-
|
|
178
|
+
try {
|
|
179
|
+
return join(resolve(root, git(root, ["rev-parse", "--git-common-dir"])), "subagents");
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
143
183
|
}
|
|
144
184
|
}
|
|
145
185
|
|
|
186
|
+
/** Marker path lives BESIDE the checkout, never inside it — a file in the worktree
|
|
187
|
+
* would be staged by `add -A`, committed into the branch, and merged into main. */
|
|
188
|
+
function ownerFile(path: string): string {
|
|
189
|
+
return `${path}.owner`;
|
|
190
|
+
}
|
|
191
|
+
|
|
146
192
|
function dropDir(root: string, path: string): void {
|
|
147
193
|
try {
|
|
148
194
|
git(root, ["worktree", "remove", "--force", path]);
|
|
149
195
|
} catch {
|
|
150
196
|
if (existsSync(path)) rmSync(path, { recursive: true, force: true });
|
|
151
197
|
}
|
|
198
|
+
rmSync(ownerFile(path), { force: true }); // marker lives beside the dir
|
|
152
199
|
prune(root);
|
|
153
200
|
}
|
|
154
201
|
|
|
@@ -161,9 +208,10 @@ function prune(root: string): void {
|
|
|
161
208
|
}
|
|
162
209
|
}
|
|
163
210
|
|
|
164
|
-
/** Is this branch checked out
|
|
211
|
+
/** Is this branch checked out right now? "Unknown" counts as YES (never delete blind). */
|
|
165
212
|
function isCheckedOut(root: string, branch: string): boolean {
|
|
166
|
-
|
|
213
|
+
const branches = worktreeBranches(root);
|
|
214
|
+
return branches === undefined || branches.includes(branch);
|
|
167
215
|
}
|
|
168
216
|
|
|
169
217
|
/**
|
|
@@ -187,29 +235,29 @@ export function cleanupMerged(root: string, opts: { skipBranches?: Set<string>;
|
|
|
187
235
|
if (isCheckedOut(root, branch)) continue; // fresh re-check, not a stale snapshot
|
|
188
236
|
const path = join(container, branch.slice(BRANCH_PREFIX.length));
|
|
189
237
|
if (existsSync(path) && ownerAlive(path)) continue; // another session's live checkout
|
|
238
|
+
// Branch FIRST: `-d` refuses anything not truly merged, so a stale "merged"
|
|
239
|
+
// listing can no longer cost us a checkout that still holds work.
|
|
240
|
+
if (!gitOk(root, ["branch", "-d", branch])) continue;
|
|
190
241
|
if (existsSync(path)) {
|
|
191
242
|
try {
|
|
192
243
|
git(root, ["worktree", "remove", "--force", path]);
|
|
193
244
|
} catch {
|
|
194
|
-
|
|
245
|
+
/* branch is gone; a leftover dir is swept later */
|
|
195
246
|
}
|
|
196
247
|
}
|
|
197
|
-
|
|
248
|
+
rmSync(ownerFile(path), { force: true });
|
|
249
|
+
cleaned += 1;
|
|
198
250
|
}
|
|
199
251
|
prune(root);
|
|
200
252
|
return cleaned;
|
|
201
253
|
}
|
|
202
254
|
|
|
203
|
-
/** Branch names currently checked out in any worktree
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.map((l) => l.slice("branch refs/heads/".length));
|
|
210
|
-
} catch {
|
|
211
|
-
return [];
|
|
212
|
-
}
|
|
255
|
+
/** Branch names currently checked out in any worktree, or undefined when git
|
|
256
|
+
* couldn't be asked — callers MUST treat undefined as "unknown", never as "none",
|
|
257
|
+
* or they will happily delete live checkouts. */
|
|
258
|
+
function worktreeBranches(root: string): string[] | undefined {
|
|
259
|
+
const listing = worktreeListing(root);
|
|
260
|
+
return listing?.filter((l) => l.startsWith("branch ")).map((l) => l.slice("branch refs/heads/".length));
|
|
213
261
|
}
|
|
214
262
|
|
|
215
263
|
/**
|
|
@@ -221,14 +269,30 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
|
|
|
221
269
|
root = realpathSync(root);
|
|
222
270
|
const sub = subagentsDir(root);
|
|
223
271
|
if (!sub || !existsSync(sub)) return 0;
|
|
272
|
+
const registered = worktreePaths(root);
|
|
273
|
+
if (!registered) return 0; // listing failed — touch nothing
|
|
224
274
|
let reaped = 0;
|
|
225
|
-
for (const path of
|
|
275
|
+
for (const path of registered) {
|
|
226
276
|
if (!isInside(path, sub)) continue; // not ours
|
|
227
277
|
if (isLive(path)) continue; // another pi session owns it
|
|
228
278
|
try {
|
|
229
279
|
commitIn(path, "subagent (recovered after interrupted session)");
|
|
230
280
|
} catch {
|
|
231
|
-
|
|
281
|
+
// A dir git refuses to read (half-created by a timed-out worktree add,
|
|
282
|
+
// or corrupted) holds no recoverable work — drop it instead of retrying
|
|
283
|
+
// forever. The "never drop uncommitted work" rule protects readable dirs.
|
|
284
|
+
try {
|
|
285
|
+
execFileSync("git", ["-C", path, "rev-parse", "--git-dir"], {
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
timeout: GIT_TIMEOUT_MS,
|
|
288
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
289
|
+
});
|
|
290
|
+
continue; // git still reads it — legitimate work, keep
|
|
291
|
+
} catch {
|
|
292
|
+
dropDir(root, path);
|
|
293
|
+
reaped += 1;
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
232
296
|
}
|
|
233
297
|
dropDir(root, path);
|
|
234
298
|
reaped += 1;
|
|
@@ -242,23 +306,46 @@ export function reapDeadWorktrees(root: string, isLive: (path: string) => boolea
|
|
|
242
306
|
*/
|
|
243
307
|
export function claimWorktree(wt: Worktree): void {
|
|
244
308
|
try {
|
|
245
|
-
writeFileSync(
|
|
309
|
+
writeFileSync(
|
|
310
|
+
ownerFile(wt.path),
|
|
311
|
+
JSON.stringify({ pid: process.pid, host: hostname(), boot: bootId(), at: Date.now() }),
|
|
312
|
+
);
|
|
246
313
|
} catch {
|
|
247
314
|
/* best-effort: worst case another session reaps it after a crash */
|
|
248
315
|
}
|
|
249
316
|
}
|
|
250
317
|
|
|
251
|
-
/**
|
|
318
|
+
/**
|
|
319
|
+
* True when the worktree is claimed by a process that still exists HERE.
|
|
320
|
+
* Guards against pid reuse across reboots (boot id) and other hosts (hostname);
|
|
321
|
+
* EPERM means the pid exists under another user — alive, not reapable.
|
|
322
|
+
*/
|
|
252
323
|
export function ownerAlive(path: string): boolean {
|
|
324
|
+
let marker: { pid?: number; host?: string; boot?: string };
|
|
253
325
|
try {
|
|
254
|
-
|
|
255
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
256
|
-
if (pid === process.pid) return true;
|
|
257
|
-
process.kill(pid, 0); // throws ESRCH when the owner is gone
|
|
258
|
-
return true;
|
|
326
|
+
marker = JSON.parse(readFileSync(ownerFile(path), "utf8"));
|
|
259
327
|
} catch {
|
|
260
|
-
return false;
|
|
328
|
+
return false; // no marker (or unreadable) → nobody claims it
|
|
261
329
|
}
|
|
330
|
+
const pid = marker.pid;
|
|
331
|
+
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
|
332
|
+
if (marker.host !== hostname()) return true; // another machine's checkout — never ours to reap
|
|
333
|
+
if (marker.boot !== bootId()) return false; // pre-reboot pid: reuse is near-certain
|
|
334
|
+
if (pid === process.pid) return true;
|
|
335
|
+
try {
|
|
336
|
+
process.kill(pid, 0);
|
|
337
|
+
return true;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return (err as NodeJS.ErrnoException)?.code === "EPERM"; // exists, other user
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Stable per-boot id, so a recycled pid from before a reboot can't look alive.
|
|
344
|
+
* Compute ONE floor on the expressed seconds, not two — separate floors of
|
|
345
|
+
* walltime and uptime flip by ±1 around integer boundaries and would read a
|
|
346
|
+
* live marker as dead on a cross-second read. */
|
|
347
|
+
function bootId(): string {
|
|
348
|
+
return String(Math.floor((Date.now() - uptime() * 1000) / 1000));
|
|
262
349
|
}
|
|
263
350
|
|
|
264
351
|
/**
|
|
@@ -271,26 +358,49 @@ export function sweepStale(root: string): void {
|
|
|
271
358
|
const sub = subagentsDir(root);
|
|
272
359
|
if (!sub || !existsSync(sub)) return;
|
|
273
360
|
const registered = worktreePaths(root);
|
|
361
|
+
// Unknown registration = every dir might be live. Deleting here would be the
|
|
362
|
+
// single most destructive thing this module can do; bail instead.
|
|
363
|
+
if (!registered) return;
|
|
364
|
+
// Empty run dirs are litter — a run dir holds nothing but its task dirs.
|
|
365
|
+
for (const runDir of readdirSync(sub, { withFileTypes: true })) {
|
|
366
|
+
if (!runDir.isDirectory()) continue;
|
|
367
|
+
const runPath = join(sub, runDir.name);
|
|
368
|
+
try {
|
|
369
|
+
if (readdirSync(runPath).length === 0) rmSync(runPath, { recursive: true, force: true });
|
|
370
|
+
} catch {
|
|
371
|
+
/* skip */
|
|
372
|
+
}
|
|
373
|
+
}
|
|
274
374
|
for (const runDir of readDirs(sub)) {
|
|
275
375
|
for (const taskDir of readDirs(join(sub, runDir))) {
|
|
276
376
|
const dir = join(sub, runDir, taskDir);
|
|
277
377
|
if (registered.some((p) => samePath(p, dir))) continue; // live/registered worktree
|
|
278
378
|
if (ownerAlive(dir)) continue; // claimed by a running session
|
|
279
379
|
rmSync(dir, { recursive: true, force: true });
|
|
380
|
+
rmSync(ownerFile(dir), { force: true });
|
|
280
381
|
}
|
|
281
382
|
}
|
|
282
383
|
prune(root);
|
|
283
384
|
}
|
|
284
385
|
|
|
285
|
-
/** Registered worktree paths
|
|
286
|
-
function worktreePaths(root: string): string[] {
|
|
386
|
+
/** Registered worktree paths, or undefined when the listing failed (see above). */
|
|
387
|
+
function worktreePaths(root: string): string[] | undefined {
|
|
388
|
+
const listing = worktreeListing(root);
|
|
389
|
+
return listing?.filter((l) => l.startsWith("worktree ")).map((l) => l.slice("worktree ".length));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** `worktree list --porcelain -z`; `-z` needs git ≥ 2.36, so fall back to the
|
|
393
|
+
* newline form (which C-quotes exotic paths — those simply won't match, and a
|
|
394
|
+
* non-match is safe: it only ever means "treat as live"). */
|
|
395
|
+
function worktreeListing(root: string): string[] | undefined {
|
|
287
396
|
try {
|
|
288
|
-
return
|
|
289
|
-
.split("\0")
|
|
290
|
-
.filter((l) => l.startsWith("worktree "))
|
|
291
|
-
.map((l) => l.slice("worktree ".length));
|
|
397
|
+
return gitRaw(root, ["worktree", "list", "--porcelain", "-z"]).split("\0").filter(Boolean);
|
|
292
398
|
} catch {
|
|
293
|
-
|
|
399
|
+
try {
|
|
400
|
+
return git(root, ["worktree", "list", "--porcelain"]).split("\n").filter(Boolean);
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined; // unknown — callers must bail out
|
|
403
|
+
}
|
|
294
404
|
}
|
|
295
405
|
}
|
|
296
406
|
|