@ferris1225/pi-subagents 4.3.8 → 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 +17 -0
- package/README.md +84 -19
- package/index.ts +2 -0
- package/package.json +1 -1
- 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 +34 -13
- 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 +8 -2
- package/src/lifecycle/tools.ts +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ Published versions of `@ferris1225/pi-subagents`. Unpublished numbers
|
|
|
4
4
|
(`4.2.3`, `4.2.6`, `4.2.9`–`4.2.11`) never shipped on npm; their changes
|
|
5
5
|
landed in the next published release.
|
|
6
6
|
|
|
7
|
+
## 4.3.9
|
|
8
|
+
|
|
9
|
+
- Add optional bounded stable `phaseId` and exact declarative write `scope` claims to single
|
|
10
|
+
and parallel dispatches. Phase identity is immutable across task rewrites and resume; scope
|
|
11
|
+
is monotonic across retained generations and survives durable v1 restore. Exact task+cwd
|
|
12
|
+
remains the compatibility fallback.
|
|
13
|
+
- Reject deterministic duplicates and declared writer-scope conflicts before parallel batch
|
|
14
|
+
allocation. Fresh single and resumed writers also reject normalized absolute scope overlap
|
|
15
|
+
with active leases, without requiring equal caller cwd. Parallel calls that omit scope remain
|
|
16
|
+
compatible and explicitly report `independence not verified`; declared claims do not prove
|
|
17
|
+
natural-language task independence. Scope is conflict metadata, not permissions or a sandbox.
|
|
18
|
+
- Add the advisory-only `subagent_risk` tool. Without a model call it resolves the repository
|
|
19
|
+
root, reads root-relative tracked and untracked changes from `HEAD`, and applies fixed
|
|
20
|
+
explainable rules for concurrency, trust-boundary, persistence-compatibility, and
|
|
21
|
+
failure-cancellation risk. It propagates cancellation, and suggests but never dispatches or
|
|
22
|
+
requires a Sentinel review.
|
|
23
|
+
|
|
7
24
|
## 4.3.8
|
|
8
25
|
|
|
9
26
|
- Restore `sentinel` as an optional fresh-context reviewer instead of the mandatory
|
package/README.md
CHANGED
|
@@ -12,12 +12,13 @@ once and your main agent delegates on its own.
|
|
|
12
12
|
|
|
13
13
|
## What's new
|
|
14
14
|
|
|
15
|
-
**4.3.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
**4.3.9** — dispatch admission now accepts bounded stable `phaseId` identities and exact
|
|
16
|
+
declarative write `scope` claims. Fresh/resumed writers are checked against active leases;
|
|
17
|
+
parallel batches preflight deterministic duplicates and all declared scope conflicts before
|
|
18
|
+
allocation. Omitted scopes remain compatible; a parallel call that omits `scope` reports
|
|
19
|
+
`independence not verified`. Declared claims do not prove natural-language task independence.
|
|
20
|
+
The no-model-call `subagent_risk` advisory classifies changed tracked and untracked paths
|
|
21
|
+
with fixed, documented rules before main decides whether a Sentinel review is worthwhile.
|
|
21
22
|
|
|
22
23
|
See [CHANGELOG.md](./CHANGELOG.md).
|
|
23
24
|
|
|
@@ -50,9 +51,10 @@ back — with you. This extension owns them:
|
|
|
50
51
|
handoff costs. Every brief carries the objective and done condition, exact paths,
|
|
51
52
|
facts already established with citations, boundaries, and the expected output, so a
|
|
52
53
|
child starts from evidence instead of re-deriving it.
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
- A stable `phaseId` owns a logical phase in one resolved working directory even if
|
|
55
|
+
its task wording changes. IDs are 1–80 ASCII letters, numbers, or `._:-`, starting
|
|
56
|
+
with a letter or number, so lease output stays single-line. Exact normalized task+cwd
|
|
57
|
+
remains the backward-compatible fallback for old calls.
|
|
56
58
|
- Follow-up work stays on the same thread: `steer` a running phase, `resume` or
|
|
57
59
|
`park` a thread with its retained context, `stop` a phase the evidence made moot.
|
|
58
60
|
- Background completions and stop results arrive at the next parent model boundary;
|
|
@@ -122,15 +124,30 @@ what the injected delegation guidance produces when the main agent dispatches fo
|
|
|
122
124
|
```ts
|
|
123
125
|
// One task
|
|
124
126
|
subagent({
|
|
127
|
+
phaseId: "cache-invalidation-fix",
|
|
125
128
|
agent: "artisan",
|
|
126
129
|
task: "Fix the cache invalidation bug in src/cache, add regression tests, run the checks.",
|
|
130
|
+
scope: {
|
|
131
|
+
paths: ["src/cache"],
|
|
132
|
+
symbols: [{ path: "test/cache.test.ts", name: "invalidates stale entries" }],
|
|
133
|
+
},
|
|
127
134
|
});
|
|
128
135
|
|
|
129
136
|
// Parallel only when each scope independently justifies a child
|
|
130
137
|
subagent({
|
|
131
138
|
tasks: [
|
|
132
|
-
{
|
|
133
|
-
|
|
139
|
+
{
|
|
140
|
+
agent: "artisan",
|
|
141
|
+
phaseId: "provider-docs",
|
|
142
|
+
task: "Update provider limits documentation from the established API citations.",
|
|
143
|
+
scope: { paths: ["docs/provider-limits.md"] },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
agent: "artisan",
|
|
147
|
+
phaseId: "config-validation",
|
|
148
|
+
task: "Fix config validation in src/config.ts and its tests.",
|
|
149
|
+
scope: { paths: ["src/config.ts", "test/config.test.ts"] },
|
|
150
|
+
},
|
|
134
151
|
],
|
|
135
152
|
});
|
|
136
153
|
```
|
|
@@ -140,13 +157,13 @@ independent unit in one `tasks` array. The runtime paces execution instead, runn
|
|
|
140
157
|
half the machine's cores with a 4–6 child-process bound; wider batches queue and
|
|
141
158
|
start automatically as slots free.
|
|
142
159
|
|
|
143
|
-
A run leases its
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
A run leases its stable, single-line `phaseId` in the resolved working directory.
|
|
161
|
+
Rewording the task with the same `phaseId` is rejected and names the existing run.
|
|
162
|
+
The id remains immutable across resume. Calls that omit `phaseId` keep the old exact
|
|
163
|
+
normalized task+cwd behavior; consequently, equal task text with different phase ids is
|
|
164
|
+
still rejected by that fallback. Matching is deterministic, never fuzzy, embedding-based,
|
|
165
|
+
or inferred from natural language. Active leases win over matching settled threads when
|
|
166
|
+
the runtime chooses which owner to report.
|
|
150
167
|
|
|
151
168
|
Because queueing is pacing rather than refusal, it is always reported as such.
|
|
152
169
|
Dispatch confirmations name each waiting run's real reason — waiting for a free
|
|
@@ -190,8 +207,49 @@ paths, or when the checks cannot prove the change. It is never a fixed pre-commi
|
|
|
190
207
|
ritual. A finding is evidence, not an order: main routes it to the thread that owns
|
|
191
208
|
the change with `subagent_control resume`, or fixes it inline when that is cheaper.
|
|
192
209
|
|
|
210
|
+
`subagent_risk({})` is an advisory-only, no-model-call check over tracked and untracked
|
|
211
|
+
changes relative to `HEAD`. It resolves the repository root first, so a nested `cwd` still
|
|
212
|
+
returns repository-root-relative paths, including untracked files outside that subdirectory.
|
|
213
|
+
Its fixed case-insensitive path-token rules flag:
|
|
214
|
+
`concurrency` (`thread`, `queue`, `parallel`, `dispatch`, locks/races and related tokens);
|
|
215
|
+
`trust-boundary` (`auth`, credentials, permissions, policy, secrets, sandbox, security, trust, tokens);
|
|
216
|
+
`persistence-compatibility` (durable state, manifests, migrations, restore, schemas,
|
|
217
|
+
serialization/storage); and `failure-cancellation` (abort, cancel, errors/failures, recovery,
|
|
218
|
+
retry, stop, timeout). It returns the changed paths, matched categories, and whether those
|
|
219
|
+
rules suggest Sentinel. If Git or `HEAD` is unavailable, it reports advisory unavailable; an
|
|
220
|
+
aborted tool call propagates cancellation instead of converting it to an advisory result. It
|
|
221
|
+
never blocks, starts a child, or automatically dispatches Sentinel.
|
|
222
|
+
|
|
223
|
+
This classifier is intentionally conservative and explainable: it only sees path names, so
|
|
224
|
+
it can produce false positives and miss risky behavior hidden behind neutral names. Main
|
|
225
|
+
still decides whether review pays from the actual diff, test evidence, handoff cost, and the
|
|
226
|
+
complete conversation. The runtime can enforce explicit phase/scope admission, but cannot
|
|
227
|
+
safely force the natural-language judgment of whether work is worth delegating.
|
|
228
|
+
|
|
193
229
|
## Parallel edits
|
|
194
230
|
|
|
231
|
+
`scope` is declarative admission metadata for expected writes, not access control. `paths`
|
|
232
|
+
contains exact file or directory paths; `symbols` contains exact `{ path, name }` claims.
|
|
233
|
+
Paths resolve from each task's caller-facing cwd and use case-insensitive comparison on
|
|
234
|
+
Windows. Wildcard `*` and `?` inputs are rejected; other punctuation is treated literally,
|
|
235
|
+
so paths such as `app/[id]/page.tsx` are valid exact claims. A path claim overlaps the same
|
|
236
|
+
path, an ancestor/descendant path, or a symbol under that path; identical path+symbol
|
|
237
|
+
claims overlap, while two different symbols in the same file may run together.
|
|
238
|
+
|
|
239
|
+
Fresh single dispatches and resumes check a declared writer scope against active, parked,
|
|
240
|
+
resuming, interrupting, or settling writer leases before allocating a generation. Scope
|
|
241
|
+
comparison uses normalized absolute claims rather than requiring equal caller cwd, so a
|
|
242
|
+
repo-root claim still conflicts with the same path claimed from a nested cwd. Settled
|
|
243
|
+
threads do not block a later phase solely because it edits the same scope.
|
|
244
|
+
|
|
245
|
+
Before allocating any run in a parallel call, the runtime also rejects deterministic phase
|
|
246
|
+
duplicates within the batch or against existing active/retained threads, then compares
|
|
247
|
+
declared writer scopes across the whole batch. A definite conflict rejects the whole batch
|
|
248
|
+
with zero starts. Parallel calls without `scope` remain valid, but their tool result and
|
|
249
|
+
launch receipt say `independence not verified`; that means the contract lacked enough
|
|
250
|
+
metadata, not that overlap was proved safe. Single calls never make a batch-independence
|
|
251
|
+
claim. The existing shared-checkout writer lane remains the final serialization boundary.
|
|
252
|
+
|
|
195
253
|
- Single tasks use your checkout. Every parallel write-capable agent (`artisan`,
|
|
196
254
|
`steward`, and custom writers) defaults to a detached Git worktree, so
|
|
197
255
|
parallel writers run at the same time. Worktree mode needs a committed `HEAD`;
|
|
@@ -230,7 +288,7 @@ Every dispatch returns a stable `#id`, which is the handle for the thread tools:
|
|
|
230
288
|
|
|
231
289
|
| Tool | What it does |
|
|
232
290
|
| ------------------ | ------------ |
|
|
233
|
-
| `subagent_control` | `steer` a running RPC attempt with additional evidence/guidance, continuing the same thread with it when the thread has settled or is parked; `resume` a parked/settled thread with an optional appended `objective`; `park` a running thread at a stable checkpoint, keeping its session and worktree for a later resume. |
|
|
291
|
+
| `subagent_control` | `steer` a running RPC attempt with additional evidence/guidance, continuing the same thread with it when the thread has settled or is parked; `resume` a parked/settled thread with an optional appended `objective` and additive `scope`; `park` a running thread at a stable checkpoint, keeping its session and worktree for a later resume. |
|
|
234
292
|
| `subagent_stop` | Destructively cancel, deliver partial output, and retire the thread. Steering and follow-up messages still queued in the child are dropped so nothing can revive it later. |
|
|
235
293
|
|
|
236
294
|
```ts
|
|
@@ -239,6 +297,13 @@ subagent_control({ action: "park", id: 7 });
|
|
|
239
297
|
subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
|
|
240
298
|
```
|
|
241
299
|
|
|
300
|
+
A fresh dispatch stores `phaseId` and normalized `scope` on its stable thread and durable
|
|
301
|
+
v1 record. `resume` always keeps the thread's phase id. An optional resume `scope` adds
|
|
302
|
+
normalized claims to the retained scope; it cannot shrink or clear prior claims, so edits
|
|
303
|
+
already present in a retained worktree stay covered by admission. Resume also rechecks the
|
|
304
|
+
unioned scope against other active writer leases before starting a generation. Existing v1
|
|
305
|
+
manifests without these optional fields remain readable.
|
|
306
|
+
|
|
242
307
|
`steer` requires a nonblank `objective`. While the child RPC is running, it adds
|
|
243
308
|
guidance to the current phase without replacing the original task. If the thread has
|
|
244
309
|
already reached `completed`, `failed`, or `parked` — including a generation that settles
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { getConfigPath, loadConfig } from "./src/configuration/config.ts";
|
|
|
24
24
|
import { runSetup } from "./src/configuration/setup.ts";
|
|
25
25
|
import { discoverAgents } from "./src/delegation/agents.ts";
|
|
26
26
|
import { registerSubagentTool } from "./src/delegation/dispatch.ts";
|
|
27
|
+
import { registerSubagentRiskTool } from "./src/delegation/risk.ts";
|
|
27
28
|
import { buildDelegationDirective } from "./src/delegation/prompt.ts";
|
|
28
29
|
import { currentSubagentDepth } from "./src/execution/spawn.ts";
|
|
29
30
|
import { createRuntime } from "./src/lifecycle/runtime.ts";
|
|
@@ -69,6 +70,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
registerSubagentTool(pi, runtime);
|
|
73
|
+
registerSubagentRiskTool(pi);
|
|
72
74
|
registerLookupTools(pi, runtime);
|
|
73
75
|
|
|
74
76
|
pi.registerCommand("subagents-setup", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.9",
|
|
4
4
|
"description": "A managed sub-agent team for pi: scout, artisan, steward, and sentinel roles, durable threads, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/delegation/agents.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { StringEnum, type Usage } from "@earendil-works/pi-ai";
|
|
10
|
+
import { resolve } from "node:path";
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
13
|
import { Type } from "typebox";
|
|
13
|
-
import { discoverAgents } from "./agents.ts";
|
|
14
|
+
import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts";
|
|
14
15
|
import { loadConfig } from "../configuration/config.ts";
|
|
15
16
|
import { formatCompletionBlock, formatUsage } from "../presentation/format.ts";
|
|
16
17
|
import {
|
|
@@ -22,7 +23,17 @@ import {
|
|
|
22
23
|
sumUsage,
|
|
23
24
|
type RunWaitReason,
|
|
24
25
|
} from "../presentation/monitor.ts";
|
|
25
|
-
import { formatPhaseLeaseReceipt } from "./prompt.ts";
|
|
26
|
+
import { findDuplicateDispatch, formatParallelScopeAdmissionNote, formatPhaseLeaseReceipt } from "./prompt.ts";
|
|
27
|
+
import {
|
|
28
|
+
findPhaseScopeOverlap,
|
|
29
|
+
findWriterLeaseScopeOverlap,
|
|
30
|
+
normalizePhaseId,
|
|
31
|
+
normalizePhaseScope,
|
|
32
|
+
PHASE_ID_MAX_LENGTH,
|
|
33
|
+
PHASE_ID_PATTERN_SOURCE,
|
|
34
|
+
type PhaseScope,
|
|
35
|
+
type PhaseScopeInput,
|
|
36
|
+
} from "./phase-scope.ts";
|
|
26
37
|
import type { SubagentRuntime, SubagentThread } from "../lifecycle/runtime.ts";
|
|
27
38
|
import { createBackgroundDispatcher } from "../lifecycle/thread-lifecycle.ts";
|
|
28
39
|
import {
|
|
@@ -53,6 +64,23 @@ const IsolationSchema = Type.Optional(
|
|
|
53
64
|
StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
|
|
54
65
|
);
|
|
55
66
|
|
|
67
|
+
const PhaseIdSchema = Type.Optional(Type.String({
|
|
68
|
+
minLength: 1,
|
|
69
|
+
maxLength: PHASE_ID_MAX_LENGTH,
|
|
70
|
+
pattern: PHASE_ID_PATTERN_SOURCE,
|
|
71
|
+
description: "Stable logical phase id: 1-80 ASCII letters, numbers, or ._:- characters, starting with a letter or number. Reuse it when task wording changes so duplicate fresh dispatches are rejected.",
|
|
72
|
+
}));
|
|
73
|
+
const ScopeSchema = Type.Optional(Type.Object({
|
|
74
|
+
paths: Type.Optional(Type.Array(Type.String({
|
|
75
|
+
...NON_BLANK_TASK_OPTIONS,
|
|
76
|
+
description: "Exact file or directory write claim resolved from the caller-facing cwd; wildcard * and ? are rejected, while other punctuation is literal.",
|
|
77
|
+
}))),
|
|
78
|
+
symbols: Type.Optional(Type.Array(Type.Object({
|
|
79
|
+
path: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact file path resolved from the caller-facing cwd." }),
|
|
80
|
+
name: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact symbol name claimed for writing." }),
|
|
81
|
+
}))),
|
|
82
|
+
}, { description: "Declarative write-conflict metadata for admission, not filesystem permissions or a sandbox. If present, at least one valid claim is required." }));
|
|
83
|
+
|
|
56
84
|
const WaitSchema = Type.Optional(
|
|
57
85
|
Type.Boolean({
|
|
58
86
|
description:
|
|
@@ -69,6 +97,8 @@ const TaskItem = Type.Object({
|
|
|
69
97
|
...NON_BLANK_TASK_OPTIONS,
|
|
70
98
|
description: TASK_BRIEF_DESCRIPTION,
|
|
71
99
|
}),
|
|
100
|
+
phaseId: PhaseIdSchema,
|
|
101
|
+
scope: ScopeSchema,
|
|
72
102
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
73
103
|
isolation: IsolationSchema,
|
|
74
104
|
});
|
|
@@ -78,6 +108,8 @@ const SubagentParams = Type.Object({
|
|
|
78
108
|
task: Type.Optional(
|
|
79
109
|
Type.String({ ...NON_BLANK_TASK_OPTIONS, description: `${TASK_BRIEF_DESCRIPTION} (single mode)` }),
|
|
80
110
|
),
|
|
111
|
+
phaseId: PhaseIdSchema,
|
|
112
|
+
scope: ScopeSchema,
|
|
81
113
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })),
|
|
82
114
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
83
115
|
isolation: IsolationSchema,
|
|
@@ -109,6 +141,91 @@ export function defaultIsolationMode(
|
|
|
109
141
|
return mode === "parallel" && writeCapable ? "worktree" : "shared";
|
|
110
142
|
}
|
|
111
143
|
|
|
144
|
+
interface PreparedDispatchTask {
|
|
145
|
+
index: number;
|
|
146
|
+
agent: string;
|
|
147
|
+
task: string;
|
|
148
|
+
cwd: string;
|
|
149
|
+
phaseId?: string;
|
|
150
|
+
scope?: PhaseScope;
|
|
151
|
+
isolation?: IsolationMode;
|
|
152
|
+
writeCapable: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function prepareDispatchTasks(
|
|
156
|
+
tasks: ReadonlyArray<{ agent: string; task: string; cwd?: string; phaseId?: string; scope?: PhaseScopeInput; isolation?: IsolationMode }>,
|
|
157
|
+
callerCwd: string,
|
|
158
|
+
agents: readonly AgentConfig[],
|
|
159
|
+
): PreparedDispatchTask[] {
|
|
160
|
+
return tasks.map((item, index) => {
|
|
161
|
+
const cwd = resolve(callerCwd, item.cwd ?? ".");
|
|
162
|
+
const agent = agents.find((candidate) => candidate.name === item.agent);
|
|
163
|
+
return {
|
|
164
|
+
index,
|
|
165
|
+
agent: item.agent,
|
|
166
|
+
task: item.task,
|
|
167
|
+
cwd,
|
|
168
|
+
phaseId: normalizePhaseId(item.phaseId),
|
|
169
|
+
scope: normalizePhaseScope(item.scope, cwd),
|
|
170
|
+
isolation: item.isolation,
|
|
171
|
+
writeCapable: agent ? isWriteCapableAgent(agent) : true,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parallelAdmissionConflict(
|
|
177
|
+
tasks: readonly PreparedDispatchTask[],
|
|
178
|
+
threads: Iterable<SubagentThread>,
|
|
179
|
+
): string | undefined {
|
|
180
|
+
for (let leftIndex = 0; leftIndex < tasks.length; leftIndex++) {
|
|
181
|
+
for (let rightIndex = leftIndex + 1; rightIndex < tasks.length; rightIndex++) {
|
|
182
|
+
const left = tasks[leftIndex]!;
|
|
183
|
+
const right = tasks[rightIndex]!;
|
|
184
|
+
const duplicate = findDuplicateDispatch([{
|
|
185
|
+
id: left.index,
|
|
186
|
+
agentName: left.agent,
|
|
187
|
+
task: left.task,
|
|
188
|
+
phaseId: left.phaseId,
|
|
189
|
+
cwd: left.cwd,
|
|
190
|
+
state: "queued",
|
|
191
|
+
}], right.task, right.cwd, right.phaseId);
|
|
192
|
+
if (duplicate) {
|
|
193
|
+
return `deterministic duplicate between tasks[${left.index}] and tasks[${right.index}]`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const leases = [...threads];
|
|
198
|
+
for (const task of tasks) {
|
|
199
|
+
const duplicate = findDuplicateDispatch(leases, task.task, task.cwd, task.phaseId);
|
|
200
|
+
if (duplicate?.kind === "active") {
|
|
201
|
+
return `tasks[${task.index}] duplicates active run #${duplicate.source.id} (${duplicate.source.agentName})`;
|
|
202
|
+
}
|
|
203
|
+
if (duplicate?.kind === "settled") {
|
|
204
|
+
return `tasks[${task.index}] duplicates settled run #${duplicate.source.id} (${duplicate.source.agentName}); resume that retained thread instead`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const writers = tasks.filter(
|
|
208
|
+
(task): task is PreparedDispatchTask & { scope: PhaseScope } => task.writeCapable && task.scope !== undefined,
|
|
209
|
+
);
|
|
210
|
+
for (let leftIndex = 0; leftIndex < writers.length; leftIndex++) {
|
|
211
|
+
for (let rightIndex = leftIndex + 1; rightIndex < writers.length; rightIndex++) {
|
|
212
|
+
const left = writers[leftIndex]!;
|
|
213
|
+
const right = writers[rightIndex]!;
|
|
214
|
+
const overlap = findPhaseScopeOverlap(left.scope, right.scope);
|
|
215
|
+
if (overlap) {
|
|
216
|
+
return `tasks[${left.index}] scope ${overlap.left} overlaps tasks[${right.index}] scope ${overlap.right}`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const task of writers) {
|
|
221
|
+
const conflict = findWriterLeaseScopeOverlap(task.scope, leases);
|
|
222
|
+
if (conflict) {
|
|
223
|
+
return `tasks[${task.index}] scope ${conflict.overlap.left} overlaps run #${conflict.lease.id} scope ${conflict.overlap.right}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
112
229
|
/** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
|
|
113
230
|
* token spend lands in the parent's footer, /session, and RPC session totals
|
|
114
231
|
* instead of being invisible. Only the total cost is known here: a child
|
|
@@ -325,11 +442,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
325
442
|
(mode: "single" | "parallel", background = false) =>
|
|
326
443
|
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
327
444
|
|
|
328
|
-
const phaseLeaseReceipt = (
|
|
445
|
+
const phaseLeaseReceipt = (
|
|
446
|
+
runIds: number[],
|
|
447
|
+
options: { mode: "single" } | { mode: "parallel"; declaredScopesComplete: boolean },
|
|
448
|
+
): string =>
|
|
329
449
|
formatPhaseLeaseReceipt(
|
|
330
450
|
runIds
|
|
331
451
|
.map((runId) => runtime.threads.get(runId))
|
|
332
452
|
.filter((thread): thread is SubagentThread => thread !== undefined),
|
|
453
|
+
options,
|
|
333
454
|
);
|
|
334
455
|
|
|
335
456
|
/** Pacing note appended to dispatch confirmations whenever runs are actually
|
|
@@ -378,7 +499,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
378
499
|
pi.registerTool({
|
|
379
500
|
name: "subagent",
|
|
380
501
|
label: "Subagent",
|
|
381
|
-
description: "Start paid leaf runs for
|
|
502
|
+
description: "Start paid leaf runs for substantial self-contained work. phaseId is a stable single-line logical identity; exact task+cwd remains the compatibility fallback. scope declares exact write-conflict metadata (not permissions or sandboxing). Fresh single and resumed writer scopes are checked against active leases; parallel batches also preflight deterministic duplicates and all declared scope overlaps before allocation. A parallel batch that omits scope reports `independence not verified`; declared claims do not prove natural-language task independence. wait:true returns results in-turn; otherwise completions wake main.",
|
|
382
503
|
parameters: SubagentParams,
|
|
383
504
|
|
|
384
505
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -452,10 +573,21 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
452
573
|
// each delegated phase. The queue paces child processes without changing
|
|
453
574
|
// phase ownership or requiring a per-call task cap.
|
|
454
575
|
if (params.tasks && params.tasks.length > 0) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
576
|
+
let prepared: PreparedDispatchTask[];
|
|
577
|
+
try {
|
|
578
|
+
prepared = prepareDispatchTasks(params.tasks, ctx.cwd, agents);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
throw new Error(`Parallel admission rejected: ${error instanceof Error ? error.message : String(error)} No background tasks were started.`);
|
|
581
|
+
}
|
|
582
|
+
const conflict = parallelAdmissionConflict(prepared, runtime.threads.values());
|
|
583
|
+
if (conflict) {
|
|
584
|
+
throw new Error(`Parallel admission rejected: ${conflict}. No background tasks were started.`);
|
|
585
|
+
}
|
|
586
|
+
const declaredScopesComplete = prepared.every((item) => item.scope !== undefined);
|
|
587
|
+
const admissionNote = formatParallelScopeAdmissionNote(declaredScopesComplete);
|
|
588
|
+
// Duplicate and scope admission completes for the whole batch before any
|
|
589
|
+
// startBackground call can allocate a run. Worktree preparation stays queued.
|
|
590
|
+
const results = await Promise.all(prepared.map((item) => {
|
|
459
591
|
const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
|
|
460
592
|
return startBackground(
|
|
461
593
|
item.agent,
|
|
@@ -464,11 +596,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
464
596
|
defaultIsolationMode(
|
|
465
597
|
"parallel",
|
|
466
598
|
item.agent,
|
|
467
|
-
item.isolation
|
|
599
|
+
item.isolation,
|
|
468
600
|
catalogAgent ? isWorktreeCapableAgent(catalogAgent) : undefined,
|
|
469
601
|
catalogAgent?.isolation,
|
|
470
602
|
),
|
|
471
|
-
{
|
|
603
|
+
{
|
|
604
|
+
deliveryRoute: params.wait ? "await" : "background",
|
|
605
|
+
phaseId: item.phaseId,
|
|
606
|
+
scope: item.scope,
|
|
607
|
+
writeCapable: item.writeCapable,
|
|
608
|
+
},
|
|
472
609
|
);
|
|
473
610
|
}));
|
|
474
611
|
const startedRuns = results.filter((result) => result.exitCode === -1);
|
|
@@ -492,9 +629,10 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
492
629
|
const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
|
|
493
630
|
if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds);
|
|
494
631
|
else runtime.completeAwaitDelivery(startedIds);
|
|
495
|
-
const
|
|
632
|
+
const resultText = failureLines.length > 0
|
|
496
633
|
? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}`
|
|
497
634
|
: blocks;
|
|
635
|
+
const text = `${resultText}\n\n${admissionNote}`;
|
|
498
636
|
return {
|
|
499
637
|
content: [{ type: "text", text }],
|
|
500
638
|
details: makeDetails("parallel", true)(results),
|
|
@@ -502,7 +640,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
502
640
|
};
|
|
503
641
|
}
|
|
504
642
|
const text = [
|
|
505
|
-
phaseLeaseReceipt(startedIds),
|
|
643
|
+
phaseLeaseReceipt(startedIds, { mode: "parallel", declaredScopesComplete }),
|
|
506
644
|
...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []),
|
|
507
645
|
].join("\n") + queuePacingNote();
|
|
508
646
|
return {
|
|
@@ -511,19 +649,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
511
649
|
};
|
|
512
650
|
}
|
|
513
651
|
|
|
514
|
-
|
|
652
|
+
let single: PreparedDispatchTask;
|
|
653
|
+
try {
|
|
654
|
+
single = prepareDispatchTasks([{
|
|
655
|
+
agent: params.agent as string,
|
|
656
|
+
task: params.task as string,
|
|
657
|
+
cwd: params.cwd,
|
|
658
|
+
phaseId: params.phaseId,
|
|
659
|
+
scope: params.scope,
|
|
660
|
+
isolation: params.isolation as IsolationMode | undefined,
|
|
661
|
+
}], ctx.cwd, agents)[0]!;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
throw new Error(`Dispatch admission rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
664
|
+
}
|
|
665
|
+
const singleCatalogAgent = agents.find((candidate) => candidate.name === single.agent);
|
|
515
666
|
const result = await startBackground(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
667
|
+
single.agent,
|
|
668
|
+
single.task,
|
|
669
|
+
single.cwd,
|
|
519
670
|
defaultIsolationMode(
|
|
520
671
|
"single",
|
|
521
|
-
|
|
522
|
-
|
|
672
|
+
single.agent,
|
|
673
|
+
single.isolation,
|
|
523
674
|
singleCatalogAgent ? isWorktreeCapableAgent(singleCatalogAgent) : undefined,
|
|
524
675
|
singleCatalogAgent?.isolation,
|
|
525
676
|
),
|
|
526
|
-
{
|
|
677
|
+
{
|
|
678
|
+
deliveryRoute: params.wait ? "await" : "background",
|
|
679
|
+
phaseId: single.phaseId,
|
|
680
|
+
scope: single.scope,
|
|
681
|
+
writeCapable: single.writeCapable,
|
|
682
|
+
},
|
|
527
683
|
);
|
|
528
684
|
if (result.exitCode !== -1) {
|
|
529
685
|
throw new Error(getResultOutput(result));
|
|
@@ -541,7 +697,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
541
697
|
return {
|
|
542
698
|
content: [{
|
|
543
699
|
type: "text",
|
|
544
|
-
text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId]) + queuePacingNote(),
|
|
700
|
+
text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId], { mode: "single" }) + queuePacingNote(),
|
|
545
701
|
}],
|
|
546
702
|
details: makeDetails("single", true)([result]),
|
|
547
703
|
};
|