@oai404iao/pi-subagent 0.2.0 → 0.4.0-alpha.0
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 +266 -75
- package/agents/worker.md +1 -1
- package/config.example.json +4 -1
- package/config.schema.json +29 -4
- package/package.json +9 -6
- package/src/agent-state.ts +125 -0
- package/src/agent-sync.ts +64 -53
- package/src/agents.ts +3 -22
- package/src/catalog.ts +59 -7
- package/src/completion-mailbox.ts +656 -0
- package/src/config.ts +57 -6
- package/src/coordinator.ts +2476 -205
- package/src/descriptor.ts +118 -12
- package/src/index.ts +177 -27
- package/src/mailbox.ts +451 -0
- package/src/providers.ts +223 -28
- package/src/render.ts +25 -8
- package/src/scheduler.ts +173 -0
- package/src/schemas.ts +114 -11
- package/src/task-path.ts +188 -0
- package/src/types.ts +74 -15
package/src/providers.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ContextInheritance,
|
|
6
|
+
SubagentMode,
|
|
7
|
+
SubagentProviderName,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const INHERITED_COMPACTION_CUSTOM_TYPE =
|
|
11
|
+
"pi-subagent/inherited-compaction-summary";
|
|
12
|
+
const INHERITED_BRANCH_CUSTOM_TYPE =
|
|
13
|
+
"pi-subagent/inherited-branch-summary";
|
|
14
|
+
const COMPACTION_SUMMARY_PREFIX =
|
|
15
|
+
"The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
|
|
16
|
+
const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
|
|
17
|
+
const BRANCH_SUMMARY_PREFIX =
|
|
18
|
+
"The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
|
|
19
|
+
const BRANCH_SUMMARY_SUFFIX = "</summary>";
|
|
5
20
|
|
|
6
21
|
export interface SessionView {
|
|
7
22
|
getBranch(): SessionEntry[];
|
|
@@ -9,6 +24,9 @@ export interface SessionView {
|
|
|
9
24
|
getSessionDir(): string;
|
|
10
25
|
getSessionFile(): string | undefined;
|
|
11
26
|
getSessionId(): string;
|
|
27
|
+
getEntries(): SessionEntry[];
|
|
28
|
+
buildContextEntries(): SessionEntry[];
|
|
29
|
+
appendCustomEntry(customType: string, data?: unknown): string;
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export interface ProviderParent {
|
|
@@ -25,7 +43,11 @@ export interface ChildProvider {
|
|
|
25
43
|
name: SubagentProviderName;
|
|
26
44
|
inheritsParentContext: boolean;
|
|
27
45
|
supportsContinuable: boolean;
|
|
28
|
-
prepare(
|
|
46
|
+
prepare(
|
|
47
|
+
parent: ProviderParent,
|
|
48
|
+
mode: SubagentMode,
|
|
49
|
+
context?: ContextInheritance,
|
|
50
|
+
): Promise<PreparedChildSession>;
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
async function removeOwnedSession(path: string | undefined): Promise<void> {
|
|
@@ -66,33 +88,195 @@ export function completedTurnBoundaryId(entries: readonly SessionEntry[]): strin
|
|
|
66
88
|
return undefined;
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
interface CompletedTurnSpan {
|
|
92
|
+
start: number;
|
|
93
|
+
end: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isSummaryEntry(entry: SessionEntry): boolean {
|
|
97
|
+
return (
|
|
98
|
+
entry.type === "compaction"
|
|
99
|
+
|| entry.type === "branch_summary"
|
|
100
|
+
|| (
|
|
101
|
+
entry.type === "message"
|
|
102
|
+
&& (
|
|
103
|
+
entry.message.role === "compactionSummary"
|
|
104
|
+
|| entry.message.role === "branchSummary"
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function startsModelTurn(entry: SessionEntry): boolean {
|
|
111
|
+
if (entry.type === "custom_message") return true;
|
|
112
|
+
if (entry.type !== "message") return false;
|
|
113
|
+
switch (entry.message.role) {
|
|
114
|
+
case "user":
|
|
115
|
+
case "custom":
|
|
116
|
+
return true;
|
|
117
|
+
case "bashExecution":
|
|
118
|
+
return !entry.message.excludeFromContext;
|
|
119
|
+
default:
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function completedTurnSpans(
|
|
125
|
+
entries: readonly SessionEntry[],
|
|
126
|
+
): {
|
|
127
|
+
turns: CompletedTurnSpan[];
|
|
128
|
+
latestSummary?: number;
|
|
129
|
+
} {
|
|
130
|
+
const turns: CompletedTurnSpan[] = [];
|
|
131
|
+
let latestSummary: number | undefined;
|
|
132
|
+
let currentStart: number | undefined;
|
|
133
|
+
let currentEnd: number | undefined;
|
|
134
|
+
const finishCurrent = () => {
|
|
135
|
+
if (currentStart !== undefined && currentEnd !== undefined) {
|
|
136
|
+
turns.push({ start: currentStart, end: currentEnd });
|
|
137
|
+
}
|
|
138
|
+
currentStart = undefined;
|
|
139
|
+
currentEnd = undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (let index = 0; index < entries.length; index++) {
|
|
143
|
+
const entry = entries[index]!;
|
|
144
|
+
if (isSummaryEntry(entry)) {
|
|
145
|
+
finishCurrent();
|
|
146
|
+
latestSummary = index;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (startsModelTurn(entry)) {
|
|
150
|
+
finishCurrent();
|
|
151
|
+
currentStart = index;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (entry.type !== "message") continue;
|
|
155
|
+
if (
|
|
156
|
+
currentStart === undefined
|
|
157
|
+
&& (
|
|
158
|
+
entry.message.role === "assistant"
|
|
159
|
+
|| entry.message.role === "toolResult"
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
currentStart = latestSummary ?? 0;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
entry.message.role === "assistant"
|
|
166
|
+
&& entry.message.stopReason !== "toolUse"
|
|
167
|
+
) {
|
|
168
|
+
currentEnd = index;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
finishCurrent();
|
|
172
|
+
return {
|
|
173
|
+
turns,
|
|
174
|
+
...(latestSummary !== undefined ? { latestSummary } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function completedContextEntries(
|
|
179
|
+
entries: readonly SessionEntry[],
|
|
180
|
+
context: Exclude<ContextInheritance, { mode: "fresh" }>,
|
|
181
|
+
): SessionEntry[] {
|
|
182
|
+
const { turns, latestSummary } = completedTurnSpans(entries);
|
|
183
|
+
const end = Math.max(
|
|
184
|
+
turns.at(-1)?.end ?? -1,
|
|
185
|
+
latestSummary ?? -1,
|
|
186
|
+
);
|
|
187
|
+
if (end < 0) return [];
|
|
188
|
+
let start = 0;
|
|
189
|
+
if (context.mode === "last_n_completed") {
|
|
190
|
+
if (turns.length >= context.completedTurns) {
|
|
191
|
+
start = turns[turns.length - context.completedTurns]!.start;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return entries
|
|
195
|
+
.slice(start, end + 1)
|
|
196
|
+
.map((entry) => structuredClone(entry));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function appendInheritedEntry(
|
|
200
|
+
session: SessionManager,
|
|
201
|
+
entry: SessionEntry,
|
|
202
|
+
): void {
|
|
203
|
+
if (entry.type === "message") {
|
|
204
|
+
if (entry.message.role === "compactionSummary") {
|
|
205
|
+
session.appendCustomMessageEntry(
|
|
206
|
+
INHERITED_COMPACTION_CUSTOM_TYPE,
|
|
207
|
+
`${COMPACTION_SUMMARY_PREFIX}${entry.message.summary}${COMPACTION_SUMMARY_SUFFIX}`,
|
|
208
|
+
false,
|
|
209
|
+
{ tokensBefore: entry.message.tokensBefore },
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (entry.message.role === "branchSummary") {
|
|
214
|
+
session.appendCustomMessageEntry(
|
|
215
|
+
INHERITED_BRANCH_CUSTOM_TYPE,
|
|
216
|
+
`${BRANCH_SUMMARY_PREFIX}${entry.message.summary}${BRANCH_SUMMARY_SUFFIX}`,
|
|
217
|
+
false,
|
|
218
|
+
{ fromId: entry.message.fromId },
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
session.appendMessage(
|
|
223
|
+
structuredClone(
|
|
224
|
+
entry.message,
|
|
225
|
+
) as Parameters<SessionManager["appendMessage"]>[0],
|
|
226
|
+
);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (entry.type === "custom_message") {
|
|
230
|
+
session.appendCustomMessageEntry(
|
|
231
|
+
entry.customType,
|
|
232
|
+
structuredClone(entry.content),
|
|
233
|
+
entry.display,
|
|
234
|
+
structuredClone(entry.details),
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (entry.type === "compaction") {
|
|
239
|
+
session.appendCustomMessageEntry(
|
|
240
|
+
INHERITED_COMPACTION_CUSTOM_TYPE,
|
|
241
|
+
`${COMPACTION_SUMMARY_PREFIX}${entry.summary}${COMPACTION_SUMMARY_SUFFIX}`,
|
|
242
|
+
false,
|
|
243
|
+
{ tokensBefore: entry.tokensBefore },
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (entry.type === "branch_summary") {
|
|
248
|
+
session.appendCustomMessageEntry(
|
|
249
|
+
INHERITED_BRANCH_CUSTOM_TYPE,
|
|
250
|
+
`${BRANCH_SUMMARY_PREFIX}${entry.summary}${BRANCH_SUMMARY_SUFFIX}`,
|
|
251
|
+
false,
|
|
252
|
+
{ fromId: entry.fromId },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function forkedSession(
|
|
258
|
+
parent: ProviderParent,
|
|
259
|
+
context: Exclude<ContextInheritance, { mode: "fresh" }>,
|
|
260
|
+
): PreparedChildSession {
|
|
70
261
|
const parentFile = parent.sessionManager.getSessionFile();
|
|
71
|
-
const
|
|
72
|
-
|
|
262
|
+
const inherited = completedContextEntries(
|
|
263
|
+
parent.sessionManager.buildContextEntries(),
|
|
264
|
+
context,
|
|
265
|
+
);
|
|
266
|
+
if (inherited.length === 0) return freshSession(parent);
|
|
73
267
|
if (!parentFile) {
|
|
74
268
|
throw new Error(
|
|
75
269
|
"fork provider cannot copy completed history from an ephemeral parent session; use spawn instead",
|
|
76
270
|
);
|
|
77
271
|
}
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
parent.sessionManager.getCwd(),
|
|
83
|
-
);
|
|
84
|
-
if (!clone.getEntry(boundaryId)) return freshSession(parent);
|
|
85
|
-
const childFile = clone.createBranchedSession(boundaryId);
|
|
86
|
-
if (!childFile) return freshSession(parent);
|
|
87
|
-
const sessionManager = SessionManager.open(
|
|
88
|
-
childFile,
|
|
89
|
-
parent.sessionManager.getSessionDir(),
|
|
90
|
-
parent.sessionManager.getCwd(),
|
|
91
|
-
);
|
|
272
|
+
const prepared = freshSession(parent);
|
|
273
|
+
for (const entry of inherited) {
|
|
274
|
+
appendInheritedEntry(prepared.sessionManager, entry);
|
|
275
|
+
}
|
|
92
276
|
return {
|
|
93
|
-
|
|
94
|
-
seedMessageCount:
|
|
95
|
-
|
|
277
|
+
...prepared,
|
|
278
|
+
seedMessageCount:
|
|
279
|
+
prepared.sessionManager.buildSessionContext().messages.length,
|
|
96
280
|
};
|
|
97
281
|
}
|
|
98
282
|
|
|
@@ -101,7 +285,14 @@ export class SpawnProvider implements ChildProvider {
|
|
|
101
285
|
readonly inheritsParentContext = false;
|
|
102
286
|
readonly supportsContinuable = true;
|
|
103
287
|
|
|
104
|
-
prepare(
|
|
288
|
+
prepare(
|
|
289
|
+
parent: ProviderParent,
|
|
290
|
+
_mode: SubagentMode,
|
|
291
|
+
context: ContextInheritance = { mode: "fresh" },
|
|
292
|
+
): Promise<PreparedChildSession> {
|
|
293
|
+
if (context.mode !== "fresh") {
|
|
294
|
+
throw new Error("spawn provider requires fresh context");
|
|
295
|
+
}
|
|
105
296
|
return Promise.resolve(freshSession(parent));
|
|
106
297
|
}
|
|
107
298
|
}
|
|
@@ -109,13 +300,17 @@ export class SpawnProvider implements ChildProvider {
|
|
|
109
300
|
export class ForkProvider implements ChildProvider {
|
|
110
301
|
readonly name = "fork";
|
|
111
302
|
readonly inheritsParentContext = true;
|
|
112
|
-
readonly supportsContinuable =
|
|
303
|
+
readonly supportsContinuable = true;
|
|
113
304
|
|
|
114
|
-
async prepare(
|
|
115
|
-
|
|
116
|
-
|
|
305
|
+
async prepare(
|
|
306
|
+
parent: ProviderParent,
|
|
307
|
+
_mode: SubagentMode,
|
|
308
|
+
context: ContextInheritance = { mode: "all_completed" },
|
|
309
|
+
): Promise<PreparedChildSession> {
|
|
310
|
+
if (context.mode === "fresh") {
|
|
311
|
+
throw new Error("fork provider requires inherited context");
|
|
117
312
|
}
|
|
118
|
-
return forkedSession(parent);
|
|
313
|
+
return forkedSession(parent, context);
|
|
119
314
|
}
|
|
120
315
|
}
|
|
121
316
|
|
package/src/render.ts
CHANGED
|
@@ -4,7 +4,14 @@ import type { DelegationDetails, ParentMessageDetails } from "./types.ts";
|
|
|
4
4
|
import { formatUsage } from "./result.ts";
|
|
5
5
|
|
|
6
6
|
export function renderDelegationCall(
|
|
7
|
-
args: {
|
|
7
|
+
args: {
|
|
8
|
+
agent?: string;
|
|
9
|
+
task_name?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
prompt?: string;
|
|
12
|
+
run_in_background?: boolean;
|
|
13
|
+
context?: { mode?: string };
|
|
14
|
+
},
|
|
8
15
|
theme: {
|
|
9
16
|
fg(color: any, text: string): string;
|
|
10
17
|
bold(text: string): string;
|
|
@@ -13,17 +20,20 @@ export function renderDelegationCall(
|
|
|
13
20
|
): Text {
|
|
14
21
|
const mode =
|
|
15
22
|
provider === "fork"
|
|
16
|
-
?
|
|
23
|
+
? args.run_in_background === true
|
|
24
|
+
? "fork · background"
|
|
25
|
+
: "fork · foreground"
|
|
17
26
|
: args.run_in_background === false
|
|
18
|
-
? "
|
|
27
|
+
? `${args.context?.mode ?? "fresh"} · foreground`
|
|
19
28
|
: args.run_in_background === true
|
|
20
|
-
? "
|
|
21
|
-
: "
|
|
29
|
+
? `${args.context?.mode ?? "fresh"} · background`
|
|
30
|
+
: `${args.context?.mode ?? "fresh"} · configured default`;
|
|
22
31
|
let text =
|
|
23
32
|
theme.fg("toolTitle", theme.bold(provider === "fork" ? "subagent_fork " : "subagent ")) +
|
|
24
33
|
theme.fg("accent", args.agent ?? "…") +
|
|
25
34
|
theme.fg("muted", ` [${mode}]`);
|
|
26
35
|
if (args.description) text += `\n ${theme.fg("dim", args.description)}`;
|
|
36
|
+
if (args.task_name) text += `\n ${theme.fg("dim", `path: ${args.task_name}`)}`;
|
|
27
37
|
return new Text(text, 0, 0);
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -37,6 +47,10 @@ export function renderDelegationResult(
|
|
|
37
47
|
},
|
|
38
48
|
): Text | Container {
|
|
39
49
|
if (!details) return new Text(content || "(no output)", 0, 0);
|
|
50
|
+
const taskPath = details.taskPath || details.agentId;
|
|
51
|
+
const contextMode =
|
|
52
|
+
details.context?.mode
|
|
53
|
+
?? (details.provider === "fork" ? "all_completed" : "fresh");
|
|
40
54
|
const running = options.isPartial || details.status === "starting" || details.status === "running";
|
|
41
55
|
const icon = running
|
|
42
56
|
? theme.fg("warning", "◌")
|
|
@@ -45,8 +59,8 @@ export function renderDelegationResult(
|
|
|
45
59
|
: theme.fg("success", "✓");
|
|
46
60
|
const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(
|
|
47
61
|
"muted",
|
|
48
|
-
`[${details.provider}/${details.mode}]`,
|
|
49
|
-
)} ${theme.fg("dim", details.
|
|
62
|
+
`[${details.provider}/${details.mode}/${contextMode}]`,
|
|
63
|
+
)} ${theme.fg("accent", taskPath)} ${theme.fg("dim", details.agentId)}`;
|
|
50
64
|
|
|
51
65
|
if (!options.expanded) {
|
|
52
66
|
const lines = [header, theme.fg("muted", details.label)];
|
|
@@ -102,7 +116,10 @@ export function renderParentMessage(
|
|
|
102
116
|
theme.fg("accent", icon) +
|
|
103
117
|
" " +
|
|
104
118
|
theme.fg("toolTitle", theme.bold(`subagent ${kind}`)) +
|
|
105
|
-
theme.fg(
|
|
119
|
+
theme.fg(
|
|
120
|
+
"muted",
|
|
121
|
+
` ${details?.taskPath ?? details?.childAgentId ?? "unknown"}${label}`,
|
|
122
|
+
),
|
|
106
123
|
outputPad,
|
|
107
124
|
0,
|
|
108
125
|
);
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export interface BackgroundRunPermit {
|
|
2
|
+
release(): void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface BackgroundRunAcquireOptions {
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
waitForCapacity?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class BackgroundConcurrencyLimitError extends Error {
|
|
11
|
+
constructor(readonly limit: number) {
|
|
12
|
+
super(
|
|
13
|
+
`background subagent concurrency limit (${limit}) is already in use; wait for an active background run to finish`,
|
|
14
|
+
);
|
|
15
|
+
this.name = "BackgroundConcurrencyLimitError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BackgroundRunWaiter {
|
|
20
|
+
resolve: (permit: BackgroundRunPermit) => void;
|
|
21
|
+
reject: (error: Error) => void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onAbort?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BackgroundRunLimiter {
|
|
27
|
+
private active = 0;
|
|
28
|
+
private readonly waiters: BackgroundRunWaiter[] = [];
|
|
29
|
+
private closedError: Error | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(private limit = 4) {
|
|
32
|
+
validateLimit(limit);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get activeCount(): number {
|
|
36
|
+
return this.active;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get pendingCount(): number {
|
|
40
|
+
return this.waiters.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
configure(limit: number): void {
|
|
44
|
+
validateLimit(limit);
|
|
45
|
+
if (limit === this.limit) return;
|
|
46
|
+
this.limit = limit;
|
|
47
|
+
this.drain();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
acquire(
|
|
51
|
+
options: BackgroundRunAcquireOptions = {},
|
|
52
|
+
): Promise<BackgroundRunPermit> {
|
|
53
|
+
if (this.closedError) return Promise.reject(this.closedError);
|
|
54
|
+
if (options.signal?.aborted) {
|
|
55
|
+
return Promise.reject(abortReason(options.signal));
|
|
56
|
+
}
|
|
57
|
+
if (this.waiters.length === 0 && this.active < this.limit) {
|
|
58
|
+
this.active += 1;
|
|
59
|
+
return Promise.resolve(this.createPermit());
|
|
60
|
+
}
|
|
61
|
+
if (options.waitForCapacity === false) {
|
|
62
|
+
return Promise.reject(new BackgroundConcurrencyLimitError(this.limit));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise<BackgroundRunPermit>((resolve, reject) => {
|
|
66
|
+
const waiter: BackgroundRunWaiter = {
|
|
67
|
+
resolve,
|
|
68
|
+
reject,
|
|
69
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
70
|
+
};
|
|
71
|
+
if (options.signal) {
|
|
72
|
+
waiter.onAbort = () => {
|
|
73
|
+
const index = this.waiters.indexOf(waiter);
|
|
74
|
+
if (index >= 0) this.waiters.splice(index, 1);
|
|
75
|
+
reject(abortReason(options.signal!));
|
|
76
|
+
this.drain();
|
|
77
|
+
};
|
|
78
|
+
options.signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
79
|
+
}
|
|
80
|
+
this.waiters.push(waiter);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(error = new Error("background subagent scheduler is shutting down")): void {
|
|
85
|
+
if (this.closedError) return;
|
|
86
|
+
this.closedError = error;
|
|
87
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
88
|
+
this.removeAbortListener(waiter);
|
|
89
|
+
waiter.reject(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private createPermit(): BackgroundRunPermit {
|
|
94
|
+
let released = false;
|
|
95
|
+
return {
|
|
96
|
+
release: () => {
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
this.active = Math.max(0, this.active - 1);
|
|
100
|
+
this.drain();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private drain(): void {
|
|
106
|
+
while (this.waiters.length > 0) {
|
|
107
|
+
const waiter = this.waiters[0]!;
|
|
108
|
+
if (waiter.signal?.aborted) {
|
|
109
|
+
this.waiters.shift();
|
|
110
|
+
this.removeAbortListener(waiter);
|
|
111
|
+
waiter.reject(abortReason(waiter.signal));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (this.closedError) {
|
|
115
|
+
this.waiters.shift();
|
|
116
|
+
this.removeAbortListener(waiter);
|
|
117
|
+
waiter.reject(this.closedError);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (this.active >= this.limit) return;
|
|
121
|
+
this.waiters.shift();
|
|
122
|
+
this.removeAbortListener(waiter);
|
|
123
|
+
this.active += 1;
|
|
124
|
+
waiter.resolve(this.createPermit());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private removeAbortListener(waiter: BackgroundRunWaiter): void {
|
|
129
|
+
if (waiter.signal && waiter.onAbort) {
|
|
130
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class AgentOperationQueue {
|
|
136
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
137
|
+
|
|
138
|
+
async run<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
|
139
|
+
const predecessor = this.tails.get(agentId) ?? Promise.resolve();
|
|
140
|
+
let release!: () => void;
|
|
141
|
+
const gate = new Promise<void>((resolve) => {
|
|
142
|
+
release = resolve;
|
|
143
|
+
});
|
|
144
|
+
const tail = predecessor.catch(() => {}).then(() => gate);
|
|
145
|
+
this.tails.set(agentId, tail);
|
|
146
|
+
|
|
147
|
+
await predecessor.catch(() => {});
|
|
148
|
+
try {
|
|
149
|
+
return await operation();
|
|
150
|
+
} finally {
|
|
151
|
+
release();
|
|
152
|
+
if (this.tails.get(agentId) === tail) this.tails.delete(agentId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async waitForIdle(): Promise<void> {
|
|
157
|
+
while (this.tails.size > 0) {
|
|
158
|
+
await Promise.all([...this.tails.values()].map((tail) => tail.catch(() => {})));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateLimit(limit: number): void {
|
|
164
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
165
|
+
throw new Error("background run limit must be a positive safe integer");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function abortReason(signal: AbortSignal): Error {
|
|
170
|
+
return signal.reason instanceof Error
|
|
171
|
+
? signal.reason
|
|
172
|
+
: new Error(signal.reason ? String(signal.reason) : "background run scheduling aborted");
|
|
173
|
+
}
|