@ferris1225/pi-subagents 4.3.4 → 4.3.6
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 +36 -0
- package/README.md +103 -83
- package/agents/artisan.md +0 -1
- package/agents/steward.md +1 -2
- package/{src/index.ts → index.ts} +19 -19
- package/package.json +4 -3
- package/src/{config.ts → configuration/config.ts} +19 -24
- package/src/configuration/setup.ts +375 -0
- package/src/configuration/ui.ts +245 -0
- package/src/{agents.ts → delegation/agents.ts} +3 -3
- package/src/{dispatch.ts → delegation/dispatch.ts} +12 -18
- package/src/{prompt.ts → delegation/prompt.ts} +5 -9
- package/src/{background.ts → execution/background.ts} +3 -6
- package/src/execution/rpc-control.ts +235 -0
- package/src/{rpc-run.ts → execution/rpc-run.ts} +35 -225
- package/src/{session-fork.ts → execution/session-fork.ts} +1 -1
- package/src/{spawn.ts → execution/spawn.ts} +10 -8
- package/src/isolation/git-command.ts +147 -0
- package/src/isolation/managed-paths.ts +145 -0
- package/src/{recovery.ts → isolation/recovery.ts} +42 -13
- package/src/{temp-hygiene.ts → isolation/temp-hygiene.ts} +7 -7
- package/src/{worktree.ts → isolation/worktree.ts} +11 -158
- package/src/{completion.ts → lifecycle/completion.ts} +2 -2
- package/src/{durable.ts → lifecycle/durable.ts} +101 -27
- package/src/{runtime.ts → lifecycle/runtime.ts} +12 -12
- package/src/{thread-lifecycle.ts → lifecycle/thread-lifecycle.ts} +25 -519
- package/src/lifecycle/thread-restore.ts +253 -0
- package/src/lifecycle/thread-shared.ts +269 -0
- package/src/{tools.ts → lifecycle/tools.ts} +51 -13
- package/src/{announcements.ts → presentation/announcements.ts} +4 -4
- package/src/{format.ts → presentation/format.ts} +3 -3
- package/src/{monitor.ts → presentation/monitor.ts} +2 -2
- package/src/{widget.ts → presentation/widget.ts} +1 -1
- package/agents/sentinel.md +0 -16
- package/src/setup.ts +0 -344
- package/src/ui.ts +0 -160
- /package/src/{models.ts → configuration/models.ts} +0 -0
- /package/src/{status.ts → presentation/status.ts} +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/** Durable restoration and startup hygiene for logical sub-agent threads. */
|
|
2
|
+
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { type ThinkingLevel } from "../configuration/config.ts";
|
|
5
|
+
import {
|
|
6
|
+
isCurrentBoot,
|
|
7
|
+
pruneStaleProjectRoots,
|
|
8
|
+
pruneThreadRecords,
|
|
9
|
+
readThreadRecords,
|
|
10
|
+
referencedDurablePaths,
|
|
11
|
+
removeThreadRecord,
|
|
12
|
+
restoredResultFromSummary,
|
|
13
|
+
type ThreadRecord,
|
|
14
|
+
} from "./durable.ts";
|
|
15
|
+
import { failedStartResult } from "../presentation/format.ts";
|
|
16
|
+
import { monitor } from "../presentation/monitor.ts";
|
|
17
|
+
import { emptyUsage } from "../execution/rpc-control.ts";
|
|
18
|
+
import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
|
|
19
|
+
import {
|
|
20
|
+
getSubagentsRoot,
|
|
21
|
+
RpcRunControl,
|
|
22
|
+
sessionExists,
|
|
23
|
+
sweepProjectResultArtifacts,
|
|
24
|
+
type SingleResult,
|
|
25
|
+
} from "../execution/spawn.ts";
|
|
26
|
+
import { isProcessAlive, killProcessTree, sweepProjectDurableDirs, sweepProjectTempDirs } from "../isolation/temp-hygiene.ts";
|
|
27
|
+
import { readRecoveryRecords, referencedRecoveryPaths } from "../isolation/recovery.ts";
|
|
28
|
+
import {
|
|
29
|
+
isPathInside,
|
|
30
|
+
restoreWorktreeIsolation,
|
|
31
|
+
worktreeGroupId,
|
|
32
|
+
type WorktreeIsolation,
|
|
33
|
+
} from "../isolation/worktree.ts";
|
|
34
|
+
import { installThreadLifecycle } from "./thread-lifecycle.ts";
|
|
35
|
+
|
|
36
|
+
/** Dropped restored record: no session means no context to resume, so its
|
|
37
|
+
* artifacts go away with the record. */
|
|
38
|
+
async function discardRestoredRecord(runtime: SubagentRuntime, record: ThreadRecord): Promise<void> {
|
|
39
|
+
if (record.sessionDir) {
|
|
40
|
+
await rm(record.sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
41
|
+
runtime.sessionDirs.delete(record.sessionDir);
|
|
42
|
+
}
|
|
43
|
+
if (record.worktree && (record.worktree.state === "active" || record.worktree.state === "retained")) {
|
|
44
|
+
const worktree = await restoreWorktreeIsolation(record.worktree).catch(() => undefined);
|
|
45
|
+
await worktree?.discard().catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
await removeThreadRecord(runtime.configPath, record.runId, record.cwd).catch(() => undefined);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createRestoredThread(
|
|
51
|
+
runtime: SubagentRuntime,
|
|
52
|
+
record: ThreadRecord,
|
|
53
|
+
worktree: WorktreeIsolation | undefined,
|
|
54
|
+
state: ThreadState,
|
|
55
|
+
): SubagentThread {
|
|
56
|
+
const thread: SubagentThread = {
|
|
57
|
+
id: record.runId,
|
|
58
|
+
generation: record.generation,
|
|
59
|
+
agentName: record.agentName,
|
|
60
|
+
task: record.task,
|
|
61
|
+
cwd: record.cwd,
|
|
62
|
+
executionCwd: record.executionCwd,
|
|
63
|
+
...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
|
|
64
|
+
isolation: record.isolation,
|
|
65
|
+
worktree,
|
|
66
|
+
state,
|
|
67
|
+
control: new RpcRunControl(record.task, record.generation),
|
|
68
|
+
generationCompletion: Promise.resolve(),
|
|
69
|
+
lifecycleVersion: 0,
|
|
70
|
+
elapsedMs: record.elapsedMs,
|
|
71
|
+
sessionId: record.sessionId,
|
|
72
|
+
sessionDir: record.sessionDir,
|
|
73
|
+
lastResult: restoredResultFromSummary(record),
|
|
74
|
+
resume: async () => failedStartResult(record.agentName, record.task, "Thread resume was not initialized."),
|
|
75
|
+
finalizeIsolation: async () => undefined,
|
|
76
|
+
};
|
|
77
|
+
installThreadLifecycle(thread, {
|
|
78
|
+
runtime,
|
|
79
|
+
startBackground: (...args) => {
|
|
80
|
+
const dispatcher = runtime.dispatcher;
|
|
81
|
+
if (!dispatcher) {
|
|
82
|
+
return Promise.resolve(failedStartResult(
|
|
83
|
+
record.agentName,
|
|
84
|
+
record.task,
|
|
85
|
+
`Run #${record.runId} cannot continue: no dispatch context is available yet. Dispatch any subagent once, then retry.`,
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
return dispatcher(...args);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return thread;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Rebuild interrupted (parked) threads from the durable manifest after a
|
|
95
|
+
* reload or restart. Orphaned children recorded by the previous process are
|
|
96
|
+
* killed first; records whose retained session vanished — and settled records
|
|
97
|
+
* left by older versions, which hold no work worth resuming — drop out with
|
|
98
|
+
* their artifacts. Returns the restored run ids. */
|
|
99
|
+
export async function restoreDurableThreads(runtime: SubagentRuntime): Promise<number[]> {
|
|
100
|
+
const records = await readThreadRecords(runtime.configPath);
|
|
101
|
+
const restoredIds: number[] = [];
|
|
102
|
+
for (const record of records) {
|
|
103
|
+
if (runtime.threads.has(record.runId) || monitor.findRun(record.runId)) continue;
|
|
104
|
+
if (record.state !== "parked") {
|
|
105
|
+
await discardRestoredRecord(runtime, record);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A child orphaned by reload/crash may still hold the retained session.
|
|
109
|
+
// The on-disk session checkpoint is what survives; kill the writer — but
|
|
110
|
+
// only while the recorded pids are still ours. Across a reboot the same
|
|
111
|
+
// numbers belong to unrelated processes.
|
|
112
|
+
if (isCurrentBoot(record)) {
|
|
113
|
+
for (const pid of record.childPids) {
|
|
114
|
+
if (isProcessAlive(pid)) killProcessTree(pid);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const sessionValid =
|
|
118
|
+
record.sessionId !== undefined &&
|
|
119
|
+
record.sessionDir !== undefined &&
|
|
120
|
+
sessionExists(record.sessionDir, record.sessionId);
|
|
121
|
+
if (!sessionValid) {
|
|
122
|
+
await discardRestoredRecord(runtime, record);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const worktree = record.worktree
|
|
126
|
+
? await restoreWorktreeIsolation(record.worktree).catch(() => undefined)
|
|
127
|
+
: undefined;
|
|
128
|
+
const restorationFailed = record.isolation === "worktree" && record.worktree !== undefined && !worktree;
|
|
129
|
+
const thread = createRestoredThread(runtime, record, worktree, restorationFailed ? "failed" : "parked");
|
|
130
|
+
runtime.threads.set(record.runId, thread);
|
|
131
|
+
runtime.sessionDirs.add(record.sessionDir!);
|
|
132
|
+
if (restorationFailed) {
|
|
133
|
+
const reason = `Run #${record.runId}'s recorded worktree could not be restored; isolated edits may be unavailable. The retained session and durable record were kept, but this thread cannot be resumed.`;
|
|
134
|
+
thread.resumeUnavailableReason = reason;
|
|
135
|
+
thread.restorationRecord = record;
|
|
136
|
+
const previous = restoredResultFromSummary(record);
|
|
137
|
+
const failed: SingleResult = {
|
|
138
|
+
agent: record.agentName,
|
|
139
|
+
task: record.task,
|
|
140
|
+
exitCode: 1,
|
|
141
|
+
messages: previous?.messages ?? [],
|
|
142
|
+
stderr: reason,
|
|
143
|
+
usage: previous?.usage ?? emptyUsage(),
|
|
144
|
+
model: previous?.model,
|
|
145
|
+
thinking: previous?.thinking,
|
|
146
|
+
stopReason: "error",
|
|
147
|
+
errorMessage: reason,
|
|
148
|
+
dispatchFailed: true,
|
|
149
|
+
sessionId: record.sessionId,
|
|
150
|
+
sessionDir: record.sessionDir,
|
|
151
|
+
projectCwd: record.cwd,
|
|
152
|
+
runId: record.runId,
|
|
153
|
+
isolation: "worktree",
|
|
154
|
+
integrationStatus: "retained",
|
|
155
|
+
};
|
|
156
|
+
thread.lastResult = failed;
|
|
157
|
+
runtime.registerRunResult(record.runId, failed);
|
|
158
|
+
monitor.restoreRun({
|
|
159
|
+
id: record.runId,
|
|
160
|
+
agent: record.agentName,
|
|
161
|
+
task: record.task,
|
|
162
|
+
status: "failed",
|
|
163
|
+
elapsedMs: record.elapsedMs,
|
|
164
|
+
isolation: "worktree",
|
|
165
|
+
integrationStatus: "retained",
|
|
166
|
+
});
|
|
167
|
+
runtime.claimRunDelivery(record.runId, "background");
|
|
168
|
+
runtime.publishRunCompletion(record.runId, {
|
|
169
|
+
agent: record.agentName,
|
|
170
|
+
block: `### Subagent restoration failed: #${record.runId} ${record.agentName}\n\n${reason}`,
|
|
171
|
+
usage: failed.usage,
|
|
172
|
+
}, true);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
monitor.restoreRun({
|
|
176
|
+
id: record.runId,
|
|
177
|
+
agent: record.agentName,
|
|
178
|
+
task: record.task,
|
|
179
|
+
status: "parked",
|
|
180
|
+
elapsedMs: record.elapsedMs,
|
|
181
|
+
isolation: record.isolation,
|
|
182
|
+
...(record.worktree
|
|
183
|
+
? {
|
|
184
|
+
integrationStatus: record.worktree.state === "active"
|
|
185
|
+
? ("pending" as const)
|
|
186
|
+
: record.worktree.state,
|
|
187
|
+
...(worktree ? { worktreeId: worktreeGroupId(worktree) } : {}),
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
});
|
|
191
|
+
restoredIds.push(record.runId);
|
|
192
|
+
}
|
|
193
|
+
return restoredIds;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Session-start durable bootstrap: restore threads, age out expired records, and
|
|
197
|
+
* sweep leaked temp/state directories. Every stage is best-effort so a broken
|
|
198
|
+
* manifest never blocks the session.
|
|
199
|
+
*
|
|
200
|
+
* Restore is published on the runtime as `durableRestore` before this returns,
|
|
201
|
+
* so callers that must see restored threads await that pass alone and never the
|
|
202
|
+
* hygiene sweeps behind it. Hygiene still runs after restore: pruning decides
|
|
203
|
+
* what to delete from the records restore has already claimed. */
|
|
204
|
+
export function bootstrapDurableState(runtime: SubagentRuntime): Promise<void> {
|
|
205
|
+
const restore = (async () => {
|
|
206
|
+
try {
|
|
207
|
+
runtime.restoredRunIds = await restoreDurableThreads(runtime);
|
|
208
|
+
} catch {
|
|
209
|
+
/* restore is best-effort */
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
runtime.durableRestore = restore;
|
|
213
|
+
return (async () => {
|
|
214
|
+
await restore;
|
|
215
|
+
try {
|
|
216
|
+
await pruneThreadRecords(runtime.configPath);
|
|
217
|
+
} catch {
|
|
218
|
+
/* retention is best-effort */
|
|
219
|
+
}
|
|
220
|
+
const projectRoots = getSubagentsRoot(runtime.configPath);
|
|
221
|
+
try {
|
|
222
|
+
sweepProjectTempDirs(projectRoots);
|
|
223
|
+
} catch {
|
|
224
|
+
/* temp hygiene is best-effort */
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
// Sessions and worktrees outlive their process on purpose, so only
|
|
228
|
+
// ownership separates state a live pi still resumes from state a crash
|
|
229
|
+
// abandoned. Valid thread and recovery records always keep their paths.
|
|
230
|
+
const records = await readThreadRecords(runtime.configPath);
|
|
231
|
+
const recoveryRecords = await readRecoveryRecords(runtime.configPath);
|
|
232
|
+
const referenced = [...referencedDurablePaths(records)];
|
|
233
|
+
referenced.push(...await referencedRecoveryPaths(runtime.configPath, recoveryRecords));
|
|
234
|
+
sweepProjectDurableDirs(projectRoots, {
|
|
235
|
+
keep: (path) => referenced.some((claimed) => isPathInside(path, claimed)),
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
/* durable-state hygiene is best-effort */
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
// Result excerpts are bounded on write, which never reaches a project
|
|
242
|
+
// that has stopped producing them.
|
|
243
|
+
sweepProjectResultArtifacts(projectRoots);
|
|
244
|
+
} catch {
|
|
245
|
+
/* result retention is best-effort */
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await pruneStaleProjectRoots(runtime.configPath);
|
|
249
|
+
} catch {
|
|
250
|
+
/* project-root hygiene is best-effort */
|
|
251
|
+
}
|
|
252
|
+
})();
|
|
253
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/** Shared contracts and coordination primitives for logical sub-agent threads. */
|
|
2
|
+
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
|
|
7
|
+
import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
|
|
8
|
+
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
|
|
9
|
+
import {
|
|
10
|
+
availableModelsInScope,
|
|
11
|
+
currentModelRef,
|
|
12
|
+
findModelByRef,
|
|
13
|
+
modelRef,
|
|
14
|
+
resolveAgentModelRoute,
|
|
15
|
+
resolveThinkingLevel,
|
|
16
|
+
} from "../configuration/models.ts";
|
|
17
|
+
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
18
|
+
import { getProjectRoot, type SingleResult } from "../execution/spawn.ts";
|
|
19
|
+
import { resolveRepositoryRoot, type IsolationMode, type WorktreeIsolation } from "../isolation/worktree.ts";
|
|
20
|
+
|
|
21
|
+
/** Control operations must never wait forever on a settling generation: the
|
|
22
|
+
* queue task can legitimately spend minutes in worktree finalization (bounded
|
|
23
|
+
* per-Git-command timeouts) or wait behind the managed repository lane. After
|
|
24
|
+
* this deadline the control path owns the lifecycle synchronously and proceeds
|
|
25
|
+
* while the stuck tail settles silently in the background. */
|
|
26
|
+
export const CONTROL_QUIESCE_TIMEOUT_MS = 20_000;
|
|
27
|
+
|
|
28
|
+
/** Resolve true when the promise settles, or false after the bounded deadline. */
|
|
29
|
+
export function quiesced(promise: Promise<unknown>, timeoutMs: number = CONTROL_QUIESCE_TIMEOUT_MS): Promise<boolean> {
|
|
30
|
+
return Promise.race([
|
|
31
|
+
promise.then(() => true, () => true),
|
|
32
|
+
new Promise<boolean>((resolve) => {
|
|
33
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
34
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
35
|
+
}),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const managedRepositoryRootTails = new Map<string, Promise<void>>();
|
|
40
|
+
|
|
41
|
+
async function canonicalManagedRepositoryRoot(cwd: string): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
// Repository identity does not depend on HEAD: empty repositories must
|
|
44
|
+
// serialize root and nested cwd requests under the same lane too.
|
|
45
|
+
return await resolveRepositoryRoot(cwd);
|
|
46
|
+
} catch {
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(resolve(cwd));
|
|
49
|
+
} catch {
|
|
50
|
+
return resolve(cwd);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Run one operation under the canonical original-repository lane.
|
|
56
|
+
*
|
|
57
|
+
* Shared write-capable generations use the abortable overload for their whole
|
|
58
|
+
* run. Isolated generations use the non-abortable overload only for their
|
|
59
|
+
* final worktree apply, so model work remains parallel while the original
|
|
60
|
+
* checkout mutation cannot race a shared writer.
|
|
61
|
+
*/
|
|
62
|
+
export async function runInManagedRepositoryLane<T>(
|
|
63
|
+
cwd: string,
|
|
64
|
+
task: () => Promise<T>,
|
|
65
|
+
): Promise<T>;
|
|
66
|
+
export async function runInManagedRepositoryLane<T>(
|
|
67
|
+
cwd: string,
|
|
68
|
+
task: () => Promise<T>,
|
|
69
|
+
signal: AbortSignal,
|
|
70
|
+
): Promise<T | undefined>;
|
|
71
|
+
export async function runInManagedRepositoryLane<T>(
|
|
72
|
+
cwd: string,
|
|
73
|
+
task: () => Promise<T>,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
): Promise<T | undefined> {
|
|
76
|
+
if (signal?.aborted) return undefined;
|
|
77
|
+
const root = await canonicalManagedRepositoryRoot(cwd);
|
|
78
|
+
const key = process.platform === "win32" ? root.toLowerCase() : root;
|
|
79
|
+
const previous = managedRepositoryRootTails.get(key) ?? Promise.resolve();
|
|
80
|
+
let release!: () => void;
|
|
81
|
+
const gate = new Promise<void>((resolveGate) => {
|
|
82
|
+
release = resolveGate;
|
|
83
|
+
});
|
|
84
|
+
const tail = previous.catch(() => undefined).then(() => gate);
|
|
85
|
+
managedRepositoryRootTails.set(key, tail);
|
|
86
|
+
let onAbort: (() => void) | undefined;
|
|
87
|
+
try {
|
|
88
|
+
if (signal) {
|
|
89
|
+
await Promise.race([
|
|
90
|
+
previous.catch(() => undefined),
|
|
91
|
+
new Promise<void>((resolveAborted) => {
|
|
92
|
+
if (signal.aborted) resolveAborted();
|
|
93
|
+
else {
|
|
94
|
+
onAbort = resolveAborted;
|
|
95
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
} else {
|
|
100
|
+
await previous.catch(() => undefined);
|
|
101
|
+
}
|
|
102
|
+
if (signal?.aborted) return undefined;
|
|
103
|
+
return await task();
|
|
104
|
+
} finally {
|
|
105
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
106
|
+
release();
|
|
107
|
+
// An aborted waiter may finish before the prior owner. Keep its chained
|
|
108
|
+
// tail installed until that owner also settles, otherwise a newcomer could
|
|
109
|
+
// observe an empty map and race the still-running workflow.
|
|
110
|
+
void tail.then(() => {
|
|
111
|
+
if (managedRepositoryRootTails.get(key) === tail) managedRepositoryRootTails.delete(key);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Track resume setup that has claimed a thread but has not yet enqueued
|
|
117
|
+
* its next generation. Shutdown invalidates these claims and waits for cleanup. */
|
|
118
|
+
export function beginRuntimePreflight(runtime: SubagentRuntime): () => void {
|
|
119
|
+
let resolvePreflight!: () => void;
|
|
120
|
+
const preflight = new Promise<void>((resolve) => {
|
|
121
|
+
resolvePreflight = resolve;
|
|
122
|
+
});
|
|
123
|
+
runtime.preflightOperations.add(preflight);
|
|
124
|
+
return () => {
|
|
125
|
+
runtime.preflightOperations.delete(preflight);
|
|
126
|
+
resolvePreflight();
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Synchronous CAS used by lifecycle controls across their async preflight. */
|
|
131
|
+
export function ownsResumeReservation(
|
|
132
|
+
runtime: SubagentRuntime,
|
|
133
|
+
thread: SubagentThread,
|
|
134
|
+
reservation: { version: number; generation: number; sessionId?: string; sessionDir?: string },
|
|
135
|
+
): boolean {
|
|
136
|
+
return (
|
|
137
|
+
runtime.sessionActive &&
|
|
138
|
+
runtime.threads.get(thread.id) === thread &&
|
|
139
|
+
!thread.retired &&
|
|
140
|
+
thread.lifecycleOperation === "resume" &&
|
|
141
|
+
thread.lifecycleVersion === reservation.version &&
|
|
142
|
+
thread.generation === reservation.generation &&
|
|
143
|
+
thread.sessionId === reservation.sessionId &&
|
|
144
|
+
thread.sessionDir === reservation.sessionDir
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Fire-and-forget durable checkpoint. Parked threads stay resumable across
|
|
149
|
+
* reloads; a settled thread drops its record so the manifest only exists
|
|
150
|
+
* while unfinished work needs it. The live session keeps working when the
|
|
151
|
+
* manifest is unwritable; only cross-reload resume is degraded. */
|
|
152
|
+
export function persistThreadCheckpoint(
|
|
153
|
+
runtime: SubagentRuntime,
|
|
154
|
+
thread: SubagentThread,
|
|
155
|
+
state: "parked" | "completed" | "failed",
|
|
156
|
+
): void {
|
|
157
|
+
const write = state === "parked"
|
|
158
|
+
? upsertThreadRecord(runtime.configPath, threadRecordFromThread(thread, state))
|
|
159
|
+
: removeThreadRecord(runtime.configPath, thread.id, thread.cwd);
|
|
160
|
+
void write.catch(() => undefined);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const WORKTREE_ISOLATION_INSTRUCTIONS =
|
|
164
|
+
"You are running in a temporary detached Git worktree. Work only in the current cwd; do not create another worktree or manually copy/apply changes to the original checkout. The parent dispatcher will integrate your tracked, deleted, and untracked changes when this thread finally settles.";
|
|
165
|
+
|
|
166
|
+
export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
167
|
+
return {
|
|
168
|
+
...agent,
|
|
169
|
+
systemPrompt: `${agent.systemPrompt.trimEnd()}\n\n${WORKTREE_ISOLATION_INSTRUCTIONS}`.trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Only write-capable agents can run in an isolated worktree. */
|
|
174
|
+
export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
175
|
+
return isWriteCapableAgent(agent);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface DispatchEnvironment {
|
|
179
|
+
ctx: ExtensionContext;
|
|
180
|
+
config: SubagentsConfig;
|
|
181
|
+
agents: AgentConfig[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface SessionSeed {
|
|
185
|
+
sessionId?: string;
|
|
186
|
+
sessionDir?: string;
|
|
187
|
+
prompt?: string;
|
|
188
|
+
worktree?: WorktreeIsolation;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ResumeReservation {
|
|
192
|
+
version: number;
|
|
193
|
+
generation: number;
|
|
194
|
+
sessionId?: string;
|
|
195
|
+
sessionDir?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The dispatcher's full internal entry point; the public tool surface only
|
|
199
|
+
* uses the first four parameters. */
|
|
200
|
+
export interface StartBackgroundOptions {
|
|
201
|
+
/** Resume path only: the thread whose retained context continues. */
|
|
202
|
+
existingThread?: SubagentThread;
|
|
203
|
+
appendedObjectiveOnResume?: boolean;
|
|
204
|
+
environment?: DispatchEnvironment;
|
|
205
|
+
seed?: SessionSeed;
|
|
206
|
+
resumeReservation?: ResumeReservation;
|
|
207
|
+
/** Chosen by the tool call before the queue can start a fast child. */
|
|
208
|
+
deliveryRoute?: "background" | "await";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type StartBackgroundInternal = (
|
|
212
|
+
agentName: string,
|
|
213
|
+
task: string,
|
|
214
|
+
cwd: string | undefined,
|
|
215
|
+
isolation?: IsolationMode,
|
|
216
|
+
options?: StartBackgroundOptions,
|
|
217
|
+
) => Promise<SingleResult>;
|
|
218
|
+
|
|
219
|
+
export interface ThreadLifecycleDeps {
|
|
220
|
+
runtime: SubagentRuntime;
|
|
221
|
+
/** Fallback context when a control caller supplies none; restored threads
|
|
222
|
+
* install without one and rely on the per-call context. */
|
|
223
|
+
runCtx?: ExtensionContext;
|
|
224
|
+
/** Fresh dispatch passes the live dispatcher; restored threads resolve it
|
|
225
|
+
* from the runtime at call time so they never pin a stale closure. */
|
|
226
|
+
startBackground: StartBackgroundInternal;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface DispatchModelRoute {
|
|
230
|
+
agent: AgentConfig;
|
|
231
|
+
mainFallbackRef?: string;
|
|
232
|
+
thinkingLevel: ThinkingLevel;
|
|
233
|
+
thinkingLevelForModel: (ref?: string) => ThinkingLevel;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function resolveDispatchModelRoute(
|
|
237
|
+
agent: AgentConfig,
|
|
238
|
+
config: SubagentsConfig,
|
|
239
|
+
ctx: ExtensionContext,
|
|
240
|
+
): DispatchModelRoute {
|
|
241
|
+
const availableModels = availableModelsInScope(ctx);
|
|
242
|
+
const mainRef = currentModelRef(ctx);
|
|
243
|
+
const route = resolveAgentModelRoute({
|
|
244
|
+
selectedRef: config.agentModels[agent.name],
|
|
245
|
+
mainRef,
|
|
246
|
+
availableRefs: availableModels.map(modelRef),
|
|
247
|
+
});
|
|
248
|
+
// A `/subagents-setup` override wins; otherwise the role default. No
|
|
249
|
+
// per-call or frontmatter thinking.
|
|
250
|
+
const preferred =
|
|
251
|
+
config.agentThinkingLevels[agent.name] ?? roleThinkingLevel(agent.name);
|
|
252
|
+
const thinkingLevelForModel = (ref?: string): ThinkingLevel => {
|
|
253
|
+
const model = ref === mainRef && ctx.model
|
|
254
|
+
? ctx.model
|
|
255
|
+
: findModelByRef(availableModels, ref);
|
|
256
|
+
return resolveThinkingLevel(model, preferred);
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
agent: { ...agent, model: route.primaryRef },
|
|
260
|
+
mainFallbackRef: route.mainFallbackRef,
|
|
261
|
+
thinkingLevel: thinkingLevelForModel(route.primaryRef),
|
|
262
|
+
thinkingLevelForModel,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Project-scoped <projectRoot>/results for a completion's artifacts. */
|
|
267
|
+
export function projectResultsRoot(configPath: string, cwd: string | undefined): string {
|
|
268
|
+
return join(getProjectRoot(configPath, cwd), "results");
|
|
269
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Thread controls around the subagent runtime: subagent_control (resume)
|
|
3
|
-
* destructive subagent_stop. There is no status/poll tool — completions carry
|
|
2
|
+
* Thread controls around the subagent runtime: subagent_control (steer/resume)
|
|
3
|
+
* and destructive subagent_stop. There is no status/poll tool — completions carry
|
|
4
4
|
* each result (with an on-disk artifact when truncated) and wake the main
|
|
5
5
|
* model, so waiting is never a tool call; the only in-turn block is `wait:
|
|
6
6
|
* true` on a dispatch, for one-shot parents that exit at end of turn.
|
|
@@ -11,16 +11,16 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
11
11
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
|
-
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "
|
|
14
|
+
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "../configuration/config.ts";
|
|
15
15
|
import { removeThreadRecord } from "./durable.ts";
|
|
16
|
-
import { formatCompletionBlock, matchRunIds } from "
|
|
17
|
-
import { emptyUsage } from "
|
|
18
|
-
import { formatTaskSummary, monitor } from "
|
|
19
|
-
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "
|
|
16
|
+
import { formatCompletionBlock, matchRunIds } from "../presentation/format.ts";
|
|
17
|
+
import { emptyUsage } from "../execution/rpc-control.ts";
|
|
18
|
+
import { formatTaskSummary, monitor } from "../presentation/monitor.ts";
|
|
19
|
+
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
|
|
20
20
|
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
21
|
-
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-
|
|
22
|
-
import { getResultOutput,
|
|
23
|
-
import type { WorktreeFinalization } from "
|
|
21
|
+
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-shared.ts";
|
|
22
|
+
import { getResultOutput, type SingleResult } from "../execution/spawn.ts";
|
|
23
|
+
import type { WorktreeFinalization } from "../isolation/worktree.ts";
|
|
24
24
|
|
|
25
25
|
function renderFirstLine(result: { content?: unknown }, label: string, theme: any): Text {
|
|
26
26
|
const parts = (result.content ?? []) as Array<{ type: string; text?: string }>;
|
|
@@ -34,19 +34,19 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
|
|
|
34
34
|
|
|
35
35
|
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
36
36
|
const SubagentControlParams = Type.Object({
|
|
37
|
-
action: StringEnum(["resume"] as const, {
|
|
37
|
+
action: StringEnum(["resume", "steer"] as const, {
|
|
38
38
|
description: "Control operation for the logical sub-agent thread.",
|
|
39
39
|
}),
|
|
40
40
|
id: Type.Integer({ minimum: 1, description: "Stable run id shown by subagent dispatch output." }),
|
|
41
41
|
objective: Type.Optional(
|
|
42
|
-
Type.String({ description: "
|
|
42
|
+
Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume." }),
|
|
43
43
|
),
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
pi.registerTool({
|
|
47
47
|
name: "subagent_control",
|
|
48
48
|
label: "Subagent Control",
|
|
49
|
-
description: "
|
|
49
|
+
description: "Steer an active running child with additional guidance, or resume a parked/settled thread by stable run id.",
|
|
50
50
|
parameters: SubagentControlParams,
|
|
51
51
|
|
|
52
52
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -64,6 +64,44 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
66
|
switch (params.action) {
|
|
67
|
+
case "steer": {
|
|
68
|
+
const objective = nonBlank(params.objective);
|
|
69
|
+
if (!objective) {
|
|
70
|
+
return { content: [{ type: "text", text: "steer objective must be non-blank." }], details: {} };
|
|
71
|
+
}
|
|
72
|
+
if (thread.retired) {
|
|
73
|
+
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and cannot be steered.` }], details: {} };
|
|
74
|
+
}
|
|
75
|
+
let unavailable: string | undefined;
|
|
76
|
+
if (thread.lifecycleOperation === "stop" || thread.state === "stopped") unavailable = "stopped";
|
|
77
|
+
else if (thread.lifecycleOperation === "park") unavailable = "parking";
|
|
78
|
+
else if (thread.lifecycleOperation === "settle") unavailable = `settled (${thread.state})`;
|
|
79
|
+
else if (thread.state === "completed" || thread.state === "failed") unavailable = `settled (${thread.state})`;
|
|
80
|
+
else if (thread.state === "queued" || thread.state === "resuming") {
|
|
81
|
+
const phase = thread.control.getPhase();
|
|
82
|
+
unavailable = phase === "starting" || phase === "retrying" ? phase : thread.state;
|
|
83
|
+
} else if (thread.state !== "running") unavailable = thread.state;
|
|
84
|
+
if (unavailable) {
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text", text: `Run #${thread.id} is ${unavailable}; only an active running RPC attempt can be steered. No guidance was sent.` }],
|
|
87
|
+
details: {},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const steered = await thread.control.steer(objective);
|
|
91
|
+
if (!steered.accepted) {
|
|
92
|
+
if (steered.reason === "no-active-attempt") {
|
|
93
|
+
return { content: [{ type: "text", text: `Run #${thread.id} is marked running but has no active RPC attempt; no guidance was sent.` }], details: {} };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: `Run #${thread.id} is ${steered.phase}; only an active running RPC attempt can be steered. No guidance was sent.` }],
|
|
97
|
+
details: {},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
content: [{ type: "text", text: `Steered run #${thread.id} with additional in-scope guidance; its original objective is unchanged.` }],
|
|
102
|
+
details: {},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
67
105
|
case "resume": {
|
|
68
106
|
if (thread.retired) {
|
|
69
107
|
return { content: [{ type: "text", text: `Run #${thread.id} was retired by subagent_stop and has no resumable session.` }], details: {} };
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "
|
|
6
|
-
import { availableModelsInScope, filterUnavailableModelOverrides } from "
|
|
7
|
-
import { announceRecoveryRecords, relocateRecoveryManifest } from "
|
|
8
|
-
import type { SubagentRuntime } from "
|
|
5
|
+
import { FIRST_RUN_SETUP_HINT, loadConfig, saveConfig } from "../configuration/config.ts";
|
|
6
|
+
import { availableModelsInScope, filterUnavailableModelOverrides } from "../configuration/models.ts";
|
|
7
|
+
import { announceRecoveryRecords, relocateRecoveryManifest } from "../isolation/recovery.ts";
|
|
8
|
+
import type { SubagentRuntime } from "../lifecycle/runtime.ts";
|
|
9
9
|
import { installActiveRunsStatus } from "./status.ts";
|
|
10
10
|
import { installActiveRunsWidget } from "./widget.ts";
|
|
11
11
|
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* and run-id matching.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { AgentConfig } from "
|
|
7
|
+
import type { AgentConfig } from "../delegation/agents.ts";
|
|
8
8
|
import { runLabel, shrinkRunLabel } from "./monitor.ts";
|
|
9
|
-
import { emptyUsage } from "
|
|
9
|
+
import { emptyUsage } from "../execution/rpc-control.ts";
|
|
10
10
|
import {
|
|
11
11
|
RESULT_LINE_MAX,
|
|
12
12
|
getResultOutput,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
writeResultArtifact,
|
|
16
16
|
type SingleResult,
|
|
17
17
|
type UsageStats,
|
|
18
|
-
} from "
|
|
18
|
+
} from "../execution/spawn.ts";
|
|
19
19
|
|
|
20
20
|
export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
|
|
21
21
|
return {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
import { stripVTControlCharacters } from "node:util";
|
|
13
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
-
import { emptyUsage, type UsageStats } from "
|
|
16
|
-
import type { IsolationMode, WorktreeFinalizationStatus } from "
|
|
15
|
+
import { emptyUsage, type UsageStats } from "../execution/rpc-control.ts";
|
|
16
|
+
import type { IsolationMode, WorktreeFinalizationStatus } from "../isolation/worktree.ts";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Types
|