@ferris1225/pi-subagents 4.3.7 → 4.3.9
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 +38 -0
- package/README.md +119 -34
- package/agents/sentinel.md +21 -0
- package/index.ts +2 -0
- package/package.json +2 -2
- package/src/configuration/config.ts +10 -15
- package/src/delegation/agents.ts +1 -0
- package/src/delegation/dispatch.ts +176 -20
- package/src/delegation/phase-scope.ts +208 -0
- package/src/delegation/prompt.ts +36 -12
- package/src/delegation/risk.ts +168 -0
- package/src/lifecycle/durable.ts +18 -0
- package/src/lifecycle/runtime.ts +12 -1
- package/src/lifecycle/thread-lifecycle.ts +62 -6
- package/src/lifecycle/thread-restore.ts +3 -0
- package/src/lifecycle/thread-shared.ts +11 -4
- package/src/lifecycle/tools.ts +12 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { runCommand } from "../isolation/git-command.ts";
|
|
5
|
+
|
|
6
|
+
export type SubagentRiskCategory =
|
|
7
|
+
| "concurrency"
|
|
8
|
+
| "trust-boundary"
|
|
9
|
+
| "persistence-compatibility"
|
|
10
|
+
| "failure-cancellation";
|
|
11
|
+
|
|
12
|
+
export type GitRiskRunner = (
|
|
13
|
+
cwd: string,
|
|
14
|
+
args: readonly string[],
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
) => Promise<string>;
|
|
17
|
+
|
|
18
|
+
export interface SubagentRiskAdvisory {
|
|
19
|
+
available: boolean;
|
|
20
|
+
changedPaths: string[];
|
|
21
|
+
categories: SubagentRiskCategory[];
|
|
22
|
+
matches: Partial<Record<SubagentRiskCategory, string[]>>;
|
|
23
|
+
recommendSentinel: boolean;
|
|
24
|
+
unavailableReason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CATEGORY_RULES: ReadonlyArray<{
|
|
28
|
+
category: SubagentRiskCategory;
|
|
29
|
+
pattern: RegExp;
|
|
30
|
+
}> = [
|
|
31
|
+
{
|
|
32
|
+
category: "concurrency",
|
|
33
|
+
pattern: /(?:^|[/_.-])(?:concurr(?:ency|ent)?|parallel|queues?|workers?|threads?|locks?|mutex|semaphore|dispatch|background|races?|lane)(?:[/_.-]|$)/u,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
category: "trust-boundary",
|
|
37
|
+
pattern: /(?:^|[/_.-])(?:auth|credentials?|permissions?|policy|privilege|secrets?|security|sandbox|trust|tokens?)(?:[/_.-]|$)/u,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
category: "persistence-compatibility",
|
|
41
|
+
pattern: /(?:^|[/_.-])(?:compat(?:ibility)?|durable|manifests?|migrations?|persist(?:ence|ent)?|restore|schemas?|serializ(?:e|ation)|storage)(?:[/_.-]|$)/u,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
category: "failure-cancellation",
|
|
45
|
+
pattern: /(?:^|[/_.-])(?:abort(?:ed|ion|s)?|cancel(?:ed|lation|led)?|errors?|fail(?:ed|ures?)?|recovery|retries|retry|stop|timeout)(?:[/_.-]|$)/u,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function normalizedGitPath(path: string): string {
|
|
50
|
+
return path.replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function classifyRiskPaths(paths: readonly string[]): Pick<
|
|
54
|
+
SubagentRiskAdvisory,
|
|
55
|
+
"categories" | "matches" | "recommendSentinel"
|
|
56
|
+
> {
|
|
57
|
+
const normalized = [...new Set(paths.map(normalizedGitPath).filter(Boolean))].sort();
|
|
58
|
+
const matches: Partial<Record<SubagentRiskCategory, string[]>> = {};
|
|
59
|
+
const categories: SubagentRiskCategory[] = [];
|
|
60
|
+
for (const rule of CATEGORY_RULES) {
|
|
61
|
+
const matching = normalized.filter((path) => rule.pattern.test(path.toLowerCase()));
|
|
62
|
+
if (matching.length === 0) continue;
|
|
63
|
+
categories.push(rule.category);
|
|
64
|
+
matches[rule.category] = matching;
|
|
65
|
+
}
|
|
66
|
+
return { categories, matches, recommendSentinel: categories.length > 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const RISK_GIT_TIMEOUT_MS = 30_000;
|
|
70
|
+
const RISK_GIT_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
71
|
+
|
|
72
|
+
const defaultGitRunner: GitRiskRunner = async (cwd, args, signal) => {
|
|
73
|
+
const result = await runCommand("git", args, {
|
|
74
|
+
cwd,
|
|
75
|
+
signal,
|
|
76
|
+
timeoutMs: RISK_GIT_TIMEOUT_MS,
|
|
77
|
+
maxOutputBytes: RISK_GIT_OUTPUT_MAX_BYTES,
|
|
78
|
+
});
|
|
79
|
+
if (result.code !== 0) {
|
|
80
|
+
const detail = result.stderr.toString("utf8").trim();
|
|
81
|
+
throw new Error(detail || `git ${args[0] ?? "command"} exited with code ${result.code}`);
|
|
82
|
+
}
|
|
83
|
+
return result.stdout.toString("utf8");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function isCancellation(error: unknown, signal?: AbortSignal): boolean {
|
|
87
|
+
return signal?.aborted === true || (error instanceof Error && error.name === "AbortError");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function nulPaths(output: string): string[] {
|
|
91
|
+
return output.split("\0").map(normalizedGitPath).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Inspect repository-root-relative tracked and untracked changes without a model call.
|
|
95
|
+
* Non-cancellation Git failures are advisory-unavailable and never block work. */
|
|
96
|
+
export async function analyzeSubagentRisk(
|
|
97
|
+
cwd: string,
|
|
98
|
+
runGit: GitRiskRunner = defaultGitRunner,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
): Promise<SubagentRiskAdvisory> {
|
|
101
|
+
signal?.throwIfAborted();
|
|
102
|
+
const resolvedCwd = resolve(cwd);
|
|
103
|
+
try {
|
|
104
|
+
const topLevel = (await runGit(resolvedCwd, ["rev-parse", "--show-toplevel"], signal)).trim();
|
|
105
|
+
signal?.throwIfAborted();
|
|
106
|
+
if (!topLevel) throw new Error("Git returned an empty repository top-level path.");
|
|
107
|
+
const repositoryRoot = resolve(topLevel);
|
|
108
|
+
const [tracked, untracked] = await Promise.all([
|
|
109
|
+
runGit(repositoryRoot, ["diff", "--name-only", "-z", "HEAD"], signal),
|
|
110
|
+
runGit(repositoryRoot, ["ls-files", "--others", "--exclude-standard", "-z"], signal),
|
|
111
|
+
]);
|
|
112
|
+
signal?.throwIfAborted();
|
|
113
|
+
const changedPaths = [...new Set([...nulPaths(tracked), ...nulPaths(untracked)])].sort();
|
|
114
|
+
const classification = classifyRiskPaths(changedPaths);
|
|
115
|
+
return { available: true, changedPaths, ...classification };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (isCancellation(error, signal)) throw error;
|
|
118
|
+
return {
|
|
119
|
+
available: false,
|
|
120
|
+
changedPaths: [],
|
|
121
|
+
categories: [],
|
|
122
|
+
matches: {},
|
|
123
|
+
recommendSentinel: false,
|
|
124
|
+
unavailableReason: error instanceof Error ? error.message : String(error),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerSubagentRiskTool(pi: ExtensionAPI): void {
|
|
130
|
+
pi.registerTool({
|
|
131
|
+
name: "subagent_risk",
|
|
132
|
+
label: "Subagent Risk",
|
|
133
|
+
description: "Advisory-only, no-model-call inspection of repository-root-relative tracked and untracked changes from HEAD, even when called from a nested cwd. Applies fixed path rules for concurrency, trust-boundary, persistence-compatibility, and failure-cancellation risk, and reports whether a fresh Sentinel review is suggested. It never dispatches a child or blocks work.",
|
|
134
|
+
parameters: Type.Object({
|
|
135
|
+
cwd: Type.Optional(Type.String({ description: "Repository working directory; defaults to the current caller cwd." })),
|
|
136
|
+
}),
|
|
137
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
138
|
+
const advisory = await analyzeSubagentRisk(
|
|
139
|
+
resolve(ctx.cwd, params.cwd ?? "."),
|
|
140
|
+
defaultGitRunner,
|
|
141
|
+
signal,
|
|
142
|
+
);
|
|
143
|
+
if (!advisory.available) {
|
|
144
|
+
return {
|
|
145
|
+
content: [{
|
|
146
|
+
type: "text",
|
|
147
|
+
text: `Sentinel risk advisory unavailable: ${advisory.unavailableReason ?? "Git could not inspect the working tree"}. Advisory only; no child was dispatched and work was not blocked.`,
|
|
148
|
+
}],
|
|
149
|
+
details: advisory,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const changed = advisory.changedPaths.length > 0
|
|
153
|
+
? advisory.changedPaths.map((path) => `- ${path}`).join("\n")
|
|
154
|
+
: "- (none)";
|
|
155
|
+
const categories = advisory.categories.length > 0 ? advisory.categories.join(", ") : "none";
|
|
156
|
+
const recommendation = advisory.recommendSentinel
|
|
157
|
+
? "Sentinel suggested by fixed path rules. Dispatch remains the main agent's decision."
|
|
158
|
+
: "Sentinel not suggested by fixed path rules.";
|
|
159
|
+
return {
|
|
160
|
+
content: [{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: `Changed paths relative to HEAD:\n${changed}\nRisk categories: ${categories}\n${recommendation} Advisory only; no child was dispatched and work was not blocked.`,
|
|
163
|
+
}],
|
|
164
|
+
details: advisory,
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
package/src/lifecycle/durable.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promis
|
|
|
19
19
|
import { uptime } from "node:os";
|
|
20
20
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
21
21
|
import type { UsageStats } from "../execution/rpc-control.ts";
|
|
22
|
+
import { normalizePhaseId, normalizePhaseScope, type PhaseScope } from "../delegation/phase-scope.ts";
|
|
22
23
|
import type { SubagentThread } from "./runtime.ts";
|
|
23
24
|
import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "../execution/spawn.ts";
|
|
24
25
|
import { isManagedSessionDir, isManagedWorktreeLayout, samePath } from "../isolation/managed-paths.ts";
|
|
@@ -76,6 +77,9 @@ export interface ThreadRecord {
|
|
|
76
77
|
generation: number;
|
|
77
78
|
agentName: string;
|
|
78
79
|
task: string;
|
|
80
|
+
phaseId?: string;
|
|
81
|
+
scope?: PhaseScope;
|
|
82
|
+
writeCapable?: boolean;
|
|
79
83
|
cwd: string;
|
|
80
84
|
executionCwd: string;
|
|
81
85
|
/** Resolved (clamped) level of the last generation. */
|
|
@@ -166,6 +170,14 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
|
166
170
|
if (raw.state !== "parked" && raw.state !== "completed" && raw.state !== "failed") return undefined;
|
|
167
171
|
const worktree = raw.worktree === undefined ? undefined : normalizeWorktreeSnapshot(raw.worktree);
|
|
168
172
|
if (worktree === null) return undefined;
|
|
173
|
+
let phaseId: string | undefined;
|
|
174
|
+
let scope: PhaseScope | undefined;
|
|
175
|
+
try {
|
|
176
|
+
phaseId = normalizePhaseId(raw.phaseId as string | undefined);
|
|
177
|
+
scope = normalizePhaseScope(raw.scope as Parameters<typeof normalizePhaseScope>[0], raw.cwd);
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
169
181
|
return {
|
|
170
182
|
runId: raw.runId,
|
|
171
183
|
createdAt: raw.createdAt,
|
|
@@ -173,6 +185,9 @@ function normalizeRecord(value: unknown): ThreadRecord | undefined {
|
|
|
173
185
|
generation: typeof raw.generation === "number" && Number.isInteger(raw.generation) && raw.generation >= 0 ? raw.generation : 0,
|
|
174
186
|
agentName: raw.agentName,
|
|
175
187
|
task: raw.task,
|
|
188
|
+
...(phaseId ? { phaseId } : {}),
|
|
189
|
+
...(scope ? { scope } : {}),
|
|
190
|
+
...(typeof raw.writeCapable === "boolean" ? { writeCapable: raw.writeCapable } : {}),
|
|
176
191
|
cwd: raw.cwd,
|
|
177
192
|
executionCwd: typeof raw.executionCwd === "string" && raw.executionCwd ? raw.executionCwd : raw.cwd,
|
|
178
193
|
...(typeof raw.thinkingLevel === "string" && raw.thinkingLevel ? { thinkingLevel: raw.thinkingLevel } : {}),
|
|
@@ -375,6 +390,9 @@ export function threadRecordFromThread(
|
|
|
375
390
|
generation: thread.generation,
|
|
376
391
|
agentName: thread.agentName,
|
|
377
392
|
task: thread.task,
|
|
393
|
+
...(thread.phaseId ? { phaseId: thread.phaseId } : {}),
|
|
394
|
+
...(thread.scope ? { scope: thread.scope } : {}),
|
|
395
|
+
...(thread.writeCapable !== undefined ? { writeCapable: thread.writeCapable } : {}),
|
|
378
396
|
cwd: thread.cwd,
|
|
379
397
|
executionCwd: thread.executionCwd,
|
|
380
398
|
...(thread.thinkingLevel ? { thinkingLevel: thread.thinkingLevel } : {}),
|
package/src/lifecycle/runtime.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { PhaseScope, PhaseScopeInput } from "../delegation/phase-scope.ts";
|
|
12
13
|
import { rmSync } from "node:fs";
|
|
13
14
|
import { resolveSubagentConcurrency, BackgroundTaskQueue } from "../execution/background.ts";
|
|
14
15
|
import {
|
|
@@ -43,6 +44,12 @@ export interface SubagentThread {
|
|
|
43
44
|
generation: number;
|
|
44
45
|
agentName: string;
|
|
45
46
|
task: string;
|
|
47
|
+
phaseId?: string;
|
|
48
|
+
scope?: PhaseScope;
|
|
49
|
+
/** Monotonic continuation scope visible while resume preflight is in flight. */
|
|
50
|
+
admissionScope?: PhaseScope;
|
|
51
|
+
/** Capability snapshot used by declared-scope admission, including after restore. */
|
|
52
|
+
writeCapable?: boolean;
|
|
46
53
|
/** Caller-facing cwd in the original worktree. */
|
|
47
54
|
cwd: string;
|
|
48
55
|
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
@@ -77,7 +84,11 @@ export interface SubagentThread {
|
|
|
77
84
|
retireOnSettle?: boolean;
|
|
78
85
|
retired?: boolean;
|
|
79
86
|
/** Installed by dispatch so the control tool can restart the same logical id. */
|
|
80
|
-
resume: (
|
|
87
|
+
resume: (
|
|
88
|
+
objective?: string,
|
|
89
|
+
ctx?: ExtensionContext,
|
|
90
|
+
metadata?: { scope?: PhaseScopeInput },
|
|
91
|
+
) => Promise<SingleResult>;
|
|
81
92
|
/** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
|
|
82
93
|
* runs under the canonical original-repository lane. */
|
|
83
94
|
finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
|
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
} from "../presentation/format.ts";
|
|
29
29
|
import { monitor } from "../presentation/monitor.ts";
|
|
30
30
|
import { findDuplicateDispatch } from "../delegation/prompt.ts";
|
|
31
|
+
import {
|
|
32
|
+
findWriterLeaseScopeOverlap,
|
|
33
|
+
mergePhaseScopes,
|
|
34
|
+
normalizePhaseId,
|
|
35
|
+
normalizePhaseScope,
|
|
36
|
+
} from "../delegation/phase-scope.ts";
|
|
31
37
|
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
|
|
32
38
|
import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
|
|
33
39
|
import { forkRetainedSession } from "../execution/session-fork.ts";
|
|
@@ -105,6 +111,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
105
111
|
startOptions: StartBackgroundOptions = {},
|
|
106
112
|
): Promise<SingleResult> => {
|
|
107
113
|
const {
|
|
114
|
+
phaseId: requestedPhaseId,
|
|
115
|
+
scope: requestedScope,
|
|
116
|
+
writeCapable: requestedWriteCapable,
|
|
108
117
|
existingThread,
|
|
109
118
|
appendedObjectiveOnResume = false,
|
|
110
119
|
environment,
|
|
@@ -127,16 +136,30 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
127
136
|
const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
|
|
128
137
|
resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
|
|
129
138
|
const agent = resolveLiveAgentTools(discoveredAgent);
|
|
139
|
+
|
|
140
|
+
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
141
|
+
let phaseId: string | undefined;
|
|
142
|
+
let scope: ReturnType<typeof normalizePhaseScope>;
|
|
143
|
+
try {
|
|
144
|
+
phaseId = normalizePhaseId(existingThread ? existingThread.phaseId : requestedPhaseId);
|
|
145
|
+
const requested = normalizePhaseScope(requestedScope, originalCwd);
|
|
146
|
+
scope = existingThread ? mergePhaseScopes(existingThread.scope, requested) : requested;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
|
|
149
|
+
}
|
|
150
|
+
const currentWriteCapable = isWriteCapableAgent(agent);
|
|
151
|
+
const priorWriteCapable = existingThread
|
|
152
|
+
? (existingThread.writeCapable ?? existingThread.agentName !== "scout")
|
|
153
|
+
: Boolean(requestedWriteCapable);
|
|
154
|
+
const writeCapable = priorWriteCapable || currentWriteCapable;
|
|
130
155
|
if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
|
|
131
156
|
return {
|
|
132
157
|
...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
|
|
133
158
|
isolation,
|
|
134
159
|
};
|
|
135
160
|
}
|
|
136
|
-
|
|
137
|
-
const originalCwd = resolve(cwd ?? runCtx.cwd);
|
|
138
161
|
if (!existingThread) {
|
|
139
|
-
const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
|
|
162
|
+
const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
|
|
140
163
|
if (duplicate?.kind === "active") {
|
|
141
164
|
return failedStartResult(
|
|
142
165
|
agentName,
|
|
@@ -150,7 +173,17 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
150
173
|
return failedStartResult(
|
|
151
174
|
agentName,
|
|
152
175
|
task,
|
|
153
|
-
`Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this
|
|
176
|
+
`Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this logical phase and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run${phaseId ? "; keep using the same phaseId on that thread" : ", or restate the brief with what changed"}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (writeCapable && scope) {
|
|
181
|
+
const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values(), existingThread?.id);
|
|
182
|
+
if (conflict) {
|
|
183
|
+
return failedStartResult(
|
|
184
|
+
agentName,
|
|
185
|
+
task,
|
|
186
|
+
`Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no new generation was started.`,
|
|
154
187
|
);
|
|
155
188
|
}
|
|
156
189
|
}
|
|
@@ -230,6 +263,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
230
263
|
thread.generation = generation;
|
|
231
264
|
thread.agentName = agent.name;
|
|
232
265
|
thread.task = task;
|
|
266
|
+
thread.phaseId = phaseId;
|
|
267
|
+
thread.scope = scope;
|
|
268
|
+
thread.writeCapable = writeCapable;
|
|
233
269
|
thread.cwd = originalCwd;
|
|
234
270
|
thread.executionCwd = executionCwd;
|
|
235
271
|
thread.thinkingLevel = thinkingLevel;
|
|
@@ -253,6 +289,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
253
289
|
generation,
|
|
254
290
|
agentName: agent.name,
|
|
255
291
|
task,
|
|
292
|
+
phaseId,
|
|
293
|
+
scope,
|
|
294
|
+
writeCapable,
|
|
256
295
|
cwd: originalCwd,
|
|
257
296
|
executionCwd,
|
|
258
297
|
thinkingLevel,
|
|
@@ -281,7 +320,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
|
|
|
281
320
|
// Shared write-capable runs serialize on the repository lane so their
|
|
282
321
|
// edits cannot race; the lane wait releases the process slot because it
|
|
283
322
|
// is write serialization, not pool pacing.
|
|
284
|
-
const reserveManagedLane = isolation === "shared" &&
|
|
323
|
+
const reserveManagedLane = isolation === "shared" && writeCapable;
|
|
285
324
|
const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
|
|
286
325
|
if (runtime.threads.get(runId)?.generation !== generation) return;
|
|
287
326
|
if (isolation === "worktree" && !worktree) {
|
|
@@ -718,7 +757,11 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
718
757
|
});
|
|
719
758
|
};
|
|
720
759
|
|
|
721
|
-
thread.resume = async (
|
|
760
|
+
thread.resume = async (
|
|
761
|
+
objective?: string,
|
|
762
|
+
resumeCtx?: ExtensionContext,
|
|
763
|
+
metadata?: { scope?: Parameters<typeof normalizePhaseScope>[0] },
|
|
764
|
+
): Promise<SingleResult> => {
|
|
722
765
|
const requestedObjective = objective?.trim();
|
|
723
766
|
if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
|
|
724
767
|
return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
|
|
@@ -726,6 +769,14 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
726
769
|
if (objective !== undefined && !requestedObjective) {
|
|
727
770
|
return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
|
|
728
771
|
}
|
|
772
|
+
const continuationPhaseId = thread.phaseId;
|
|
773
|
+
let continuationScope: ReturnType<typeof normalizePhaseScope>;
|
|
774
|
+
try {
|
|
775
|
+
const additionalScope = normalizePhaseScope(metadata?.scope, thread.cwd);
|
|
776
|
+
continuationScope = mergePhaseScopes(thread.scope, additionalScope);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
return failedStartResult(thread.agentName, thread.task, error instanceof Error ? error.message : String(error));
|
|
779
|
+
}
|
|
729
780
|
if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
|
|
730
781
|
if (thread.resumeUnavailableReason) {
|
|
731
782
|
return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
|
|
@@ -750,6 +801,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
750
801
|
sessionId: previousSessionId,
|
|
751
802
|
sessionDir: previousSessionDir,
|
|
752
803
|
};
|
|
804
|
+
thread.admissionScope = continuationScope;
|
|
753
805
|
thread.lifecycleOperation = "resume";
|
|
754
806
|
thread.state = "resuming";
|
|
755
807
|
const finishPreflight = beginRuntimePreflight(runtime);
|
|
@@ -763,6 +815,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
763
815
|
// Never wait forever on a previous generation that is still settling
|
|
764
816
|
// (e.g. blocked behind the managed repository lane in finalization).
|
|
765
817
|
if (!(await quiesced(thread.generationCompletion))) {
|
|
818
|
+
if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
|
|
766
819
|
return failedStartResult(
|
|
767
820
|
thread.agentName,
|
|
768
821
|
thread.task,
|
|
@@ -829,6 +882,8 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
829
882
|
thread.isolation,
|
|
830
883
|
{
|
|
831
884
|
existingThread: thread,
|
|
885
|
+
phaseId: continuationPhaseId,
|
|
886
|
+
scope: continuationScope,
|
|
832
887
|
appendedObjectiveOnResume: objective !== undefined,
|
|
833
888
|
environment: {
|
|
834
889
|
ctx: currentCtx,
|
|
@@ -883,6 +938,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
|
|
|
883
938
|
);
|
|
884
939
|
} finally {
|
|
885
940
|
finishPreflight();
|
|
941
|
+
if (thread.lifecycleVersion === reservation.version) thread.admissionScope = undefined;
|
|
886
942
|
if (
|
|
887
943
|
thread.lifecycleOperation === "resume" &&
|
|
888
944
|
thread.lifecycleVersion === reservation.version
|
|
@@ -58,6 +58,9 @@ function createRestoredThread(
|
|
|
58
58
|
generation: record.generation,
|
|
59
59
|
agentName: record.agentName,
|
|
60
60
|
task: record.task,
|
|
61
|
+
phaseId: record.phaseId,
|
|
62
|
+
scope: record.scope,
|
|
63
|
+
writeCapable: record.writeCapable ?? record.agentName !== "scout",
|
|
61
64
|
cwd: record.cwd,
|
|
62
65
|
executionCwd: record.executionCwd,
|
|
63
66
|
...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
|
|
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
|
|
7
|
+
import type { PhaseScope } from "../delegation/phase-scope.ts";
|
|
7
8
|
import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
|
|
8
9
|
import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
|
|
9
10
|
import {
|
|
@@ -170,9 +171,10 @@ export function withWorktreeSystemPrompt(agent: AgentConfig): AgentConfig {
|
|
|
170
171
|
};
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
/** Only write-capable agents can run in an isolated worktree.
|
|
174
|
+
/** Only write-capable agents can run in an isolated worktree. Sentinel reviews
|
|
175
|
+
* the caller's uncommitted diff, which a detached worktree cannot contain. */
|
|
174
176
|
export function isWorktreeCapableAgent(agent: AgentConfig): boolean {
|
|
175
|
-
return isWriteCapableAgent(agent);
|
|
177
|
+
return agent.name !== "sentinel" && isWriteCapableAgent(agent);
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
export interface DispatchEnvironment {
|
|
@@ -194,9 +196,14 @@ export interface ResumeReservation {
|
|
|
194
196
|
sessionDir?: string;
|
|
195
197
|
}
|
|
196
198
|
|
|
197
|
-
/**
|
|
198
|
-
*
|
|
199
|
+
/** Dispatcher's internal entry point. Public phase/scope claims are normalized
|
|
200
|
+
* into options; resume adds lifecycle-only continuation fields there too. */
|
|
199
201
|
export interface StartBackgroundOptions {
|
|
202
|
+
/** Normalized identity and claims for a fresh or resumed generation. */
|
|
203
|
+
phaseId?: string;
|
|
204
|
+
scope?: PhaseScope;
|
|
205
|
+
/** Fresh-dispatch hint OR-merged with live capability; false cannot downgrade a writer. Resume stays monotonic. */
|
|
206
|
+
writeCapable?: boolean;
|
|
200
207
|
/** Resume path only: the thread whose retained context continues. */
|
|
201
208
|
existingThread?: SubagentThread;
|
|
202
209
|
appendedObjectiveOnResume?: boolean;
|
package/src/lifecycle/tools.ts
CHANGED
|
@@ -34,6 +34,13 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
37
|
+
const ResumeScopeSchema = Type.Optional(Type.Object({
|
|
38
|
+
paths: Type.Optional(Type.Array(Type.String({ minLength: 1, pattern: "\\S" }))),
|
|
39
|
+
symbols: Type.Optional(Type.Array(Type.Object({
|
|
40
|
+
path: Type.String({ minLength: 1, pattern: "\\S" }),
|
|
41
|
+
name: Type.String({ minLength: 1, pattern: "\\S" }),
|
|
42
|
+
}))),
|
|
43
|
+
}, { description: "Additional declarative write claims for resume; normalized claims are unioned with retained scope and cannot remove it. Scope is conflict metadata, not permissions or a sandbox." }));
|
|
37
44
|
const SubagentControlParams = Type.Object({
|
|
38
45
|
action: StringEnum(["steer", "resume", "park"] as const, {
|
|
39
46
|
description:
|
|
@@ -43,6 +50,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
43
50
|
objective: Type.Optional(
|
|
44
51
|
Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
|
|
45
52
|
),
|
|
53
|
+
scope: ResumeScopeSchema,
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
/** A thread that a steer can continue instead of reject: it is not live, but
|
|
@@ -52,7 +60,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
52
60
|
pi.registerTool({
|
|
53
61
|
name: "subagent_control",
|
|
54
62
|
label: "Subagent Control",
|
|
55
|
-
description: "Steer a running child
|
|
63
|
+
description: "Steer a running child, resume a parked/settled thread, or park a running thread by stable run id. Resume keeps phaseId immutable and may only extend retained scope.",
|
|
56
64
|
parameters: SubagentControlParams,
|
|
57
65
|
|
|
58
66
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -103,7 +111,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
103
111
|
return textResult("resume objective must be non-blank when provided.");
|
|
104
112
|
}
|
|
105
113
|
const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
|
|
106
|
-
const pending = await thread.resume(requestedObjective, ctx
|
|
114
|
+
const pending = await thread.resume(requestedObjective, ctx, {
|
|
115
|
+
scope: params.scope,
|
|
116
|
+
});
|
|
107
117
|
if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
|
|
108
118
|
const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
|
|
109
119
|
const mode = requestedObjective
|