@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -8
- package/package.json +1 -1
- package/src/agent-manager.ts +307 -110
- package/src/index.ts +40 -17
- package/src/invocation-config.ts +5 -19
- package/src/nested-tools.ts +9 -0
- package/src/process-budget.ts +343 -0
- package/src/schedule.ts +5 -1
- package/src/settings.ts +75 -2
- package/src/types.ts +6 -0
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./
|
|
|
48
48
|
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
49
49
|
import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
50
50
|
import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
|
|
51
|
+
import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
|
|
51
52
|
import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
52
53
|
import { SubagentScheduler } from "./schedule.js";
|
|
53
54
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
@@ -336,6 +337,20 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
336
337
|
// injected as scoped custom tools by the existing manager instead.
|
|
337
338
|
if (inChildSessionContext()) return;
|
|
338
339
|
|
|
340
|
+
// A factory throw is only a load warning. Check shared-state compatibility
|
|
341
|
+
// before registering anything, and make an incompatible reload fail the
|
|
342
|
+
// bind through the host's fatal session_start path instead of loading half
|
|
343
|
+
// an extension (or continuing without the process-wide budget).
|
|
344
|
+
const StartupError = hostStartupErrorClass();
|
|
345
|
+
try {
|
|
346
|
+
getBudgetPolicy();
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
349
|
+
if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
|
|
350
|
+
pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
339
354
|
// ---- Register custom notification renderer ----
|
|
340
355
|
// Core collapses a custom message to this renderer's first line and draws the
|
|
341
356
|
// ▸/▾ gutter itself; see ui/notification-view.ts.
|
|
@@ -377,7 +392,6 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
377
392
|
// Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
|
|
378
393
|
// from `session_start`. A host without that class would silently degrade to
|
|
379
394
|
// warnings, so refuse here, before any project agent definition is read.
|
|
380
|
-
const StartupError = hostStartupErrorClass();
|
|
381
395
|
if (strictAgentFiles && !StartupError) {
|
|
382
396
|
throw new Error(
|
|
383
397
|
"[pi-subagents] strictAgentFiles requires a pi host that exports ExtensionStartupError, which this host lacks. " +
|
|
@@ -1683,6 +1697,13 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1683
1697
|
return name.replace(/-\d{8}$/, "");
|
|
1684
1698
|
}
|
|
1685
1699
|
|
|
1700
|
+
/** The "queued" line of a background result; names the process budget only when it is on. */
|
|
1701
|
+
const queuedPositionLine = (): string => {
|
|
1702
|
+
const { maxActive } = getBudgetPolicy();
|
|
1703
|
+
const budget = maxActive > 0 ? `, ${maxActive} process-wide` : "";
|
|
1704
|
+
return `Position: queued (max ${manager.getMaxConcurrent()} concurrent${budget})\n`;
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1686
1707
|
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
1687
1708
|
// Global + project merged; missing → defaults; corrupt file emits a warning
|
|
1688
1709
|
// to stderr and falls back to defaults.
|
|
@@ -1690,6 +1711,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1690
1711
|
{
|
|
1691
1712
|
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
1692
1713
|
setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
|
|
1714
|
+
setBudgetPolicy,
|
|
1693
1715
|
setDefaultMaxTurns,
|
|
1694
1716
|
setGraceTurns,
|
|
1695
1717
|
setDefaultJoinMode,
|
|
@@ -2092,9 +2114,6 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2092
2114
|
modelName,
|
|
2093
2115
|
modelId,
|
|
2094
2116
|
thinking,
|
|
2095
|
-
// Only set where the agent file outranked the caller, so the surfaces can
|
|
2096
|
-
// disclose a parameter that was accepted but could not take effect (#182).
|
|
2097
|
-
requestedThinking: resolvedConfig.overridden?.thinking,
|
|
2098
2117
|
// Explicit value only — the default fallback would just add noise.
|
|
2099
2118
|
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
2100
2119
|
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
@@ -2206,19 +2225,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2206
2225
|
// a background spawn. Previously run_in_background was silently ignored
|
|
2207
2226
|
// on resume (this branch returned before the background branch below),
|
|
2208
2227
|
// so a resumed agent always blocked the main loop until it finished.
|
|
2228
|
+
// A detached resume hands control back while the record stays
|
|
2229
|
+
// "running", and parallel tool calls can resume one agent twice in a
|
|
2230
|
+
// turn, so nothing stops the model from resuming an agent mid-run.
|
|
2231
|
+
// manager.resume() refuses that on both paths (it would orphan the live
|
|
2232
|
+
// run's abort controller); say why here, where the model can act on it,
|
|
2233
|
+
// instead of letting it read as a generic failure.
|
|
2234
|
+
if (existing.status === "running" || existing.status === "queued") {
|
|
2235
|
+
return textResult(
|
|
2236
|
+
`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
2237
|
+
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2209
2241
|
if (runInBackground) {
|
|
2210
2242
|
const id = existing.id;
|
|
2211
|
-
// A detached resume hands control back while the record stays
|
|
2212
|
-
// "running", so nothing stops the model from resuming the same agent
|
|
2213
|
-
// again mid-run. manager.resume() refuses that (it would orphan the
|
|
2214
|
-
// live run's abort controller); say why here, where the model can act
|
|
2215
|
-
// on it, instead of letting it read as a generic failure.
|
|
2216
|
-
if (existing.status === "running" || existing.status === "queued") {
|
|
2217
|
-
return textResult(
|
|
2218
|
-
`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
2219
|
-
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
|
|
2220
|
-
);
|
|
2221
|
-
}
|
|
2222
2243
|
|
|
2223
2244
|
const record = await startBackgroundResume(ctx, existing, params.prompt, {
|
|
2224
2245
|
outputTranscript,
|
|
@@ -2235,7 +2256,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2235
2256
|
`Agent ID: ${id}\n` +
|
|
2236
2257
|
`Type: ${existing.type}\n` +
|
|
2237
2258
|
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2238
|
-
(isQueued ?
|
|
2259
|
+
(isQueued ? queuedPositionLine() : "") +
|
|
2239
2260
|
`\nYou will be notified when this agent completes.\n` +
|
|
2240
2261
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
|
|
2241
2262
|
{ ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
@@ -2292,7 +2313,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2292
2313
|
`Type: ${displayName}\n` +
|
|
2293
2314
|
`Description: ${params.description}\n` +
|
|
2294
2315
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2295
|
-
(isQueued ?
|
|
2316
|
+
(isQueued ? queuedPositionLine() : "") +
|
|
2296
2317
|
`\nYou will be notified when this agent completes.\n` +
|
|
2297
2318
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2298
2319
|
`Do not duplicate this agent's work.`,
|
|
@@ -3838,6 +3859,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3838
3859
|
// goes away. undefined is dropped by JSON.stringify.
|
|
3839
3860
|
fallbackSubagent: getFallbackSubagent(),
|
|
3840
3861
|
// Global-only (see loadSettings): never written into the project file.
|
|
3862
|
+
maxActiveSubagents: undefined,
|
|
3863
|
+
nestedSubagentReserve: undefined,
|
|
3841
3864
|
modelAliases: undefined,
|
|
3842
3865
|
agentModels: undefined,
|
|
3843
3866
|
reportUsage: isReportUsageEnabled(),
|
package/src/invocation-config.ts
CHANGED
|
@@ -143,16 +143,6 @@ export function resolveAgentInvocationConfig(
|
|
|
143
143
|
runInBackground: boolean;
|
|
144
144
|
isolated: boolean;
|
|
145
145
|
isolation?: IsolationMode;
|
|
146
|
-
/**
|
|
147
|
-
* Caller thinking levels an agent file's frontmatter outranked, so surfaces
|
|
148
|
-
* can say "(asked X)" instead of presenting the effective value as requested.
|
|
149
|
-
* `model` deliberately does not behave this way: an explicit `Agent` model is
|
|
150
|
-
* an intentional dispatch choice and takes precedence over an agent default.
|
|
151
|
-
*
|
|
152
|
-
* `max_turns` is deliberately absent: no surface renders a requested-vs-
|
|
153
|
-
* effective turn limit, so recording one would be dead data.
|
|
154
|
-
*/
|
|
155
|
-
overridden?: { thinking?: ThinkingLevel };
|
|
156
146
|
} {
|
|
157
147
|
// Precedence first, collapse second — reversing these loses the veto, since
|
|
158
148
|
// an agent file's "off" only outranks a caller's "worktree" while it is still
|
|
@@ -160,10 +150,6 @@ export function resolveAgentInvocationConfig(
|
|
|
160
150
|
const requested = agentConfig?.isolation ?? params.isolation;
|
|
161
151
|
const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
|
|
162
152
|
|
|
163
|
-
const overriddenThinking = agentConfig?.thinking != null && params.thinking != null
|
|
164
|
-
&& agentConfig.thinking !== params.thinking
|
|
165
|
-
? params.thinking as ThinkingLevel
|
|
166
|
-
: undefined;
|
|
167
153
|
return {
|
|
168
154
|
// A frontmatter model is a portable default, not a policy boundary. An
|
|
169
155
|
// explicit model on the Agent call must win so an orchestrator can select a
|
|
@@ -171,16 +157,16 @@ export function resolveAgentInvocationConfig(
|
|
|
171
157
|
// it, the global `agentModels` setting outranks the definition's own default.
|
|
172
158
|
modelInput: params.model ?? configuredAgentModel(opts?.agentType ?? agentConfig?.name ?? "", agentConfig?.model),
|
|
173
159
|
modelFromParams: params.model != null,
|
|
174
|
-
|
|
160
|
+
// A specialist's frontmatter is a reusable default, not a policy boundary:
|
|
161
|
+
// an explicit dispatch chooses the effort for this run. Pi still clamps the
|
|
162
|
+
// selected level to the resolved model, and agent-manager records that
|
|
163
|
+
// requested-versus-effective difference after the session starts.
|
|
164
|
+
thinking: (params.thinking ?? agentConfig?.thinking) as ThinkingLevel | undefined,
|
|
175
165
|
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
176
166
|
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
177
167
|
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? opts?.defaultRunInBackground ?? false,
|
|
178
168
|
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
179
169
|
isolation,
|
|
180
|
-
// Undefined rather than an empty object when nothing was overridden: callers
|
|
181
|
-
// spread this into the invocation snapshot, and an always-present key would
|
|
182
|
-
// put `requestedThinking: undefined` on every record.
|
|
183
|
-
overridden: overriddenThinking ? { thinking: overriddenThinking } : undefined,
|
|
184
170
|
};
|
|
185
171
|
}
|
|
186
172
|
|
package/src/nested-tools.ts
CHANGED
|
@@ -221,6 +221,15 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
221
221
|
if (!ownsRecord(existing, context.parentAgentId)) {
|
|
222
222
|
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
|
|
223
223
|
}
|
|
224
|
+
// Two resumes of one child in the same turn (parallel tool calls) would
|
|
225
|
+
// run two prompts on one session; the manager refuses the second, and
|
|
226
|
+
// this says why instead of a generic failure.
|
|
227
|
+
if (existing.status === "running" || existing.status === "queued") {
|
|
228
|
+
return textResult(
|
|
229
|
+
`Nested agent "${params.resume}" is still ${existing.status}; it can only be resumed once its current run finishes.`,
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
224
233
|
// Frontmatter authoritative, as on the spawn path; the manager falls
|
|
225
234
|
// back to the limit the child was spawned with, then the default.
|
|
226
235
|
const resumed = await context.manager.resume(params.resume, params.prompt, signal, {
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-budget.ts -- one ceiling on running subagents for the whole pi process.
|
|
3
|
+
*
|
|
4
|
+
* `maxConcurrent` and `maxConcurrentForeground` are per `AgentManager`, and
|
|
5
|
+
* there can be more than one manager per process: `/new`, `/resume`, `/fork`
|
|
6
|
+
* and `/reload` activate a fresh one and leave the previous manager alive until
|
|
7
|
+
* its background agents finish. (Subagent sessions do not activate this
|
|
8
|
+
* extension; nested children run in the parent's manager.) Those pools also
|
|
9
|
+
* exempt nested children, workflow children, scheduled jobs and resumes. The budget is the one count all of them share, kept on `globalThis`
|
|
10
|
+
* under `Symbol.for` so every manager and every module instance (a `/reload`
|
|
11
|
+
* loads a fresh copy of this file) sees the same state.
|
|
12
|
+
*
|
|
13
|
+
* Scope is the PROCESS, not the machine: two pi processes each get their own
|
|
14
|
+
* budget. It bounds running agents, not CPU, memory or commands.
|
|
15
|
+
*
|
|
16
|
+
* Every run holds one lease from the moment it is admitted until it actually
|
|
17
|
+
* settles. A parent keeps its lease while it waits on a child: releasing it
|
|
18
|
+
* around a nested call would be wrong, because pi runs a turn's tool calls in
|
|
19
|
+
* parallel and the parent's other tools are still executing.
|
|
20
|
+
*
|
|
21
|
+
* Holding parent leases is what makes a plain cap deadlock: twelve parents
|
|
22
|
+
* each waiting on a child that can never start. The reserve prevents it. With
|
|
23
|
+
* `L` the policy's `maxDepth` (one value for the whole process, NOT a branch's
|
|
24
|
+
* own cap) and `limit(j) = maxActive - nestedReserve * (L - j)`, a run at depth
|
|
25
|
+
* `d` is admitted only while, for EVERY level `j` from `d` to `L`,
|
|
26
|
+
*
|
|
27
|
+
* active at depth <= j < limit(j)
|
|
28
|
+
*
|
|
29
|
+
* (`j = L` is the plain `active < maxActive`). So with `maxActive: 12,
|
|
30
|
+
* nestedReserve: 2, L: 2`, at most ten top-level runs hold slots at once, and
|
|
31
|
+
* the last two only ever go to depth-2 children. Every admission keeps
|
|
32
|
+
* `count(<= j) <= limit(j)` for all `j`, releases only lower counts, and
|
|
33
|
+
* policy changes that could break it wait for an idle process (see
|
|
34
|
+
* `setBudgetPolicy`). Suppose every holder is a parent blocked on a child and
|
|
35
|
+
* `k < L` is the deepest level holding any: then `active = count(<= k) <=
|
|
36
|
+
* limit(k) < limit(k + 1) <= maxActive`, so a depth-`k+1` child fits at every
|
|
37
|
+
* level. Leaves (depth `L`) never wait on anyone, so they finish. That needs
|
|
38
|
+
* `limit` strictly increasing and `limit(1) >= 1` -- `isCoherent` -- and a run
|
|
39
|
+
* deeper than `L` refused outright (`assertWithinBudgetDepth`), since nothing
|
|
40
|
+
* reserves a slot for it. Top-level, workflow and scheduled runs all count as
|
|
41
|
+
* depth 1.
|
|
42
|
+
*
|
|
43
|
+
* Waiters are served first-come-first-served among the ELIGIBLE: a deeper
|
|
44
|
+
* waiter may pass a shallower one whose threshold is not met, which is what the
|
|
45
|
+
* reserve is for. Without that, a root at the head of the line would block the
|
|
46
|
+
* child that its own siblings are waiting on.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const BUDGET_KEY = Symbol.for("pi-subagents:process-budget");
|
|
50
|
+
|
|
51
|
+
/** Bumped on any incompatible change to `BudgetState`'s shape. */
|
|
52
|
+
const BUDGET_VERSION = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Queued starts the budget will hold while it is enabled. Only a bound on
|
|
56
|
+
* memory and on runaway fan-out -- a full queue fails the spawn with
|
|
57
|
+
* `BUDGET_QUEUE_FULL_ERROR` instead of parking work nobody will see start.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_PENDING_SUBAGENTS = 128;
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_BUDGET_POLICY: Readonly<BudgetPolicy> = {
|
|
62
|
+
maxActive: 0,
|
|
63
|
+
nestedReserve: 2,
|
|
64
|
+
maxDepth: 2,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface BudgetPolicy {
|
|
68
|
+
/** Process-wide ceiling on running subagents. `0` = off (counted, never enforced). */
|
|
69
|
+
maxActive: number;
|
|
70
|
+
/** Slots held back per nesting level below a run, so children can always start. */
|
|
71
|
+
nestedReserve: number;
|
|
72
|
+
/**
|
|
73
|
+
* The deepest level the reserve covers: the GLOBAL `maxSubagentDepth`, so a
|
|
74
|
+
* project file cannot reshape a process-wide policy. A deeper run is refused.
|
|
75
|
+
*/
|
|
76
|
+
maxDepth: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One admitted run. Released exactly once; later releases are no-ops. */
|
|
80
|
+
export interface BudgetLease {
|
|
81
|
+
readonly depth: number;
|
|
82
|
+
released: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A start waiting for admission. */
|
|
86
|
+
export interface BudgetWaiter {
|
|
87
|
+
/** Nesting depth of the run: top-level = 1. */
|
|
88
|
+
depth: number;
|
|
89
|
+
/** Local conditions the owner also needs (its own pool limits). */
|
|
90
|
+
ready: () => boolean;
|
|
91
|
+
/** Takes ownership of the lease. Must not throw; a throw is swallowed and the lease released. */
|
|
92
|
+
grant: (lease: BudgetLease) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface BudgetState {
|
|
96
|
+
version: number;
|
|
97
|
+
policy: BudgetPolicy;
|
|
98
|
+
/** A tightening change, applied once no lease is active (`setBudgetPolicy`). */
|
|
99
|
+
pendingPolicy: BudgetPolicy | undefined;
|
|
100
|
+
active: Set<BudgetLease>;
|
|
101
|
+
waiters: BudgetWaiter[];
|
|
102
|
+
pumping: boolean;
|
|
103
|
+
repump: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const BUDGET_QUEUE_FULL_ERROR =
|
|
107
|
+
`Subagent queue is full (${MAX_PENDING_SUBAGENTS} waiting for the process-wide maxActiveSubagents limit). `
|
|
108
|
+
+ "Wait for running agents to finish, or do the work directly.";
|
|
109
|
+
|
|
110
|
+
function freshState(): BudgetState {
|
|
111
|
+
return {
|
|
112
|
+
version: BUDGET_VERSION,
|
|
113
|
+
policy: { ...DEFAULT_BUDGET_POLICY },
|
|
114
|
+
pendingPolicy: undefined,
|
|
115
|
+
active: new Set(),
|
|
116
|
+
waiters: [],
|
|
117
|
+
pumping: false,
|
|
118
|
+
repump: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The process's budget state. An incompatible version (a `/reload` onto a
|
|
124
|
+
* build that changed the shape) refuses budget operations until pi restarts.
|
|
125
|
+
* Never fork the state: two private counts could each admit the whole cap.
|
|
126
|
+
*/
|
|
127
|
+
function state(): BudgetState {
|
|
128
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
129
|
+
const existing = g[BUDGET_KEY] as BudgetState | undefined;
|
|
130
|
+
if (existing === undefined) {
|
|
131
|
+
const created = freshState();
|
|
132
|
+
g[BUDGET_KEY] = created;
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
if (existing.version === BUDGET_VERSION) return existing;
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[pi-subagents] Incompatible process budget v${String(existing.version)} already loaded (this build uses v${String(BUDGET_VERSION)}). `
|
|
138
|
+
+ "Restart pi before starting more subagents; no private budget will be created.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The deepest level `policy` reserves for; nesting off still means one level. */
|
|
143
|
+
const topLevel = (policy: BudgetPolicy): number => Math.max(1, policy.maxDepth);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How many runs at depth <= `level` may hold leases before a run at `level` or
|
|
147
|
+
* shallower has to wait. No floor: `isCoherent` keeps it at least one.
|
|
148
|
+
*/
|
|
149
|
+
export function admissionLimit(policy: BudgetPolicy, level: number): number {
|
|
150
|
+
if (policy.maxActive <= 0) return Number.POSITIVE_INFINITY;
|
|
151
|
+
return policy.maxActive - policy.nestedReserve * (topLevel(policy) - level);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether `policy` is deadlock-free: off, or `limit` strictly increasing from
|
|
156
|
+
* at least one at depth 1 -- a reserve of one or more, and room for one
|
|
157
|
+
* top-level run after reserving for every level below it.
|
|
158
|
+
*/
|
|
159
|
+
export function isCoherent(policy: BudgetPolicy): boolean {
|
|
160
|
+
if (policy.maxActive <= 0 || topLevel(policy) === 1) return true;
|
|
161
|
+
return policy.nestedReserve >= 1 && admissionLimit(policy, 1) >= 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every level's limit is at least what it was, so no held lease can block a child that fit before. */
|
|
165
|
+
function relaxes(from: BudgetPolicy, to: BudgetPolicy): boolean {
|
|
166
|
+
if (to.maxActive <= 0) return true;
|
|
167
|
+
if (from.maxActive <= 0 || topLevel(from) !== topLevel(to)) return false;
|
|
168
|
+
for (let level = 1; level <= topLevel(to); level++) {
|
|
169
|
+
if (admissionLimit(to, level) < admissionLimit(from, level)) return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getBudgetPolicy(): BudgetPolicy {
|
|
175
|
+
return { ...state().policy };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Replace the policy. Last writer wins: every activation in the process
|
|
180
|
+
* shares it. An incoherent policy is refused with a warning. A change that
|
|
181
|
+
* could strand a waiting child -- turning the budget on, lowering a limit,
|
|
182
|
+
* changing `maxDepth` -- is held until no lease is active, since running
|
|
183
|
+
* parents were admitted under the old limits; turning it off or raising
|
|
184
|
+
* limits applies at once.
|
|
185
|
+
*/
|
|
186
|
+
export function setBudgetPolicy(next: BudgetPolicy): void {
|
|
187
|
+
const policy: BudgetPolicy = {
|
|
188
|
+
maxActive: Math.max(0, Math.floor(next.maxActive)),
|
|
189
|
+
nestedReserve: Math.max(0, Math.floor(next.nestedReserve)),
|
|
190
|
+
maxDepth: Math.max(0, Math.floor(next.maxDepth)),
|
|
191
|
+
};
|
|
192
|
+
if (!isCoherent(policy)) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`[pi-subagents] Ignoring maxActiveSubagents ${String(policy.maxActive)} with nestedSubagentReserve ${String(policy.nestedReserve)} `
|
|
195
|
+
+ `at maxSubagentDepth ${String(policy.maxDepth)}: it could deadlock. Nesting needs a reserve of at least 1 and `
|
|
196
|
+
+ "maxActiveSubagents >= nestedSubagentReserve * (maxSubagentDepth - 1) + 1.",
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const s = state();
|
|
201
|
+
if (s.active.size === 0 || relaxes(s.policy, policy)) {
|
|
202
|
+
s.policy = policy;
|
|
203
|
+
s.pendingPolicy = undefined;
|
|
204
|
+
pumpBudget();
|
|
205
|
+
} else {
|
|
206
|
+
s.pendingPolicy = policy;
|
|
207
|
+
console.warn(
|
|
208
|
+
`[pi-subagents] Process budget change deferred until all running subagents settle: `
|
|
209
|
+
+ `maxActiveSubagents=${String(policy.maxActive)}, nestedSubagentReserve=${String(policy.nestedReserve)}, `
|
|
210
|
+
+ `maxSubagentDepth=${String(policy.maxDepth)}. The previous policy remains in force; continuous work can delay this indefinitely.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The policy waiting for the process to go idle, if any. For diagnostics and tests. */
|
|
216
|
+
export function getPendingBudgetPolicy(): BudgetPolicy | undefined {
|
|
217
|
+
const pending = state().pendingPolicy;
|
|
218
|
+
return pending === undefined ? undefined : { ...pending };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Throw when the budget is on and a run at `depth` is deeper than its policy
|
|
223
|
+
* reserves for: it could wait forever behind its own ancestors. Pass/fail is
|
|
224
|
+
* fixed for the run's life, since `maxDepth` changes only while idle.
|
|
225
|
+
*/
|
|
226
|
+
export function assertWithinBudgetDepth(depth: number): void {
|
|
227
|
+
const { policy } = state();
|
|
228
|
+
if (policy.maxActive > 0 && depth > topLevel(policy)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Nested subagent at depth ${String(depth)} exceeds the process budget's depth ${String(topLevel(policy))} `
|
|
231
|
+
+ "(global maxSubagentDepth). Complete the task directly.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Running leases and waiting starts, for diagnostics and tests. */
|
|
237
|
+
export function budgetUsage(): { active: number; pending: number; maxActive: number } {
|
|
238
|
+
const s = state();
|
|
239
|
+
return { active: s.active.size, pending: s.waiters.length, maxActive: s.policy.maxActive };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function admits(s: BudgetState, depth: number): boolean {
|
|
243
|
+
const { policy } = s;
|
|
244
|
+
if (policy.maxActive <= 0) return true;
|
|
245
|
+
const top = topLevel(policy);
|
|
246
|
+
const level = Math.max(1, depth);
|
|
247
|
+
if (level > top) return false;
|
|
248
|
+
// Leases deeper than `top` cannot exist while it is on; clamp so they still count in the total.
|
|
249
|
+
const atDepth = new Array<number>(top + 1).fill(0);
|
|
250
|
+
for (const lease of s.active) atDepth[Math.min(top, Math.max(1, lease.depth))]++;
|
|
251
|
+
let atOrAbove = 0;
|
|
252
|
+
for (let j = 1; j <= top; j++) {
|
|
253
|
+
atOrAbove += atDepth[j];
|
|
254
|
+
if (j >= level && atOrAbove >= admissionLimit(policy, j)) return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function newLease(s: BudgetState, depth: number): BudgetLease {
|
|
260
|
+
const lease: BudgetLease = { depth, released: false };
|
|
261
|
+
s.active.add(lease);
|
|
262
|
+
return lease;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Take a lease now, or undefined when this depth is at its limit or an earlier
|
|
267
|
+
* eligible waiter has first claim. Completion listeners can spawn synchronously
|
|
268
|
+
* between release and drain; re-entrant grant callbacks can do the same.
|
|
269
|
+
*/
|
|
270
|
+
export function tryAcquireBudget(depth: number): BudgetLease | undefined {
|
|
271
|
+
const s = state();
|
|
272
|
+
if (!admits(s, depth) || s.waiters.some(waiter => admits(s, waiter.depth) && waiter.ready())) return undefined;
|
|
273
|
+
return newLease(s, depth);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether another waiter fits. Always true while the budget is off. */
|
|
277
|
+
export function budgetHasPendingRoom(): boolean {
|
|
278
|
+
const s = state();
|
|
279
|
+
return s.policy.maxActive <= 0 || s.waiters.length < MAX_PENDING_SUBAGENTS;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Join the queue. Returns a cancel function; cancelling a granted waiter is a no-op. */
|
|
283
|
+
export function enqueueBudget(waiter: BudgetWaiter): () => void {
|
|
284
|
+
state().waiters.push(waiter);
|
|
285
|
+
return () => {
|
|
286
|
+
const s = state();
|
|
287
|
+
const i = s.waiters.indexOf(waiter);
|
|
288
|
+
if (i !== -1) s.waiters.splice(i, 1);
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Give a lease back. Idempotent; does not pump, so callers keep their own
|
|
294
|
+
* ordering. The last release applies a held policy change.
|
|
295
|
+
*/
|
|
296
|
+
export function releaseBudget(lease: BudgetLease | undefined): void {
|
|
297
|
+
if (lease === undefined || lease.released) return;
|
|
298
|
+
lease.released = true;
|
|
299
|
+
const s = state();
|
|
300
|
+
s.active.delete(lease);
|
|
301
|
+
if (s.active.size === 0 && s.pendingPolicy !== undefined) {
|
|
302
|
+
s.policy = s.pendingPolicy;
|
|
303
|
+
s.pendingPolicy = undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Admit every waiter that fits, earliest first, re-scanning after each grant
|
|
309
|
+
* because a grant changes the counts (`ready()` reads the owner's pools).
|
|
310
|
+
* Re-entrant calls from inside a grant are folded into the running loop.
|
|
311
|
+
*/
|
|
312
|
+
export function pumpBudget(): void {
|
|
313
|
+
const s = state();
|
|
314
|
+
if (s.pumping) {
|
|
315
|
+
s.repump = true;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
s.pumping = true;
|
|
319
|
+
try {
|
|
320
|
+
do {
|
|
321
|
+
s.repump = false;
|
|
322
|
+
for (;;) {
|
|
323
|
+
const i = s.waiters.findIndex(w => admits(s, w.depth) && w.ready());
|
|
324
|
+
if (i === -1) break;
|
|
325
|
+
const [waiter] = s.waiters.splice(i, 1);
|
|
326
|
+
const lease = newLease(s, waiter.depth);
|
|
327
|
+
try {
|
|
328
|
+
waiter.grant(lease);
|
|
329
|
+
} catch {
|
|
330
|
+
releaseBudget(lease);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} while (s.repump);
|
|
334
|
+
} finally {
|
|
335
|
+
s.pumping = false;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Test-only: forget all leases and waiters and restore the default policy. */
|
|
340
|
+
export function resetBudgetForTests(): void {
|
|
341
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
342
|
+
g[BUDGET_KEY] = freshState();
|
|
343
|
+
}
|
package/src/schedule.ts
CHANGED
|
@@ -357,7 +357,11 @@ export class SubagentScheduler {
|
|
|
357
357
|
// Terminal states: completed/steered = success; error/aborted/stopped = error.
|
|
358
358
|
// awaitStartup first: with isolation: "worktree" the run promise only exists
|
|
359
359
|
// once the repo copy is made, and a failed copy rejects here.
|
|
360
|
-
|
|
360
|
+
// The start gate comes first: `bypassQueue` skips the pool but not the
|
|
361
|
+
// process budget (`maxActiveSubagents`), so a fired job can sit "queued"
|
|
362
|
+
// with no startup and no promise yet, and would otherwise finalize at once.
|
|
363
|
+
Promise.resolve(manager.getRecord(agentId)?.startGate)
|
|
364
|
+
.then(() => manager.awaitStartup(agentId))
|
|
361
365
|
.then(() => manager.getRecord(agentId)?.promise)
|
|
362
366
|
.then(() => {
|
|
363
367
|
const r = manager.getRecord(agentId);
|
package/src/settings.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { NO_FALLBACK } from "./agent-types.js";
|
|
9
9
|
import { readEnterpriseLocalManifest } from "./enterprise-local.js";
|
|
10
|
+
import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
|
|
10
11
|
import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
|
|
11
12
|
|
|
12
13
|
export interface SubagentsSettings {
|
|
@@ -33,6 +34,30 @@ export interface SubagentsSettings {
|
|
|
33
34
|
* several blocking resumes in one message can still exceed the limit.
|
|
34
35
|
*/
|
|
35
36
|
maxConcurrentForeground?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Process-wide ceiling on running subagents, across every manager in this pi
|
|
39
|
+
* process: top-level, nested, workflow, scheduled, RPC and mention spawns,
|
|
40
|
+
* and resumes, in the root session and in sessions a `/new`, `/resume`,
|
|
41
|
+
* `/fork` or `/reload` left running. `0` = off, the default. The process,
|
|
42
|
+
* not the machine: two pi processes each get their own. See
|
|
43
|
+
* `process-budget.ts`.
|
|
44
|
+
*
|
|
45
|
+
* Global configuration only, like `modelAliases`: a project file's value is
|
|
46
|
+
* ignored with a warning. Shared by every activation in the process, so the
|
|
47
|
+
* last one to apply its settings wins. A change that tightens it waits until
|
|
48
|
+
* no subagent in the process holds a slot (`setBudgetPolicy`).
|
|
49
|
+
*/
|
|
50
|
+
maxActiveSubagents?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Slots of `maxActiveSubagents` held back per nesting level below a run, so
|
|
53
|
+
* parents that keep their slot while they wait can never starve their own
|
|
54
|
+
* children. Defaults to `2`; with the global `maxSubagentDepth: 2` that
|
|
55
|
+
* leaves `maxActiveSubagents - 2` for top-level runs. With nesting on it
|
|
56
|
+
* must be at least `1`, and `maxActiveSubagents` at least
|
|
57
|
+
* `nestedSubagentReserve * (maxSubagentDepth - 1) + 1`; a budget that
|
|
58
|
+
* breaks either is ignored with a warning, since it could deadlock.
|
|
59
|
+
*/
|
|
60
|
+
nestedSubagentReserve?: number;
|
|
36
61
|
/**
|
|
37
62
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
38
63
|
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
|
|
@@ -357,6 +382,8 @@ export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
|
357
382
|
export interface SettingsAppliers {
|
|
358
383
|
setMaxConcurrent: (n: number) => void;
|
|
359
384
|
setMaxConcurrentForeground: (n: number) => void;
|
|
385
|
+
/** The process budget, from `loadBudgetPolicy`: global configuration only. */
|
|
386
|
+
setBudgetPolicy: (policy: BudgetPolicy) => void;
|
|
360
387
|
setDefaultMaxTurns: (n: number) => void;
|
|
361
388
|
setGraceTurns: (n: number) => void;
|
|
362
389
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
@@ -442,6 +469,20 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
442
469
|
) {
|
|
443
470
|
out.maxConcurrentForeground = r.maxConcurrentForeground as number;
|
|
444
471
|
}
|
|
472
|
+
if (
|
|
473
|
+
Number.isInteger(r.maxActiveSubagents) &&
|
|
474
|
+
(r.maxActiveSubagents as number) >= 0 &&
|
|
475
|
+
(r.maxActiveSubagents as number) <= MAX_CONCURRENT_CEILING
|
|
476
|
+
) {
|
|
477
|
+
out.maxActiveSubagents = r.maxActiveSubagents as number;
|
|
478
|
+
}
|
|
479
|
+
if (
|
|
480
|
+
Number.isInteger(r.nestedSubagentReserve) &&
|
|
481
|
+
(r.nestedSubagentReserve as number) >= 0 &&
|
|
482
|
+
(r.nestedSubagentReserve as number) <= MAX_CONCURRENT_CEILING
|
|
483
|
+
) {
|
|
484
|
+
out.nestedSubagentReserve = r.nestedSubagentReserve as number;
|
|
485
|
+
}
|
|
445
486
|
if (
|
|
446
487
|
Number.isInteger(r.defaultMaxTurns) &&
|
|
447
488
|
(r.defaultMaxTurns as number) >= 0 &&
|
|
@@ -617,16 +658,47 @@ function readSettingsFile(path: string): SubagentsSettings {
|
|
|
617
658
|
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
618
659
|
// Aliases pick the provider a subagent runs on, so they are machine
|
|
619
660
|
// configuration: a cloned repo's project file must not redirect them.
|
|
620
|
-
const {
|
|
661
|
+
const {
|
|
662
|
+
modelAliases: projectModelAliases,
|
|
663
|
+
agentModels: projectAgentModels,
|
|
664
|
+
maxActiveSubagents: projectMaxActive,
|
|
665
|
+
nestedSubagentReserve: projectReserve,
|
|
666
|
+
...project
|
|
667
|
+
} = readSettingsFile(projectPath(cwd));
|
|
621
668
|
if (projectModelAliases !== undefined) {
|
|
622
669
|
console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
|
|
623
670
|
}
|
|
624
671
|
if (projectAgentModels !== undefined) {
|
|
625
672
|
console.warn("[pi-subagents] Ignoring agentModels in project .pi/subagents.json: per-agent models are read from trusted global configuration only.");
|
|
626
673
|
}
|
|
674
|
+
// The process budget spans every session in the process, so no one
|
|
675
|
+
// project's file may set it, least of all a cloned one lifting the cap.
|
|
676
|
+
if (projectMaxActive !== undefined || projectReserve !== undefined) {
|
|
677
|
+
console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
|
|
678
|
+
}
|
|
679
|
+
return { ...trustedSettings(), ...project };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
|
|
683
|
+
function trustedSettings(): SubagentsSettings {
|
|
627
684
|
const enterprise = readEnterpriseLocalManifest()?.subagents;
|
|
628
685
|
const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
|
|
629
|
-
return { ...readSettingsFile(globalPath()), ...enterpriseSettings
|
|
686
|
+
return { ...readSettingsFile(globalPath()), ...enterpriseSettings };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The process budget's policy, from trusted configuration only -- including
|
|
691
|
+
* its depth, the GLOBAL `maxSubagentDepth`: a project file may still change
|
|
692
|
+
* how deep its own sessions nest, but not what the process-wide reserve
|
|
693
|
+
* covers. A missing key means its default, so removing the cap turns it off.
|
|
694
|
+
*/
|
|
695
|
+
export function loadBudgetPolicy(): BudgetPolicy {
|
|
696
|
+
const trusted = trustedSettings();
|
|
697
|
+
return {
|
|
698
|
+
maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
|
|
699
|
+
nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
|
|
700
|
+
maxDepth: trusted.maxSubagentDepth ?? DEFAULT_BUDGET_POLICY.maxDepth,
|
|
701
|
+
};
|
|
630
702
|
}
|
|
631
703
|
|
|
632
704
|
/**
|
|
@@ -706,6 +778,7 @@ export function applyAndEmitLoaded(
|
|
|
706
778
|
): SubagentsSettings {
|
|
707
779
|
const settings = loadSettings(cwd);
|
|
708
780
|
applySettings(settings, appliers);
|
|
781
|
+
appliers.setBudgetPolicy(loadBudgetPolicy());
|
|
709
782
|
emit("subagents:settings_loaded", { settings });
|
|
710
783
|
return settings;
|
|
711
784
|
}
|