@tt-a1i/openpi 0.3.1 → 0.4.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 +87 -24
- package/SETUP.md +3 -3
- package/extensions/ask-user/index.ts +30 -14
- package/extensions/background-terminals/src/prompt.ts +1 -1
- package/extensions/background-terminals/src/ui/ps.ts +132 -129
- package/extensions/capabilities/index.ts +30 -42
- package/extensions/capabilities/src/ui.ts +93 -0
- package/extensions/file-mutation-display/index.ts +34 -76
- package/extensions/file-mutation-display/render.ts +387 -88
- package/extensions/file-search/index.ts +8 -7
- package/extensions/file-search/src/binaries.ts +18 -18
- package/extensions/git-info/src/changed-files-view.ts +47 -14
- package/extensions/git-read/index.ts +330 -0
- package/extensions/git-read/src/args.ts +171 -0
- package/extensions/git-read/src/process.ts +81 -0
- package/extensions/git-read/src/prompt.ts +56 -0
- package/extensions/sessions/index.ts +70 -55
- package/extensions/setup/index.ts +6 -6
- package/extensions/shared/activity-status.ts +6 -5
- package/extensions/shared/below-editor-navigation.ts +26 -0
- package/extensions/shared/capability-intent.ts +53 -0
- package/extensions/shared/child-session.ts +7 -1
- package/extensions/shared/result-budget.ts +134 -0
- package/extensions/shared/screen-chrome.ts +133 -0
- package/extensions/shared/setup-config.ts +24 -5
- package/extensions/shared/spinner.ts +28 -0
- package/extensions/shared/text-projection.ts +56 -0
- package/extensions/shared/tool-surface.ts +13 -6
- package/extensions/subagents/index.ts +204 -140
- package/extensions/subagents/navigation.ts +52 -23
- package/extensions/subagents/src/agent-types.ts +37 -15
- package/extensions/subagents/src/backends/stub.ts +7 -0
- package/extensions/subagents/src/id-sequence.ts +84 -0
- package/extensions/subagents/src/manager.ts +620 -537
- package/extensions/subagents/src/prompt.ts +153 -38
- package/extensions/subagents/src/result-artifact.ts +142 -0
- package/extensions/subagents/src/runtime.ts +8 -5
- package/extensions/subagents/src/ui/takeover.ts +84 -109
- package/extensions/subagents/src/ui/transcript.ts +76 -42
- package/extensions/subagents/src/ui/wait-result.ts +1 -1
- package/extensions/tasks/ui.ts +79 -62
- package/extensions/ui-customization/footer.ts +7 -4
- package/extensions/user-input-fold/index.ts +185 -0
- package/extensions/workflows/artifacts.ts +35 -0
- package/extensions/workflows/controller.ts +14 -2
- package/extensions/workflows/coordinator.ts +64 -0
- package/extensions/workflows/dashboard.ts +353 -173
- package/extensions/workflows/handoff.ts +62 -20
- package/extensions/workflows/index.ts +647 -387
- package/extensions/workflows/model.ts +57 -15
- package/extensions/workflows/navigation.ts +33 -14
- package/extensions/workflows/prompt.ts +104 -8
- package/extensions/workflows/replay-safety.ts +16 -6
- package/extensions/workflows/result-delivery.ts +189 -0
- package/extensions/workflows/sandbox-child.cjs +11 -0
- package/package.json +1 -1
- package/skills/subagents/SKILL.md +2 -2
- package/skills/workflows/REFERENCE.md +7 -4
- package/skills/workflows/SKILL.md +53 -10
- package/extensions/subagents/src/format.ts +0 -48
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* The manager also exposes a synchronous `SubagentReadModel` so the
|
|
10
10
|
* imperative TUI components (which render synchronously) can read snapshots
|
|
11
11
|
* and issue fire-and-forget commands without touching the Effect runtime.
|
|
12
|
+
*
|
|
13
|
+
* Every run is guarded by a first-response watchdog: a provider that accepts
|
|
14
|
+
* the request but never emits its first assistant event is settled as a
|
|
15
|
+
* failure (releasing its concurrency slot) instead of hanging forever,
|
|
16
|
+
* mirroring the workflow runner's watchdog.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import {
|
|
@@ -54,6 +59,13 @@ export const MAX_TRACKED = 64;
|
|
|
54
59
|
const STOP_TIMEOUT_MS = 5_000;
|
|
55
60
|
/** Session abort/shutdown (5s) plus bounded direct-worktree cleanup (4s). */
|
|
56
61
|
const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
|
|
62
|
+
/**
|
|
63
|
+
* First-response watchdog: a run whose provider accepts the request but
|
|
64
|
+
* never emits an assistant event is settled as a failure so it cannot
|
|
65
|
+
* occupy a concurrency slot forever. Matches the workflow runner's
|
|
66
|
+
* FIRST_RESPONSE_TIMEOUT_MS (extensions/workflows/runner.ts).
|
|
67
|
+
*/
|
|
68
|
+
export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
|
|
57
69
|
const ERROR_TEXT_MAX_LENGTH = 4_096;
|
|
58
70
|
const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
|
|
59
71
|
const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
|
|
@@ -64,6 +76,10 @@ function bounded(text: string) {
|
|
|
64
76
|
return text.slice(0, ERROR_TEXT_MAX_LENGTH);
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
function formatWatchdogTimeout(ms: number) {
|
|
80
|
+
return ms % 1_000 === 0 ? `${ms / 1_000} seconds` : `${ms} ms`;
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
function boundedTranscriptText(text: string) {
|
|
68
84
|
return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
|
|
69
85
|
}
|
|
@@ -108,6 +124,8 @@ interface Entry {
|
|
|
108
124
|
scope: Scope.Closeable;
|
|
109
125
|
pump?: Fiber.Fiber<void>;
|
|
110
126
|
liveToolMap: Map<string, LiveToolState>;
|
|
127
|
+
/** First-response watchdog timer for the active (or just-armed) run. */
|
|
128
|
+
watchdogTimer?: ReturnType<typeof setTimeout>;
|
|
111
129
|
/** Idle restart dispatched but RunStarted not folded yet; counts as running
|
|
112
130
|
* so concurrent restarts cannot race past the cap. */
|
|
113
131
|
restarting?: boolean;
|
|
@@ -183,589 +201,654 @@ export class SubagentManager extends Context.Service<
|
|
|
183
201
|
|
|
184
202
|
// --- Implementation --------------------------------------------------------------
|
|
185
203
|
|
|
186
|
-
const makeManager =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// A failed status/render listener must not corrupt lifecycle state.
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (id) {
|
|
223
|
-
for (const listener of idListeners.get(id) ?? []) {
|
|
204
|
+
const makeManager = (config: SubagentManagerConfig = {}) =>
|
|
205
|
+
Effect.gen(function* () {
|
|
206
|
+
const firstResponseTimeoutMs =
|
|
207
|
+
config.firstResponseTimeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
|
|
208
|
+
const registry = yield* BackendRegistry;
|
|
209
|
+
// Detached forker for sync contexts (read-model commands, pruning) that
|
|
210
|
+
// preserves the manager's services instead of using the global runtime.
|
|
211
|
+
const runDetached = Effect.runForkWith(yield* Effect.context());
|
|
212
|
+
|
|
213
|
+
const entries = new Map<string, Entry>();
|
|
214
|
+
const waitInterest = new Map<string, number>();
|
|
215
|
+
const listeners = new Set<() => void>();
|
|
216
|
+
/** One-shot nextChange waiters, swapped out before invocation so waiters
|
|
217
|
+
* re-registering during notification are not visited in the same sweep. */
|
|
218
|
+
let changeWaiters: Array<() => void> = [];
|
|
219
|
+
const idListeners = new Map<string, Set<() => void>>();
|
|
220
|
+
const cleanups = new Set<Fiber.Fiber<unknown>>();
|
|
221
|
+
let modelCounter = config.initialModelCounter ?? 0;
|
|
222
|
+
let btwCounter = config.initialBtwCounter ?? 0;
|
|
223
|
+
// Reservations are tracked per pool so the model and user "by the way" asides
|
|
224
|
+
// never contend for the same slots.
|
|
225
|
+
let reservedModel = 0;
|
|
226
|
+
let reservedBtw = 0;
|
|
227
|
+
let disposed = false;
|
|
228
|
+
let onSettled:
|
|
229
|
+
| ((snap: SubagentSnapshot, consumed: boolean) => void)
|
|
230
|
+
| undefined;
|
|
231
|
+
|
|
232
|
+
const notify = (id?: string) => {
|
|
233
|
+
const waiters = changeWaiters;
|
|
234
|
+
changeWaiters = [];
|
|
235
|
+
for (const waiter of waiters) waiter();
|
|
236
|
+
for (const listener of [...listeners]) {
|
|
224
237
|
try {
|
|
225
238
|
listener();
|
|
226
239
|
} catch {
|
|
227
|
-
//
|
|
240
|
+
// A failed status/render listener must not corrupt lifecycle state.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (id) {
|
|
244
|
+
for (const listener of idListeners.get(id) ?? []) {
|
|
245
|
+
try {
|
|
246
|
+
listener();
|
|
247
|
+
} catch {
|
|
248
|
+
// Same.
|
|
249
|
+
}
|
|
228
250
|
}
|
|
229
251
|
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Resolves on the next state change. Interruption unregisters the waiter. */
|
|
255
|
+
const nextChange = Effect.callback<void>((resume) => {
|
|
256
|
+
const waiter = () => resume(Effect.void);
|
|
257
|
+
changeWaiters.push(waiter);
|
|
258
|
+
return Effect.sync(() => {
|
|
259
|
+
const index = changeWaiters.indexOf(waiter);
|
|
260
|
+
if (index >= 0) changeWaiters.splice(index, 1);
|
|
261
|
+
});
|
|
240
262
|
});
|
|
241
|
-
});
|
|
242
263
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
(
|
|
288
|
-
(a
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
for (const entry of candidates) {
|
|
292
|
-
if (entries.size <= MAX_TRACKED) break;
|
|
293
|
-
entries.delete(entry.snapshot.id);
|
|
294
|
-
const fiber = runDetached(closeEntryScope(entry));
|
|
295
|
-
cleanups.add(fiber);
|
|
296
|
-
fiber.addObserver(() => cleanups.delete(fiber));
|
|
297
|
-
}
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
const settle = (entry: Entry, outcome: RunOutcome) => {
|
|
301
|
-
const s = entry.snapshot;
|
|
302
|
-
const wasRestarting = entry.restarting === true;
|
|
303
|
-
entry.restarting = false;
|
|
304
|
-
if (s.status !== "running") {
|
|
305
|
-
if (!wasRestarting) return;
|
|
306
|
-
// A cancel can clear a queued restart before RunStarted reaches the
|
|
307
|
-
// manager. Its RunSettled still belongs to the new run, not the old
|
|
308
|
-
// settled snapshot, so promote the lifecycle before applying it.
|
|
309
|
-
s.status = "running";
|
|
310
|
-
s.settledAt = undefined;
|
|
311
|
-
s.errorText = undefined;
|
|
312
|
-
}
|
|
313
|
-
s.settledAt = Date.now();
|
|
314
|
-
switch (outcome._tag) {
|
|
315
|
-
case "Completed":
|
|
316
|
-
s.status = "done";
|
|
317
|
-
s.errorText = undefined;
|
|
318
|
-
s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
|
|
319
|
-
break;
|
|
320
|
-
case "Failed":
|
|
321
|
-
s.status = "error";
|
|
322
|
-
s.errorText = bounded(outcome.errorText);
|
|
323
|
-
// Never let a failed run report the previous run's successful output.
|
|
324
|
-
s.finalText = (outcome.partialText ?? "").slice(
|
|
325
|
-
0,
|
|
326
|
-
FINAL_TEXT_MAX_LENGTH,
|
|
327
|
-
);
|
|
328
|
-
break;
|
|
329
|
-
case "Interrupted":
|
|
330
|
-
s.status = "error";
|
|
331
|
-
s.errorText = "Run was aborted";
|
|
332
|
-
s.finalText = (outcome.partialText ?? "").slice(
|
|
333
|
-
0,
|
|
334
|
-
FINAL_TEXT_MAX_LENGTH,
|
|
264
|
+
/**
|
|
265
|
+
* A restart dispatched by `send` occupies a slot immediately, but the
|
|
266
|
+
* `RunStarted` that flips `snapshot.status` only arrives on the async pump.
|
|
267
|
+
* Every caller that asks "is this busy?" must honor that window, or a
|
|
268
|
+
* wait/cancel issued in the same turn as the restart would observe the old
|
|
269
|
+
* settled run and return (or cancel) the wrong thing.
|
|
270
|
+
*/
|
|
271
|
+
const isBusy = (entry: Entry | undefined) =>
|
|
272
|
+
entry !== undefined &&
|
|
273
|
+
(entry.snapshot.status === "running" || entry.restarting === true);
|
|
274
|
+
|
|
275
|
+
const runningCount = (origin?: SubagentOrigin) =>
|
|
276
|
+
[...entries.values()].filter(
|
|
277
|
+
(e) =>
|
|
278
|
+
isBusy(e) && (origin === undefined || e.snapshot.origin === origin),
|
|
279
|
+
).length;
|
|
280
|
+
|
|
281
|
+
/** Per-pool capacity: model asides and user "by the way" asides never mix. */
|
|
282
|
+
const poolLimit = (origin: SubagentOrigin) =>
|
|
283
|
+
origin === "btw" ? MAX_RUNNING_BTW : MAX_RUNNING;
|
|
284
|
+
const poolReserved = (origin: SubagentOrigin) =>
|
|
285
|
+
origin === "btw" ? reservedBtw : reservedModel;
|
|
286
|
+
const atPoolCapacity = (origin: SubagentOrigin) =>
|
|
287
|
+
runningCount(origin) + poolReserved(origin) >= poolLimit(origin);
|
|
288
|
+
|
|
289
|
+
const addInterest = (ids: ReadonlyArray<string>) => {
|
|
290
|
+
for (const id of ids)
|
|
291
|
+
waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
|
|
292
|
+
};
|
|
293
|
+
const releaseInterest = (ids: ReadonlyArray<string>) => {
|
|
294
|
+
for (const id of ids) {
|
|
295
|
+
const count = (waitInterest.get(id) ?? 1) - 1;
|
|
296
|
+
if (count <= 0) waitInterest.delete(id);
|
|
297
|
+
else waitInterest.set(id, count);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const closeEntryScope = (entry: Entry) =>
|
|
302
|
+
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
|
|
303
|
+
|
|
304
|
+
const pruneSettled = () => {
|
|
305
|
+
if (entries.size <= MAX_TRACKED) return;
|
|
306
|
+
const candidates = [...entries.values()]
|
|
307
|
+
.filter((e) => !isBusy(e) && !waitInterest.has(e.snapshot.id))
|
|
308
|
+
.sort(
|
|
309
|
+
(a, b) =>
|
|
310
|
+
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
|
|
311
|
+
(b.snapshot.settledAt ?? b.snapshot.createdAt),
|
|
335
312
|
);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
switch (event._tag) {
|
|
356
|
-
case "RunStarted":
|
|
357
|
-
entry.restarting = false;
|
|
313
|
+
for (const entry of candidates) {
|
|
314
|
+
if (entries.size <= MAX_TRACKED) break;
|
|
315
|
+
entries.delete(entry.snapshot.id);
|
|
316
|
+
const fiber = runDetached(closeEntryScope(entry));
|
|
317
|
+
cleanups.add(fiber);
|
|
318
|
+
fiber.addObserver(() => cleanups.delete(fiber));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const settle = (entry: Entry, outcome: RunOutcome) => {
|
|
323
|
+
clearWatchdog(entry);
|
|
324
|
+
const s = entry.snapshot;
|
|
325
|
+
const wasRestarting = entry.restarting === true;
|
|
326
|
+
entry.restarting = false;
|
|
327
|
+
if (s.status !== "running") {
|
|
328
|
+
if (!wasRestarting) return;
|
|
329
|
+
// A cancel can clear a queued restart before RunStarted reaches the
|
|
330
|
+
// manager. Its RunSettled still belongs to the new run, not the old
|
|
331
|
+
// settled snapshot, so promote the lifecycle before applying it.
|
|
358
332
|
s.status = "running";
|
|
359
333
|
s.settledAt = undefined;
|
|
360
334
|
s.errorText = undefined;
|
|
361
|
-
break;
|
|
362
|
-
case "RunSettled":
|
|
363
|
-
settle(entry, event.outcome);
|
|
364
|
-
return; // settle() already notified
|
|
365
|
-
case "UserMessage":
|
|
366
|
-
appendTranscript(s, {
|
|
367
|
-
kind: "user",
|
|
368
|
-
text: boundedTranscriptText(event.text),
|
|
369
|
-
});
|
|
370
|
-
break;
|
|
371
|
-
case "AssistantDelta": {
|
|
372
|
-
const live = s.liveAssistant ?? { text: "", thinking: "" };
|
|
373
|
-
s.liveAssistant =
|
|
374
|
-
event.kind === "text"
|
|
375
|
-
? {
|
|
376
|
-
...live,
|
|
377
|
-
text: (live.text + event.delta).slice(
|
|
378
|
-
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
379
|
-
),
|
|
380
|
-
}
|
|
381
|
-
: {
|
|
382
|
-
...live,
|
|
383
|
-
thinking: (live.thinking + event.delta).slice(
|
|
384
|
-
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
385
|
-
),
|
|
386
|
-
};
|
|
387
|
-
break;
|
|
388
335
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
336
|
+
s.settledAt = Date.now();
|
|
337
|
+
switch (outcome._tag) {
|
|
338
|
+
case "Completed":
|
|
339
|
+
s.status = "done";
|
|
340
|
+
s.errorText = undefined;
|
|
341
|
+
s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
|
|
342
|
+
break;
|
|
343
|
+
case "Failed":
|
|
344
|
+
s.status = "error";
|
|
345
|
+
s.errorText = bounded(outcome.errorText);
|
|
346
|
+
// Never let a failed run report the previous run's successful output.
|
|
347
|
+
s.finalText = (outcome.partialText ?? "").slice(
|
|
348
|
+
0,
|
|
349
|
+
FINAL_TEXT_MAX_LENGTH,
|
|
350
|
+
);
|
|
351
|
+
break;
|
|
352
|
+
case "Interrupted":
|
|
353
|
+
s.status = "error";
|
|
354
|
+
s.errorText = "Run was aborted";
|
|
355
|
+
s.finalText = (outcome.partialText ?? "").slice(
|
|
356
|
+
0,
|
|
357
|
+
FINAL_TEXT_MAX_LENGTH,
|
|
358
|
+
);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
s.liveAssistant = undefined;
|
|
362
|
+
entry.liveToolMap.clear();
|
|
363
|
+
s.liveTools = [];
|
|
364
|
+
s.queued = [];
|
|
365
|
+
const consumed = (waitInterest.get(s.id) ?? 0) > 0;
|
|
366
|
+
notify(s.id);
|
|
367
|
+
try {
|
|
368
|
+
// During teardown, don't queue results into a shutting-down session.
|
|
369
|
+
if (!disposed) onSettled?.(s, consumed);
|
|
370
|
+
} catch {
|
|
371
|
+
// The parent session may be unavailable; settlement stays final.
|
|
372
|
+
}
|
|
373
|
+
pruneSettled();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** Stop the first-response watchdog (first response arrived / run settled). */
|
|
377
|
+
const clearWatchdog = (entry: Entry) => {
|
|
378
|
+
if (entry.watchdogTimer !== undefined) {
|
|
379
|
+
clearTimeout(entry.watchdogTimer);
|
|
380
|
+
entry.watchdogTimer = undefined;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Settle a run whose provider never emitted a first assistant response. */
|
|
385
|
+
const watchdogExpired = (entry: Entry) => {
|
|
386
|
+
entry.watchdogTimer = undefined;
|
|
387
|
+
if (!isBusy(entry)) return;
|
|
388
|
+
const model = entry.snapshot.meta.modelLabel;
|
|
389
|
+
settle(entry, {
|
|
390
|
+
_tag: "Failed",
|
|
391
|
+
errorText: `Agent received no assistant response event${model ? ` for ${model}` : ""} within ${formatWatchdogTimeout(firstResponseTimeoutMs)}; the provider request may be stalled. Retry the subagent.`,
|
|
392
|
+
});
|
|
393
|
+
// The stalled session cannot be trusted to abort cooperatively; dispose
|
|
394
|
+
// it like the abort-deadline path so it cannot revive into a zombie run.
|
|
395
|
+
const fiber = runDetached(
|
|
396
|
+
closeEntryScope(entry).pipe(
|
|
397
|
+
Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
|
|
398
|
+
Effect.ignore,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
cleanups.add(fiber);
|
|
402
|
+
fiber.addObserver(() => cleanups.delete(fiber));
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Arm the first-response watchdog for the entry's current run. */
|
|
406
|
+
const armWatchdog = (entry: Entry) => {
|
|
407
|
+
clearWatchdog(entry);
|
|
408
|
+
entry.watchdogTimer = setTimeout(
|
|
409
|
+
() => watchdogExpired(entry),
|
|
410
|
+
firstResponseTimeoutMs,
|
|
411
|
+
);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const foldEvent = (entry: Entry, event: SubagentEvent) => {
|
|
415
|
+
const s = entry.snapshot;
|
|
416
|
+
switch (event._tag) {
|
|
417
|
+
case "RunStarted":
|
|
418
|
+
entry.restarting = false;
|
|
419
|
+
s.status = "running";
|
|
420
|
+
s.settledAt = undefined;
|
|
421
|
+
s.errorText = undefined;
|
|
422
|
+
armWatchdog(entry);
|
|
423
|
+
break;
|
|
424
|
+
case "RunSettled":
|
|
425
|
+
settle(entry, event.outcome);
|
|
426
|
+
return; // settle() already notified
|
|
427
|
+
case "UserMessage":
|
|
428
|
+
appendTranscript(s, {
|
|
429
|
+
kind: "user",
|
|
430
|
+
text: boundedTranscriptText(event.text),
|
|
431
|
+
});
|
|
432
|
+
break;
|
|
433
|
+
case "AssistantDelta": {
|
|
434
|
+
clearWatchdog(entry);
|
|
435
|
+
const live = s.liveAssistant ?? { text: "", thinking: "" };
|
|
436
|
+
s.liveAssistant =
|
|
437
|
+
event.kind === "text"
|
|
394
438
|
? {
|
|
395
|
-
...
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
439
|
+
...live,
|
|
440
|
+
text: (live.text + event.delta).slice(
|
|
441
|
+
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
442
|
+
),
|
|
399
443
|
}
|
|
400
|
-
: {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
444
|
+
: {
|
|
445
|
+
...live,
|
|
446
|
+
thinking: (live.thinking + event.delta).slice(
|
|
447
|
+
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
448
|
+
),
|
|
449
|
+
};
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
case "AssistantMessage":
|
|
453
|
+
clearWatchdog(entry);
|
|
454
|
+
appendTranscript(s, {
|
|
455
|
+
kind: "assistant",
|
|
456
|
+
parts: event.parts.map((part) =>
|
|
457
|
+
part.type === "toolCall"
|
|
458
|
+
? {
|
|
459
|
+
...part,
|
|
460
|
+
argsPreview: part.argsPreview
|
|
461
|
+
? boundedTranscriptText(part.argsPreview)
|
|
462
|
+
: undefined,
|
|
463
|
+
}
|
|
464
|
+
: { ...part, text: boundedTranscriptText(part.text) },
|
|
465
|
+
),
|
|
466
|
+
});
|
|
467
|
+
s.liveAssistant = undefined;
|
|
468
|
+
s.turns++;
|
|
469
|
+
break;
|
|
470
|
+
case "ToolStart":
|
|
419
471
|
entry.liveToolMap.set(event.toolId, {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
472
|
+
toolId: event.toolId,
|
|
473
|
+
name: event.name,
|
|
474
|
+
argsPreview: event.argsPreview
|
|
475
|
+
? boundedTranscriptText(event.argsPreview)
|
|
476
|
+
: undefined,
|
|
424
477
|
});
|
|
425
478
|
s.liveTools = [...entry.liveToolMap.values()];
|
|
479
|
+
break;
|
|
480
|
+
case "ToolUpdate": {
|
|
481
|
+
const current = entry.liveToolMap.get(event.toolId);
|
|
482
|
+
if (current) {
|
|
483
|
+
entry.liveToolMap.set(event.toolId, {
|
|
484
|
+
...current,
|
|
485
|
+
outputPreview: event.outputPreview
|
|
486
|
+
? boundedTranscriptText(event.outputPreview)
|
|
487
|
+
: current.outputPreview,
|
|
488
|
+
});
|
|
489
|
+
s.liveTools = [...entry.liveToolMap.values()];
|
|
490
|
+
}
|
|
491
|
+
break;
|
|
426
492
|
}
|
|
427
|
-
|
|
493
|
+
case "ToolEnd":
|
|
494
|
+
entry.liveToolMap.delete(event.toolId);
|
|
495
|
+
s.liveTools = [...entry.liveToolMap.values()];
|
|
496
|
+
appendTranscript(s, {
|
|
497
|
+
kind: "toolResult",
|
|
498
|
+
toolId: event.toolId,
|
|
499
|
+
name: event.name,
|
|
500
|
+
isError: event.isError,
|
|
501
|
+
outputPreview: event.outputPreview
|
|
502
|
+
? boundedTranscriptText(event.outputPreview)
|
|
503
|
+
: undefined,
|
|
504
|
+
});
|
|
505
|
+
break;
|
|
506
|
+
case "QueueChanged":
|
|
507
|
+
s.queued = event.queued;
|
|
508
|
+
break;
|
|
509
|
+
case "UsageChanged":
|
|
510
|
+
s.usage = {
|
|
511
|
+
tokens: event.tokens ?? s.usage.tokens,
|
|
512
|
+
contextWindow: event.contextWindow ?? s.usage.contextWindow,
|
|
513
|
+
};
|
|
514
|
+
break;
|
|
515
|
+
case "MetaChanged":
|
|
516
|
+
s.meta = { ...s.meta, ...event.meta };
|
|
517
|
+
break;
|
|
518
|
+
case "BackendError":
|
|
519
|
+
s.errorText = bounded(event.message);
|
|
520
|
+
break;
|
|
428
521
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const origin: SubagentOrigin = task.origin ?? "model";
|
|
464
|
-
// Reserve synchronously (before the first yield inside doSpawn) so
|
|
465
|
-
// parallel tool calls cannot race past the pool cap.
|
|
466
|
-
yield* Effect.suspend(
|
|
467
|
-
(): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
|
|
468
|
-
if (disposed) {
|
|
469
|
-
return new SpawnError({
|
|
470
|
-
message: "Subagent manager is shutting down.",
|
|
522
|
+
notify(s.id);
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const spawn = (backendName: BackendName, task: SpawnTask) =>
|
|
526
|
+
Effect.gen(function* () {
|
|
527
|
+
const origin: SubagentOrigin = task.origin ?? "model";
|
|
528
|
+
// Reserve synchronously (before the first yield inside doSpawn) so
|
|
529
|
+
// parallel tool calls cannot race past the pool cap.
|
|
530
|
+
yield* Effect.suspend(
|
|
531
|
+
(): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
|
|
532
|
+
if (disposed) {
|
|
533
|
+
return new SpawnError({
|
|
534
|
+
message: "Subagent manager is shutting down.",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (atPoolCapacity(origin)) {
|
|
538
|
+
return new ConcurrencyLimitError({
|
|
539
|
+
message: `Max ${poolLimit(origin)} ${
|
|
540
|
+
origin === "btw" ? "by-the-way" : "subagent"
|
|
541
|
+
} sessions can run concurrently. Wait for one to finish before spawning another.`,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (origin === "btw") reservedBtw++;
|
|
545
|
+
else reservedModel++;
|
|
546
|
+
return Effect.void;
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const doSpawn = Effect.gen(function* () {
|
|
551
|
+
const backend: SubagentBackend | undefined =
|
|
552
|
+
registry.get(backendName);
|
|
553
|
+
if (!backend) {
|
|
554
|
+
return yield* new BackendUnavailableError({
|
|
555
|
+
message: `Unknown backend "${backendName}".`,
|
|
471
556
|
});
|
|
472
557
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
558
|
+
const scope = yield* Scope.make();
|
|
559
|
+
const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
|
|
560
|
+
Effect.onError(() => Scope.close(scope, Exit.void)),
|
|
561
|
+
);
|
|
562
|
+
if (disposed) {
|
|
563
|
+
yield* Scope.close(scope, Exit.void);
|
|
564
|
+
return yield* new SpawnError({
|
|
565
|
+
message: "Subagent manager shut down while spawning.",
|
|
478
566
|
});
|
|
479
567
|
}
|
|
480
|
-
if (origin === "btw") reservedBtw++;
|
|
481
|
-
else reservedModel++;
|
|
482
|
-
return Effect.void;
|
|
483
|
-
},
|
|
484
|
-
);
|
|
485
568
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
569
|
+
const id =
|
|
570
|
+
origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
|
|
571
|
+
const meta = yield* session.meta;
|
|
572
|
+
const entry: Entry = {
|
|
573
|
+
snapshot: {
|
|
574
|
+
id,
|
|
575
|
+
origin,
|
|
576
|
+
backend: backendName,
|
|
577
|
+
title: task.title,
|
|
578
|
+
prompt: task.prompt,
|
|
579
|
+
cwd: task.cwd,
|
|
580
|
+
status: "running",
|
|
581
|
+
createdAt: Date.now(),
|
|
582
|
+
meta,
|
|
583
|
+
usage: { contextWindow: meta.contextWindow },
|
|
584
|
+
transcript: [],
|
|
585
|
+
liveTools: [],
|
|
586
|
+
queued: [],
|
|
587
|
+
finalText: "",
|
|
588
|
+
turns: 0,
|
|
589
|
+
},
|
|
590
|
+
session,
|
|
591
|
+
scope,
|
|
592
|
+
liveToolMap: new Map(),
|
|
593
|
+
};
|
|
594
|
+
entries.set(id, entry);
|
|
595
|
+
// The run is live from the caller's perspective before RunStarted
|
|
596
|
+
// reaches the pump; guard that window too.
|
|
597
|
+
armWatchdog(entry);
|
|
598
|
+
|
|
599
|
+
// Pump: fold the event stream into the snapshot. Tied to the entry
|
|
600
|
+
// scope, so closing the scope stops it. If the stream ends while the
|
|
601
|
+
// subagent still looks running, the backend died out from under us.
|
|
602
|
+
const pump = Stream.runForEach(session.events, (event) =>
|
|
603
|
+
Effect.sync(() => foldEvent(entry, event)),
|
|
604
|
+
).pipe(
|
|
605
|
+
Effect.ensuring(
|
|
606
|
+
Effect.sync(() => {
|
|
607
|
+
if (entry.snapshot.status === "running") {
|
|
608
|
+
settle(entry, {
|
|
609
|
+
_tag: "Failed",
|
|
610
|
+
errorText: "Backend event stream ended unexpectedly",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}),
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
|
|
503
617
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
id,
|
|
510
|
-
origin,
|
|
511
|
-
backend: backendName,
|
|
512
|
-
title: task.title,
|
|
513
|
-
prompt: task.prompt,
|
|
514
|
-
cwd: task.cwd,
|
|
515
|
-
status: "running",
|
|
516
|
-
createdAt: Date.now(),
|
|
517
|
-
meta,
|
|
518
|
-
usage: { contextWindow: meta.contextWindow },
|
|
519
|
-
transcript: [],
|
|
520
|
-
liveTools: [],
|
|
521
|
-
queued: [],
|
|
522
|
-
finalText: "",
|
|
523
|
-
turns: 0,
|
|
524
|
-
},
|
|
525
|
-
session,
|
|
526
|
-
scope,
|
|
527
|
-
liveToolMap: new Map(),
|
|
528
|
-
};
|
|
529
|
-
entries.set(id, entry);
|
|
530
|
-
|
|
531
|
-
// Pump: fold the event stream into the snapshot. Tied to the entry
|
|
532
|
-
// scope, so closing the scope stops it. If the stream ends while the
|
|
533
|
-
// subagent still looks running, the backend died out from under us.
|
|
534
|
-
const pump = Stream.runForEach(session.events, (event) =>
|
|
535
|
-
Effect.sync(() => foldEvent(entry, event)),
|
|
536
|
-
).pipe(
|
|
618
|
+
notify(id);
|
|
619
|
+
return entry.snapshot as SubagentSnapshot;
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
return yield* doSpawn.pipe(
|
|
537
623
|
Effect.ensuring(
|
|
538
624
|
Effect.sync(() => {
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
errorText: "Backend event stream ended unexpectedly",
|
|
543
|
-
});
|
|
544
|
-
}
|
|
625
|
+
if (origin === "btw") reservedBtw--;
|
|
626
|
+
else reservedModel--;
|
|
627
|
+
notify();
|
|
545
628
|
}),
|
|
546
629
|
),
|
|
547
630
|
);
|
|
548
|
-
entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
|
|
549
|
-
|
|
550
|
-
notify(id);
|
|
551
|
-
return entry.snapshot as SubagentSnapshot;
|
|
552
631
|
});
|
|
553
632
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
)
|
|
562
|
-
|
|
563
|
-
|
|
633
|
+
const waitFor = (
|
|
634
|
+
ids: ReadonlyArray<string>,
|
|
635
|
+
onPending?: (pending: string[]) => void,
|
|
636
|
+
) =>
|
|
637
|
+
Effect.suspend(() => {
|
|
638
|
+
const unique = [...new Set(ids)];
|
|
639
|
+
addInterest(unique);
|
|
640
|
+
const loop = Effect.gen(function* () {
|
|
641
|
+
while (true) {
|
|
642
|
+
const pending = unique.filter((id) => isBusy(entries.get(id)));
|
|
643
|
+
if (pending.length === 0) return;
|
|
644
|
+
onPending?.(pending);
|
|
645
|
+
yield* nextChange;
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
return loop.pipe(
|
|
649
|
+
Effect.ensuring(
|
|
650
|
+
Effect.sync(() => {
|
|
651
|
+
releaseInterest(unique);
|
|
652
|
+
pruneSettled();
|
|
653
|
+
}),
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
});
|
|
564
657
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
yield*
|
|
658
|
+
/** Interrupt one busy entry, including the pre-RunStarted restart window. */
|
|
659
|
+
const abortEntry = (entry: Entry) =>
|
|
660
|
+
Effect.gen(function* () {
|
|
661
|
+
if (!isBusy(entry)) return;
|
|
662
|
+
const graceful = yield* entry.session.interrupt.pipe(
|
|
663
|
+
Effect.timeout(STOP_TIMEOUT_MS),
|
|
664
|
+
Effect.result,
|
|
665
|
+
);
|
|
666
|
+
if (Result.isFailure(graceful)) {
|
|
667
|
+
// Settle before closing the scope so the pump's stream-ended
|
|
668
|
+
// fallback ("Backend event stream ended unexpectedly") cannot win
|
|
669
|
+
// the race and report the wrong terminal reason.
|
|
670
|
+
yield* Effect.sync(() => {
|
|
671
|
+
settle(entry, { _tag: "Interrupted" });
|
|
672
|
+
entry.snapshot.errorText =
|
|
673
|
+
"Abort deadline exceeded; session was force-disposed";
|
|
674
|
+
notify(entry.snapshot.id);
|
|
675
|
+
});
|
|
676
|
+
// Bound the close like disposeAll does: a stuck backend finalizer
|
|
677
|
+
// must not hang cancel after the run is already settled.
|
|
678
|
+
yield* closeEntryScope(entry).pipe(
|
|
679
|
+
Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
|
|
680
|
+
Effect.ignore,
|
|
681
|
+
);
|
|
578
682
|
}
|
|
579
683
|
});
|
|
580
|
-
return loop.pipe(
|
|
581
|
-
Effect.ensuring(
|
|
582
|
-
Effect.sync(() => {
|
|
583
|
-
releaseInterest(unique);
|
|
584
|
-
pruneSettled();
|
|
585
|
-
}),
|
|
586
|
-
),
|
|
587
|
-
);
|
|
588
|
-
});
|
|
589
684
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
"Abort deadline exceeded; session was force-disposed";
|
|
606
|
-
notify(entry.snapshot.id);
|
|
685
|
+
const cancel = (ids: ReadonlyArray<string>) =>
|
|
686
|
+
Effect.suspend(() => {
|
|
687
|
+
const unique = [...new Set(ids)];
|
|
688
|
+
const running = unique
|
|
689
|
+
.map((id) => entries.get(id))
|
|
690
|
+
.filter((entry): entry is Entry => isBusy(entry));
|
|
691
|
+
const runningIds = running.map((entry) => entry.snapshot.id);
|
|
692
|
+
// Mark consumed before interrupting so cancellation does not also
|
|
693
|
+
// enqueue duplicate automatic result messages into the parent.
|
|
694
|
+
addInterest(runningIds);
|
|
695
|
+
const work = Effect.gen(function* () {
|
|
696
|
+
yield* Effect.forEach(running, abortEntry, {
|
|
697
|
+
concurrency: "unbounded",
|
|
698
|
+
});
|
|
699
|
+
while (running.some(isBusy)) yield* nextChange;
|
|
607
700
|
});
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
701
|
+
return work.pipe(
|
|
702
|
+
Effect.ensuring(
|
|
703
|
+
Effect.sync(() => {
|
|
704
|
+
releaseInterest(runningIds);
|
|
705
|
+
pruneSettled();
|
|
706
|
+
}),
|
|
707
|
+
),
|
|
708
|
+
Effect.map(
|
|
709
|
+
(): ReadonlyArray<CancelResult> =>
|
|
710
|
+
unique.map((id) => {
|
|
711
|
+
const snapshot = entries.get(id)?.snapshot;
|
|
712
|
+
return {
|
|
713
|
+
id,
|
|
714
|
+
title: snapshot?.title ?? "?",
|
|
715
|
+
status: snapshot?.status ?? "error",
|
|
716
|
+
cancelled: runningIds.includes(id),
|
|
717
|
+
};
|
|
718
|
+
}),
|
|
719
|
+
),
|
|
613
720
|
);
|
|
614
|
-
}
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
const cancel = (ids: ReadonlyArray<string>) =>
|
|
618
|
-
Effect.suspend(() => {
|
|
619
|
-
const unique = [...new Set(ids)];
|
|
620
|
-
const running = unique
|
|
621
|
-
.map((id) => entries.get(id))
|
|
622
|
-
.filter((entry): entry is Entry => isBusy(entry));
|
|
623
|
-
const runningIds = running.map((entry) => entry.snapshot.id);
|
|
624
|
-
// Mark consumed before interrupting so cancellation does not also
|
|
625
|
-
// enqueue duplicate automatic result messages into the parent.
|
|
626
|
-
addInterest(runningIds);
|
|
627
|
-
const work = Effect.gen(function* () {
|
|
628
|
-
yield* Effect.forEach(running, abortEntry, {
|
|
629
|
-
concurrency: "unbounded",
|
|
630
|
-
});
|
|
631
|
-
while (running.some(isBusy)) yield* nextChange;
|
|
632
721
|
});
|
|
633
|
-
return work.pipe(
|
|
634
|
-
Effect.ensuring(
|
|
635
|
-
Effect.sync(() => {
|
|
636
|
-
releaseInterest(runningIds);
|
|
637
|
-
pruneSettled();
|
|
638
|
-
}),
|
|
639
|
-
),
|
|
640
|
-
Effect.map(
|
|
641
|
-
(): ReadonlyArray<CancelResult> =>
|
|
642
|
-
unique.map((id) => {
|
|
643
|
-
const snapshot = entries.get(id)?.snapshot;
|
|
644
|
-
return {
|
|
645
|
-
id,
|
|
646
|
-
title: snapshot?.title ?? "?",
|
|
647
|
-
status: snapshot?.status ?? "error",
|
|
648
|
-
cancelled: runningIds.includes(id),
|
|
649
|
-
};
|
|
650
|
-
}),
|
|
651
|
-
),
|
|
652
|
-
);
|
|
653
|
-
});
|
|
654
722
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
return new SendError({
|
|
660
|
-
message: `Subagent "${id}" is no longer tracked.`,
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
// Restarting a settled subagent occupies a running slot again, so it
|
|
664
|
-
// must respect the same cap as spawn. Steering an already-running one
|
|
665
|
-
// does not consume additional capacity.
|
|
666
|
-
if (!isBusy(entry)) {
|
|
667
|
-
const origin = entry.snapshot.origin;
|
|
668
|
-
if (atPoolCapacity(origin)) {
|
|
723
|
+
const send = (id: string, text: string) =>
|
|
724
|
+
Effect.suspend((): Effect.Effect<void, SendError> => {
|
|
725
|
+
const entry = entries.get(id);
|
|
726
|
+
if (!entry || disposed) {
|
|
669
727
|
return new SendError({
|
|
670
|
-
message: `
|
|
671
|
-
origin === "btw" ? "by-the-way" : "subagent"
|
|
672
|
-
} sessions can run concurrently; restarting "${id}" would exceed that.`,
|
|
728
|
+
message: `Subagent "${id}" is no longer tracked.`,
|
|
673
729
|
});
|
|
674
730
|
}
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
})
|
|
731
|
+
// Restarting a settled subagent occupies a running slot again, so it
|
|
732
|
+
// must respect the same cap as spawn. Steering an already-running one
|
|
733
|
+
// does not consume additional capacity.
|
|
734
|
+
if (!isBusy(entry)) {
|
|
735
|
+
const origin = entry.snapshot.origin;
|
|
736
|
+
if (atPoolCapacity(origin)) {
|
|
737
|
+
return new SendError({
|
|
738
|
+
message: `Max ${poolLimit(origin)} ${
|
|
739
|
+
origin === "btw" ? "by-the-way" : "subagent"
|
|
740
|
+
} sessions can run concurrently; restarting "${id}" would exceed that.`,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
// Occupy the slot synchronously: the RunStarted that flips status
|
|
744
|
+
// arrives via the async pump, and two concurrent restarts must not
|
|
745
|
+
// both pass the check in that window. Cleared by RunStarted/settle,
|
|
746
|
+
// or here when the backend rejects the send.
|
|
747
|
+
entry.restarting = true;
|
|
748
|
+
// A backend that accepts the send but never starts the run would
|
|
749
|
+
// hold the slot forever; guard the restart window the same way the
|
|
750
|
+
// spawn path guards its pre-RunStarted window.
|
|
751
|
+
armWatchdog(entry);
|
|
752
|
+
return entry.session.send(text).pipe(
|
|
753
|
+
Effect.onError(() =>
|
|
754
|
+
Effect.sync(() => {
|
|
755
|
+
entry.restarting = false;
|
|
756
|
+
notify(entry.snapshot.id);
|
|
757
|
+
}),
|
|
758
|
+
),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
return entry.session.send(text);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const disposeAll = Effect.gen(function* () {
|
|
765
|
+
disposed = true;
|
|
766
|
+
const all = [...entries.values()];
|
|
767
|
+
for (const entry of all) clearWatchdog(entry);
|
|
768
|
+
entries.clear();
|
|
769
|
+
yield* Effect.forEach(
|
|
770
|
+
all,
|
|
771
|
+
(entry) =>
|
|
772
|
+
closeEntryScope(entry).pipe(
|
|
773
|
+
Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
|
|
774
|
+
Effect.ignore,
|
|
686
775
|
),
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
776
|
+
{ concurrency: "unbounded" },
|
|
777
|
+
);
|
|
778
|
+
// Pruning cleanups are detached; bound them like everything else so a
|
|
779
|
+
// stuck backend finalizer cannot block runtime shutdown indefinitely.
|
|
780
|
+
yield* Effect.forEach(
|
|
781
|
+
[...cleanups],
|
|
782
|
+
(fiber) =>
|
|
783
|
+
Fiber.await(fiber).pipe(
|
|
784
|
+
Effect.timeout(STOP_TIMEOUT_MS),
|
|
785
|
+
Effect.ignore,
|
|
786
|
+
),
|
|
787
|
+
{ concurrency: "unbounded" },
|
|
788
|
+
).pipe(Effect.ignore);
|
|
789
|
+
yield* Effect.sync(() => notify());
|
|
690
790
|
});
|
|
691
791
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
792
|
+
const view: SubagentReadModel = {
|
|
793
|
+
list: () => [...entries.values()].map((entry) => entry.snapshot),
|
|
794
|
+
get: (id) => entries.get(id)?.snapshot,
|
|
795
|
+
size: () => entries.size,
|
|
796
|
+
subscribe: (listener) => {
|
|
797
|
+
listeners.add(listener);
|
|
798
|
+
return () => listeners.delete(listener);
|
|
799
|
+
},
|
|
800
|
+
subscribeTo: (id, listener) => {
|
|
801
|
+
let set = idListeners.get(id);
|
|
802
|
+
if (!set) {
|
|
803
|
+
set = new Set();
|
|
804
|
+
idListeners.set(id, set);
|
|
805
|
+
}
|
|
806
|
+
set.add(listener);
|
|
807
|
+
return () => {
|
|
808
|
+
set.delete(listener);
|
|
809
|
+
if (set.size === 0) idListeners.delete(id);
|
|
810
|
+
};
|
|
811
|
+
},
|
|
812
|
+
requestSend: (id, text) => {
|
|
813
|
+
runDetached(send(id, text).pipe(Effect.ignore));
|
|
814
|
+
},
|
|
815
|
+
requestAbort: (id) => {
|
|
816
|
+
const entry = entries.get(id);
|
|
817
|
+
if (!entry) return;
|
|
818
|
+
// UI-initiated aborts are not "consumed": the failed result still
|
|
819
|
+
// flows back to the parent as a follow-up message, matching v1.
|
|
820
|
+
runDetached(abortEntry(entry).pipe(Effect.ignore));
|
|
821
|
+
},
|
|
822
|
+
setOnSettled: (hook) => {
|
|
823
|
+
onSettled = hook;
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// Safety net: disposing the ManagedRuntime tears everything down even if
|
|
828
|
+
// the extension forgot to call disposeAll explicitly.
|
|
829
|
+
yield* Effect.addFinalizer(() => disposeAll);
|
|
830
|
+
|
|
831
|
+
return SubagentManager.of({
|
|
832
|
+
spawn,
|
|
833
|
+
waitFor,
|
|
834
|
+
cancel,
|
|
835
|
+
send,
|
|
836
|
+
get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
|
|
837
|
+
list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
|
|
838
|
+
disposeAll,
|
|
839
|
+
view,
|
|
840
|
+
});
|
|
714
841
|
});
|
|
715
842
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
},
|
|
724
|
-
subscribeTo: (id, listener) => {
|
|
725
|
-
let set = idListeners.get(id);
|
|
726
|
-
if (!set) {
|
|
727
|
-
set = new Set();
|
|
728
|
-
idListeners.set(id, set);
|
|
729
|
-
}
|
|
730
|
-
set.add(listener);
|
|
731
|
-
return () => {
|
|
732
|
-
set.delete(listener);
|
|
733
|
-
if (set.size === 0) idListeners.delete(id);
|
|
734
|
-
};
|
|
735
|
-
},
|
|
736
|
-
requestSend: (id, text) => {
|
|
737
|
-
runDetached(send(id, text).pipe(Effect.ignore));
|
|
738
|
-
},
|
|
739
|
-
requestAbort: (id) => {
|
|
740
|
-
const entry = entries.get(id);
|
|
741
|
-
if (!entry) return;
|
|
742
|
-
// UI-initiated aborts are not "consumed": the failed result still
|
|
743
|
-
// flows back to the parent as a follow-up message, matching v1.
|
|
744
|
-
runDetached(abortEntry(entry).pipe(Effect.ignore));
|
|
745
|
-
},
|
|
746
|
-
setOnSettled: (hook) => {
|
|
747
|
-
onSettled = hook;
|
|
748
|
-
},
|
|
749
|
-
};
|
|
750
|
-
|
|
751
|
-
// Safety net: disposing the ManagedRuntime tears everything down even if
|
|
752
|
-
// the extension forgot to call disposeAll explicitly.
|
|
753
|
-
yield* Effect.addFinalizer(() => disposeAll);
|
|
754
|
-
|
|
755
|
-
return SubagentManager.of({
|
|
756
|
-
spawn,
|
|
757
|
-
waitFor,
|
|
758
|
-
cancel,
|
|
759
|
-
send,
|
|
760
|
-
get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
|
|
761
|
-
list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
|
|
762
|
-
disposeAll,
|
|
763
|
-
view,
|
|
764
|
-
});
|
|
765
|
-
});
|
|
843
|
+
export interface SubagentManagerConfig {
|
|
844
|
+
/** Test-only override for the first-response watchdog timeout. */
|
|
845
|
+
firstResponseTimeoutMs?: number;
|
|
846
|
+
/** Session-branch high-water marks restored by the extension host. */
|
|
847
|
+
initialModelCounter?: number;
|
|
848
|
+
initialBtwCounter?: number;
|
|
849
|
+
}
|
|
766
850
|
|
|
767
|
-
export const
|
|
768
|
-
SubagentManager,
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
> = Layer.effect(SubagentManager, makeManager);
|
|
851
|
+
export const makeSubagentManagerLayer = (config: SubagentManagerConfig = {}) =>
|
|
852
|
+
Layer.effect(SubagentManager, makeManager(config));
|
|
853
|
+
|
|
854
|
+
export const SubagentManagerLive = makeSubagentManagerLayer();
|