@aragon-agent/cli 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +128 -1
- package/dist/agent/controller.d.ts +51 -1
- package/dist/agent/controller.js +177 -2
- package/dist/agent/headless.d.ts +12 -0
- package/dist/agent/headless.js +41 -1
- package/dist/agent/reducer.d.ts +48 -0
- package/dist/agent/reducer.js +91 -2
- package/dist/agent/system-prompt.d.ts +17 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +66 -2
- package/dist/commands/builtins.js +82 -0
- package/dist/config/app-paths.d.ts +17 -0
- package/dist/config/app-paths.js +21 -0
- package/dist/config/cli-commands.d.ts +26 -2
- package/dist/config/cli-commands.js +63 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +28 -2
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +65 -5
- package/dist/config/migrate-state-out-of-config.d.ts +32 -0
- package/dist/config/migrate-state-out-of-config.js +85 -0
- package/dist/config/prompt-history.d.ts +86 -0
- package/dist/config/prompt-history.js +332 -0
- package/dist/config/schema.d.ts +112 -16
- package/dist/config/schema.js +89 -6
- package/dist/config/store.js +13 -4
- package/dist/config/ui-state.d.ts +52 -0
- package/dist/config/ui-state.js +149 -0
- package/dist/logging/install.d.ts +85 -0
- package/dist/logging/install.js +94 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.d.ts +16 -0
- package/dist/session/persist.js +19 -1
- package/dist/team/activity.d.ts +67 -0
- package/dist/team/activity.js +130 -0
- package/dist/team/bus.d.ts +190 -0
- package/dist/team/bus.js +366 -0
- package/dist/team/comm-tools.d.ts +61 -0
- package/dist/team/comm-tools.js +221 -0
- package/dist/team/human-queue.d.ts +55 -0
- package/dist/team/human-queue.js +72 -0
- package/dist/team/limits.d.ts +123 -0
- package/dist/team/limits.js +128 -0
- package/dist/team/normalize.d.ts +39 -0
- package/dist/team/normalize.js +106 -0
- package/dist/team/panel-rows.d.ts +48 -0
- package/dist/team/panel-rows.js +75 -0
- package/dist/team/prompt.d.ts +49 -0
- package/dist/team/prompt.js +87 -0
- package/dist/team/report.d.ts +58 -0
- package/dist/team/report.js +205 -0
- package/dist/team/retry.d.ts +48 -0
- package/dist/team/retry.js +56 -0
- package/dist/team/runtime.d.ts +122 -0
- package/dist/team/runtime.js +406 -0
- package/dist/team/subagent.d.ts +90 -0
- package/dist/team/subagent.js +294 -0
- package/dist/team/task-tool.d.ts +47 -0
- package/dist/team/task-tool.js +118 -0
- package/dist/team/types.d.ts +172 -0
- package/dist/team/types.js +13 -0
- package/dist/tools/index.d.ts +48 -2
- package/dist/tools/index.js +26 -1
- package/dist/ui/App.js +97 -13
- package/dist/ui/StatusBar.d.ts +21 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TeamPanel.d.ts +57 -0
- package/dist/ui/TeamPanel.js +134 -0
- package/dist/ui/Transcript.js +10 -0
- package/dist/ui/entries/TeamCard.d.ts +31 -0
- package/dist/ui/entries/TeamCard.js +39 -0
- package/dist/ui/glyphs.d.ts +10 -0
- package/dist/ui/glyphs.js +6 -0
- package/dist/ui/layout/AppShell.d.ts +11 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/overlays/HelpOverlay.js +12 -0
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +1 -1
- package/dist/ui/use-startup-notices.js +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cold-start retry decision (team-live-activity F-4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
|
|
7
|
+
* toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
|
|
8
|
+
* written to the transcript, and no tool ran, so nothing was written to disk.
|
|
9
|
+
* Everything else about the child is fresh by construction.
|
|
10
|
+
*
|
|
11
|
+
* THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
|
|
12
|
+
* `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
|
|
13
|
+
* `retryable: true`, and every abort in this system reaches the provider as an
|
|
14
|
+
* aborted fetch - so a user pressing Esc produces a "retryable" error. The
|
|
15
|
+
* predicate below does NOT see that, and must not be asked to: `TeamRuntime`
|
|
16
|
+
* excludes the abort paths before it ever calls the predicate, and there are
|
|
17
|
+
* exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
|
|
18
|
+
* `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
|
|
19
|
+
* per-child timeout via the local `timedOut` flag). The turn-cap abort is
|
|
20
|
+
* excluded twice over, by `run.truncated` here and by `turns === 0`.
|
|
21
|
+
*
|
|
22
|
+
* There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
|
|
23
|
+
* agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
|
|
24
|
+
* breaks BEFORE emitting `message_update`, so on an aborted request the
|
|
25
|
+
* provider's `error` event never reaches a subscriber and `run.retryable` is
|
|
26
|
+
* never even set. That is a property of a `break` in another package that no
|
|
27
|
+
* test in this repo pins; it could change under a refactor of core's steering
|
|
28
|
+
* handling without anyone here noticing. The four exclusions stay, and the
|
|
29
|
+
* acceptance test keeps asserting all four from this side of the boundary.
|
|
30
|
+
*/
|
|
31
|
+
import type { SubagentRun } from './types.js';
|
|
32
|
+
/**
|
|
33
|
+
* Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
|
|
34
|
+
*
|
|
35
|
+
* The error is constructed inside `packages/core` and crosses a package
|
|
36
|
+
* boundary; a duplicated core instance (a linked checkout, a hoisting accident)
|
|
37
|
+
* would make `instanceof` false while the field is plainly there, and the
|
|
38
|
+
* failure mode would be "retries silently stopped happening". `formatStreamError`
|
|
39
|
+
* reads `errorType` exactly this way for the same reason (reducer.ts).
|
|
40
|
+
*/
|
|
41
|
+
export declare function isRetryableStreamError(err: unknown): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether this attempt may be replaced by a fresh child.
|
|
44
|
+
*
|
|
45
|
+
* `attemptsSoFar` counts the attempt that just ended, so the first failure calls
|
|
46
|
+
* this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
|
|
47
|
+
*/
|
|
48
|
+
export declare function shouldRetryColdStart(run: SubagentRun, attemptsSoFar: number): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cold-start retry decision (team-live-activity F-4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
|
|
7
|
+
* toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
|
|
8
|
+
* written to the transcript, and no tool ran, so nothing was written to disk.
|
|
9
|
+
* Everything else about the child is fresh by construction.
|
|
10
|
+
*
|
|
11
|
+
* THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
|
|
12
|
+
* `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
|
|
13
|
+
* `retryable: true`, and every abort in this system reaches the provider as an
|
|
14
|
+
* aborted fetch - so a user pressing Esc produces a "retryable" error. The
|
|
15
|
+
* predicate below does NOT see that, and must not be asked to: `TeamRuntime`
|
|
16
|
+
* excludes the abort paths before it ever calls the predicate, and there are
|
|
17
|
+
* exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
|
|
18
|
+
* `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
|
|
19
|
+
* per-child timeout via the local `timedOut` flag). The turn-cap abort is
|
|
20
|
+
* excluded twice over, by `run.truncated` here and by `turns === 0`.
|
|
21
|
+
*
|
|
22
|
+
* There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
|
|
23
|
+
* agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
|
|
24
|
+
* breaks BEFORE emitting `message_update`, so on an aborted request the
|
|
25
|
+
* provider's `error` event never reaches a subscriber and `run.retryable` is
|
|
26
|
+
* never even set. That is a property of a `break` in another package that no
|
|
27
|
+
* test in this repo pins; it could change under a refactor of core's steering
|
|
28
|
+
* handling without anyone here noticing. The four exclusions stay, and the
|
|
29
|
+
* acceptance test keeps asserting all four from this side of the boundary.
|
|
30
|
+
*/
|
|
31
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
32
|
+
/**
|
|
33
|
+
* Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
|
|
34
|
+
*
|
|
35
|
+
* The error is constructed inside `packages/core` and crosses a package
|
|
36
|
+
* boundary; a duplicated core instance (a linked checkout, a hoisting accident)
|
|
37
|
+
* would make `instanceof` false while the field is plainly there, and the
|
|
38
|
+
* failure mode would be "retries silently stopped happening". `formatStreamError`
|
|
39
|
+
* reads `errorType` exactly this way for the same reason (reducer.ts).
|
|
40
|
+
*/
|
|
41
|
+
export function isRetryableStreamError(err) {
|
|
42
|
+
return err?.retryable === true;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether this attempt may be replaced by a fresh child.
|
|
46
|
+
*
|
|
47
|
+
* `attemptsSoFar` counts the attempt that just ended, so the first failure calls
|
|
48
|
+
* this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
|
|
49
|
+
*/
|
|
50
|
+
export function shouldRetryColdStart(run, attemptsSoFar) {
|
|
51
|
+
return (attemptsSoFar <= TEAM_LIMITS.maxColdStartRetries &&
|
|
52
|
+
run.retryable === true &&
|
|
53
|
+
run.turns === 0 &&
|
|
54
|
+
run.toolCalls === 0 &&
|
|
55
|
+
!run.truncated);
|
|
56
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
|
|
3
|
+
* disposal (team-subagents §3.2 / §3.5 / §3.8).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ALL CONCURRENCY LIVES INSIDE ONE TOOL EXECUTION (I-1 / D-1). The core agent
|
|
8
|
+
* loop runs tool calls strictly sequentially, and that is not an accident to be
|
|
9
|
+
* "fixed": plan mode's human-input bridge holds ONE outstanding request slot and
|
|
10
|
+
* is correct only because tool calls never overlap (I-P11). So `task` takes a
|
|
11
|
+
* BATCH and the fan-out is scheduled here, where it is ours to control. Anyone
|
|
12
|
+
* tempted to make `agent-loop.ts` concurrent to "fix" parallel delegation is
|
|
13
|
+
* about to silently mis-route a plan approval to a question wizard.
|
|
14
|
+
*
|
|
15
|
+
* The scheduler is a plain slot pool: an index cursor plus `maxConcurrent`
|
|
16
|
+
* worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
|
|
17
|
+
* queue class, no event-loop trickery — every path is observable from the event
|
|
18
|
+
* stream.
|
|
19
|
+
*/
|
|
20
|
+
import type { ProviderRegistry, SkillRegistry, ToolPolicyDecision, ToolPolicyVerdict } from '@aragon-agent/core';
|
|
21
|
+
import type { AgentMode } from '../agent/agent-mode.js';
|
|
22
|
+
import type { CliConfig } from '../config/schema.js';
|
|
23
|
+
import type { TeamHumanQueue } from './human-queue.js';
|
|
24
|
+
import { type SubagentAgentFactory } from './subagent.js';
|
|
25
|
+
import type { DispatchOutcome, SubagentSpec, TeamEventListener, TeamSnapshot } from './types.js';
|
|
26
|
+
export interface TeamRuntimeDeps {
|
|
27
|
+
/** Read LIVE: a settings-screen model or key edit must reach the next dispatch. */
|
|
28
|
+
getConfig: () => CliConfig;
|
|
29
|
+
providerRegistry: ProviderRegistry;
|
|
30
|
+
getCwd: () => string;
|
|
31
|
+
getMode: () => AgentMode;
|
|
32
|
+
getApiKey: (providerId: string) => string | undefined;
|
|
33
|
+
skillRegistry?: SkillRegistry;
|
|
34
|
+
alwaysBlock?: () => string;
|
|
35
|
+
toolPolicy?: () => ToolPolicyDecision;
|
|
36
|
+
onChildToolPolicyEvent?: (e: {
|
|
37
|
+
label: string;
|
|
38
|
+
tool: string;
|
|
39
|
+
verdict: ToolPolicyVerdict;
|
|
40
|
+
sourceNames: string[];
|
|
41
|
+
}) => void;
|
|
42
|
+
humanQueue?: TeamHumanQueue;
|
|
43
|
+
/** Test seam (§8.1): a stub factory keeps the whole scheduler off the network. */
|
|
44
|
+
agentFactory?: SubagentAgentFactory;
|
|
45
|
+
}
|
|
46
|
+
export declare class TeamRuntime {
|
|
47
|
+
private readonly deps;
|
|
48
|
+
private readonly listeners;
|
|
49
|
+
private live;
|
|
50
|
+
private bus;
|
|
51
|
+
private snapshotState;
|
|
52
|
+
private busy;
|
|
53
|
+
private aborted;
|
|
54
|
+
private counter;
|
|
55
|
+
private dispatchTimer;
|
|
56
|
+
/**
|
|
57
|
+
* Every in-flight retry backoff, so an abort releases ALL of them (P1-7).
|
|
58
|
+
*
|
|
59
|
+
* A SET, NOT ONE FIELD, and the plural is load-bearing: the failure that
|
|
60
|
+
* triggers a cold start is usually provider-wide, so a 429 fails every
|
|
61
|
+
* in-flight child's first request in the same tick and up to `maxConcurrent`
|
|
62
|
+
* children enter backoff together. With a single stored resolver two of three
|
|
63
|
+
* are overwritten and only the last is released, so "an abort during the
|
|
64
|
+
* backoff window ends the dispatch promptly" would hold for one child and
|
|
65
|
+
* quietly not for the others.
|
|
66
|
+
*/
|
|
67
|
+
private readonly pendingBackoffs;
|
|
68
|
+
constructor(deps: TeamRuntimeDeps);
|
|
69
|
+
subscribe(listener: TeamEventListener): () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Unreachable while I-1 holds, and present anyway so "only one dispatch at a
|
|
72
|
+
* time" is a property of THIS module rather than a consequence of another
|
|
73
|
+
* package's loop shape (R-14).
|
|
74
|
+
*/
|
|
75
|
+
isBusy(): boolean;
|
|
76
|
+
/** The live roster, for `/team` status. `null` when nothing is dispatching. */
|
|
77
|
+
snapshot(): TeamSnapshot | null;
|
|
78
|
+
dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal): Promise<DispatchOutcome>;
|
|
79
|
+
/** One slot of the pool: take the next index until the list is exhausted. */
|
|
80
|
+
private worker;
|
|
81
|
+
/**
|
|
82
|
+
* Run one child to a terminal phase, replacing it once if its very first
|
|
83
|
+
* request died on a transport error before it did anything at all (F-4).
|
|
84
|
+
*/
|
|
85
|
+
private runOne;
|
|
86
|
+
/**
|
|
87
|
+
* Swap a cold-started child for a fresh one, carrying only what must survive.
|
|
88
|
+
*
|
|
89
|
+
* `this.live` IS `handles`, so the single assignment below also makes
|
|
90
|
+
* `abortAll()` abort the NEW child and makes the outcome carry the new run.
|
|
91
|
+
* Anyone tempted to "clean up" that aliasing into a defensive copy must update
|
|
92
|
+
* both places.
|
|
93
|
+
*/
|
|
94
|
+
private replaceForRetry;
|
|
95
|
+
/**
|
|
96
|
+
* The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
|
|
97
|
+
* then error, then no-summary, then done.
|
|
98
|
+
*
|
|
99
|
+
* Extracted verbatim so the retry loop has one exit and the order lives in one
|
|
100
|
+
* place.
|
|
101
|
+
*/
|
|
102
|
+
private settlePhase;
|
|
103
|
+
/**
|
|
104
|
+
* Sleep, releasing early when the dispatch aborts.
|
|
105
|
+
*
|
|
106
|
+
* The timer is `unref`'d so a pending backoff cannot hold the process open on
|
|
107
|
+
* exit, and the resolver is registered in `pendingBackoffs` so `abortAll()`
|
|
108
|
+
* releases EVERY one of them rather than only the last (P1-7).
|
|
109
|
+
*/
|
|
110
|
+
private backoff;
|
|
111
|
+
/**
|
|
112
|
+
* Stop every child and settle every blocking wait.
|
|
113
|
+
*
|
|
114
|
+
* Idempotent, and safe to call when nothing is running — `AgentController.abort()`
|
|
115
|
+
* calls it unconditionally.
|
|
116
|
+
*/
|
|
117
|
+
abortAll(): void;
|
|
118
|
+
/** Process-exit path: abort everything and clear timers (R-15). */
|
|
119
|
+
dispose(): void;
|
|
120
|
+
private publish;
|
|
121
|
+
private emit;
|
|
122
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
|
|
3
|
+
* disposal (team-subagents §3.2 / §3.5 / §3.8).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ALL CONCURRENCY LIVES INSIDE ONE TOOL EXECUTION (I-1 / D-1). The core agent
|
|
8
|
+
* loop runs tool calls strictly sequentially, and that is not an accident to be
|
|
9
|
+
* "fixed": plan mode's human-input bridge holds ONE outstanding request slot and
|
|
10
|
+
* is correct only because tool calls never overlap (I-P11). So `task` takes a
|
|
11
|
+
* BATCH and the fan-out is scheduled here, where it is ours to control. Anyone
|
|
12
|
+
* tempted to make `agent-loop.ts` concurrent to "fix" parallel delegation is
|
|
13
|
+
* about to silently mis-route a plan approval to a question wizard.
|
|
14
|
+
*
|
|
15
|
+
* The scheduler is a plain slot pool: an index cursor plus `maxConcurrent`
|
|
16
|
+
* worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
|
|
17
|
+
* queue class, no event-loop trickery — every path is observable from the event
|
|
18
|
+
* stream.
|
|
19
|
+
*/
|
|
20
|
+
import { TeamBus } from './bus.js';
|
|
21
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
22
|
+
import { shouldRetryColdStart } from './retry.js';
|
|
23
|
+
import { createSubagent, defaultSubagentFactory, } from './subagent.js';
|
|
24
|
+
export class TeamRuntime {
|
|
25
|
+
deps;
|
|
26
|
+
listeners = new Set();
|
|
27
|
+
live = [];
|
|
28
|
+
bus = null;
|
|
29
|
+
snapshotState = null;
|
|
30
|
+
busy = false;
|
|
31
|
+
aborted = false;
|
|
32
|
+
counter = 0;
|
|
33
|
+
dispatchTimer = null;
|
|
34
|
+
/**
|
|
35
|
+
* Every in-flight retry backoff, so an abort releases ALL of them (P1-7).
|
|
36
|
+
*
|
|
37
|
+
* A SET, NOT ONE FIELD, and the plural is load-bearing: the failure that
|
|
38
|
+
* triggers a cold start is usually provider-wide, so a 429 fails every
|
|
39
|
+
* in-flight child's first request in the same tick and up to `maxConcurrent`
|
|
40
|
+
* children enter backoff together. With a single stored resolver two of three
|
|
41
|
+
* are overwritten and only the last is released, so "an abort during the
|
|
42
|
+
* backoff window ends the dispatch promptly" would hold for one child and
|
|
43
|
+
* quietly not for the others.
|
|
44
|
+
*/
|
|
45
|
+
pendingBackoffs = new Set();
|
|
46
|
+
constructor(deps) {
|
|
47
|
+
this.deps = deps;
|
|
48
|
+
}
|
|
49
|
+
subscribe(listener) {
|
|
50
|
+
this.listeners.add(listener);
|
|
51
|
+
return () => this.listeners.delete(listener);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Unreachable while I-1 holds, and present anyway so "only one dispatch at a
|
|
55
|
+
* time" is a property of THIS module rather than a consequence of another
|
|
56
|
+
* package's loop shape (R-14).
|
|
57
|
+
*/
|
|
58
|
+
isBusy() {
|
|
59
|
+
return this.busy;
|
|
60
|
+
}
|
|
61
|
+
/** The live roster, for `/team` status. `null` when nothing is dispatching. */
|
|
62
|
+
snapshot() {
|
|
63
|
+
return this.snapshotState;
|
|
64
|
+
}
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// Dispatch
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
async dispatch(specs, requested, signal) {
|
|
69
|
+
this.counter += 1;
|
|
70
|
+
const dispatchId = `d${this.counter}`;
|
|
71
|
+
const config = this.deps.getConfig();
|
|
72
|
+
const team = config.team;
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
this.busy = true;
|
|
75
|
+
this.aborted = false;
|
|
76
|
+
// DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
|
|
77
|
+
// this binding so it reads the LIVE array: F-4 replaces `handles[index]`
|
|
78
|
+
// mid-dispatch, and a captured array or a captured handle would answer for
|
|
79
|
+
// the discarded child. The bus is built from `specs` and the handles are
|
|
80
|
+
// built from the bus, so the existing construction order forces the hoist.
|
|
81
|
+
// `canSend` is only ever called from inside a `team_wait` on a child that
|
|
82
|
+
// does not exist yet, so the TDZ window is not reachable.
|
|
83
|
+
let handles = [];
|
|
84
|
+
/**
|
|
85
|
+
* Whether `label` still has a loop that could call `team_send` (F-3 / P0-2).
|
|
86
|
+
*
|
|
87
|
+
* NEGATIVE, over TERMINAL PHASES ONLY. "The child's phase is running" is the
|
|
88
|
+
* natural way to write this and it is wrong on the SHIPPED DEFAULTS: at
|
|
89
|
+
* `maxConcurrent: 3, maxSubagents: 5`, children 4 and 5 sit in `queued` for
|
|
90
|
+
* the first part of every five-way dispatch, and a wait on one of them would
|
|
91
|
+
* be refused with "nobody can answer: a4 already finished". `queued` is not
|
|
92
|
+
* terminal - that child has not started yet and will.
|
|
93
|
+
*
|
|
94
|
+
* An unknown label permits, so this can never become a second, silent path
|
|
95
|
+
* to a refusal: `checkWaitable` has its own `unknown_sender` branch.
|
|
96
|
+
*/
|
|
97
|
+
const canSend = (label) => {
|
|
98
|
+
const handle = handles.find((h) => h.run.label === label);
|
|
99
|
+
if (!handle)
|
|
100
|
+
return true;
|
|
101
|
+
const phase = handle.run.phase;
|
|
102
|
+
return phase !== 'done' && phase !== 'failed' && phase !== 'aborted';
|
|
103
|
+
};
|
|
104
|
+
const bus = new TeamBus(specs.map((s) => s.label), {
|
|
105
|
+
onMessage: (message) => this.emit({ type: 'message', dispatchId, message }),
|
|
106
|
+
canSend,
|
|
107
|
+
});
|
|
108
|
+
this.bus = bus;
|
|
109
|
+
const subDeps = {
|
|
110
|
+
bus,
|
|
111
|
+
config,
|
|
112
|
+
providerRegistry: this.deps.providerRegistry,
|
|
113
|
+
getCwd: this.deps.getCwd,
|
|
114
|
+
getMode: this.deps.getMode,
|
|
115
|
+
getApiKey: this.deps.getApiKey,
|
|
116
|
+
...(this.deps.skillRegistry ? { skillRegistry: this.deps.skillRegistry } : {}),
|
|
117
|
+
...(this.deps.alwaysBlock ? { alwaysBlock: this.deps.alwaysBlock() } : {}),
|
|
118
|
+
...(this.deps.toolPolicy ? { toolPolicy: this.deps.toolPolicy } : {}),
|
|
119
|
+
...(this.deps.onChildToolPolicyEvent
|
|
120
|
+
? { onChildToolPolicyEvent: this.deps.onChildToolPolicyEvent }
|
|
121
|
+
: {}),
|
|
122
|
+
confirmTools: config.confirmTools,
|
|
123
|
+
...(this.deps.humanQueue ? { humanQueue: this.deps.humanQueue } : {}),
|
|
124
|
+
agentFactory: this.deps.agentFactory ?? defaultSubagentFactory,
|
|
125
|
+
};
|
|
126
|
+
// Hoisted into a factory so a retry can build a fresh child with IDENTICAL
|
|
127
|
+
// wiring (F-4). Replacing the child rather than re-prompting it is the only
|
|
128
|
+
// shape where the retried child is genuinely equivalent to the first:
|
|
129
|
+
// `Agent.prompt()` appends to the same history, so a second prompt would
|
|
130
|
+
// show the child its brief twice.
|
|
131
|
+
const makeHandle = (spec) => createSubagent(spec, subDeps, {
|
|
132
|
+
onUpdate: (run) => this.publish(dispatchId, run),
|
|
133
|
+
onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage }),
|
|
134
|
+
});
|
|
135
|
+
// ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
|
|
136
|
+
// the bus, `canSend` would see the empty array forever, and every peer would
|
|
137
|
+
// look live - which fails open rather than closed, so nothing would break
|
|
138
|
+
// loudly.
|
|
139
|
+
handles = specs.map(makeHandle);
|
|
140
|
+
this.live = handles;
|
|
141
|
+
this.snapshotState = {
|
|
142
|
+
dispatchId,
|
|
143
|
+
active: true,
|
|
144
|
+
runs: handles.map((h) => ({ ...h.run })),
|
|
145
|
+
requested,
|
|
146
|
+
startedAt,
|
|
147
|
+
messageCount: 0,
|
|
148
|
+
};
|
|
149
|
+
this.emit({ type: 'dispatch_start', dispatchId, requested, specs });
|
|
150
|
+
// TWO ABORT SOURCES, ON PURPOSE (§3.8). `ctx.signal` is the correct path
|
|
151
|
+
// today; `AgentController.abort()` also calls `abortAll()` directly, which
|
|
152
|
+
// makes "no child survives its dispatch" a property of the controller rather
|
|
153
|
+
// than a consequence of signal plumbing three modules away.
|
|
154
|
+
const onAbort = () => this.abortAll();
|
|
155
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
156
|
+
// The dispatch ceiling has to be enforced HERE as well as through
|
|
157
|
+
// `toolTimeoutOverrides.task`: the executor's timeout is COOPERATIVE (I-2),
|
|
158
|
+
// so the override only bites because `task` listens to `ctx.signal` — and a
|
|
159
|
+
// second, independent timer means a signal that never arrives still ends the
|
|
160
|
+
// dispatch.
|
|
161
|
+
this.dispatchTimer = setTimeout(() => this.abortAll(), team.dispatchTimeoutMs);
|
|
162
|
+
const runCtx = {
|
|
163
|
+
dispatchId,
|
|
164
|
+
handles,
|
|
165
|
+
makeHandle,
|
|
166
|
+
subagentTimeoutMs: team.subagentTimeoutMs,
|
|
167
|
+
};
|
|
168
|
+
const cursor = { next: 0 };
|
|
169
|
+
const workers = Array.from({ length: Math.max(1, Math.min(team.maxConcurrent, handles.length)) }, () => this.worker(runCtx, cursor));
|
|
170
|
+
await Promise.allSettled(workers);
|
|
171
|
+
if (this.dispatchTimer)
|
|
172
|
+
clearTimeout(this.dispatchTimer);
|
|
173
|
+
this.dispatchTimer = null;
|
|
174
|
+
signal?.removeEventListener('abort', onAbort);
|
|
175
|
+
for (const handle of handles)
|
|
176
|
+
handle.unsubscribe();
|
|
177
|
+
const outcome = {
|
|
178
|
+
dispatchId,
|
|
179
|
+
runs: handles.map((h) => ({ ...h.run, filesTouched: [...h.run.filesTouched] })),
|
|
180
|
+
requested,
|
|
181
|
+
startedAt,
|
|
182
|
+
endedAt: Date.now(),
|
|
183
|
+
aborted: this.aborted,
|
|
184
|
+
leadMail: bus.leadMail(),
|
|
185
|
+
usage: handles.reduce((acc, h) => ({
|
|
186
|
+
inputTokens: acc.inputTokens + h.run.usage.inputTokens,
|
|
187
|
+
outputTokens: acc.outputTokens + h.run.usage.outputTokens,
|
|
188
|
+
}), { inputTokens: 0, outputTokens: 0 }),
|
|
189
|
+
};
|
|
190
|
+
this.live = [];
|
|
191
|
+
this.bus = null;
|
|
192
|
+
this.snapshotState = null;
|
|
193
|
+
this.busy = false;
|
|
194
|
+
this.emit({ type: 'dispatch_end', dispatchId, outcome });
|
|
195
|
+
return outcome;
|
|
196
|
+
}
|
|
197
|
+
/** One slot of the pool: take the next index until the list is exhausted. */
|
|
198
|
+
async worker(ctx, cursor) {
|
|
199
|
+
for (;;) {
|
|
200
|
+
const index = cursor.next;
|
|
201
|
+
cursor.next += 1;
|
|
202
|
+
const handle = ctx.handles[index];
|
|
203
|
+
if (!handle)
|
|
204
|
+
return;
|
|
205
|
+
if (this.aborted) {
|
|
206
|
+
// Never started. Marked here rather than left `queued`, so the report
|
|
207
|
+
// accounts for every spec the dispatch accepted.
|
|
208
|
+
handle.run.phase = 'aborted';
|
|
209
|
+
handle.run.endedAt = Date.now();
|
|
210
|
+
this.publish(ctx.dispatchId, handle.run);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
await this.runOne(ctx, index);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Run one child to a terminal phase, replacing it once if its very first
|
|
218
|
+
* request died on a transport error before it did anything at all (F-4).
|
|
219
|
+
*/
|
|
220
|
+
async runOne(ctx, index) {
|
|
221
|
+
const { dispatchId, handles, makeHandle, subagentTimeoutMs } = ctx;
|
|
222
|
+
for (let attempt = 0;; attempt += 1) {
|
|
223
|
+
const handle = handles[index];
|
|
224
|
+
const { run, agent } = handle;
|
|
225
|
+
run.phase = 'starting';
|
|
226
|
+
run.startedAt = Date.now();
|
|
227
|
+
this.publish(dispatchId, run);
|
|
228
|
+
let timedOut = false;
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
timedOut = true;
|
|
231
|
+
agent.abort();
|
|
232
|
+
}, subagentTimeoutMs);
|
|
233
|
+
try {
|
|
234
|
+
await agent.prompt(handle.spec.prompt);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// `Agent.prompt()` resolves even on failure, so this only fires for a
|
|
238
|
+
// stub factory or a genuine throw. Recorded rather than swallowed: a
|
|
239
|
+
// child that failed for a reason nobody can see is the worst outcome the
|
|
240
|
+
// report can produce.
|
|
241
|
+
run.error = err instanceof Error ? err.message : String(err);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
// THE TWO ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
|
|
247
|
+
// this system reaches the provider as an aborted fetch, and `core` marks
|
|
248
|
+
// an `AbortError` retryable - so Esc, `ctx.signal`, the dispatch timeout
|
|
249
|
+
// (all three via `this.aborted`) and the per-child timeout (`timedOut`)
|
|
250
|
+
// are excluded HERE, before `shouldRetryColdStart` is ever consulted.
|
|
251
|
+
const retryable = !this.aborted && !timedOut && shouldRetryColdStart(run, attempt + 1);
|
|
252
|
+
if (!retryable) {
|
|
253
|
+
run.endedAt = Date.now();
|
|
254
|
+
this.settlePhase(run, timedOut, subagentTimeoutMs);
|
|
255
|
+
this.publish(dispatchId, run);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const fresh = this.replaceForRetry(dispatchId, handles, index, makeHandle);
|
|
259
|
+
await this.backoff(TEAM_LIMITS.retryBackoffMs);
|
|
260
|
+
if (this.aborted) {
|
|
261
|
+
fresh.run.phase = 'aborted';
|
|
262
|
+
fresh.run.endedAt = Date.now();
|
|
263
|
+
this.publish(dispatchId, fresh.run);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Swap a cold-started child for a fresh one, carrying only what must survive.
|
|
270
|
+
*
|
|
271
|
+
* `this.live` IS `handles`, so the single assignment below also makes
|
|
272
|
+
* `abortAll()` abort the NEW child and makes the outcome carry the new run.
|
|
273
|
+
* Anyone tempted to "clean up" that aliasing into a defensive copy must update
|
|
274
|
+
* both places.
|
|
275
|
+
*/
|
|
276
|
+
replaceForRetry(dispatchId, handles, index, makeHandle) {
|
|
277
|
+
const handle = handles[index];
|
|
278
|
+
const carried = { ...handle.run.usage };
|
|
279
|
+
const retries = (handle.run.retries ?? 0) + 1;
|
|
280
|
+
handle.unsubscribe();
|
|
281
|
+
try {
|
|
282
|
+
handle.agent.abort();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Already gone; this must not stop the retry.
|
|
286
|
+
}
|
|
287
|
+
const fresh = makeHandle(handle.spec);
|
|
288
|
+
// Provably {0,0} under the predicate above (no turn completed, so no
|
|
289
|
+
// `turn_end` and no usage event). Carried anyway so that a future relaxation
|
|
290
|
+
// of the predicate cannot silently under-report spend - the most misleading
|
|
291
|
+
// failure this feature can produce.
|
|
292
|
+
fresh.run.usage = carried;
|
|
293
|
+
fresh.run.retries = retries;
|
|
294
|
+
handles[index] = fresh;
|
|
295
|
+
this.publish(dispatchId, fresh.run);
|
|
296
|
+
return fresh;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
|
|
300
|
+
* then error, then no-summary, then done.
|
|
301
|
+
*
|
|
302
|
+
* Extracted verbatim so the retry loop has one exit and the order lives in one
|
|
303
|
+
* place.
|
|
304
|
+
*/
|
|
305
|
+
settlePhase(run, timedOut, subagentTimeoutMs) {
|
|
306
|
+
if (this.aborted) {
|
|
307
|
+
run.phase = 'aborted';
|
|
308
|
+
}
|
|
309
|
+
else if (timedOut) {
|
|
310
|
+
run.phase = 'failed';
|
|
311
|
+
run.error = run.error ?? `subagent exceeded ${Math.round(subagentTimeoutMs / 1000)}s`;
|
|
312
|
+
}
|
|
313
|
+
else if (run.error) {
|
|
314
|
+
run.phase = 'failed';
|
|
315
|
+
}
|
|
316
|
+
else if (!run.summary) {
|
|
317
|
+
// A child whose watchdog fired, or whose stream died silently, resolves
|
|
318
|
+
// with nothing. `[failed: run produced no output]` is the honest reading;
|
|
319
|
+
// `[ok]` with an empty section is not.
|
|
320
|
+
run.phase = 'failed';
|
|
321
|
+
run.error = 'run produced no output';
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
run.phase = 'done';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Sleep, releasing early when the dispatch aborts.
|
|
329
|
+
*
|
|
330
|
+
* The timer is `unref`'d so a pending backoff cannot hold the process open on
|
|
331
|
+
* exit, and the resolver is registered in `pendingBackoffs` so `abortAll()`
|
|
332
|
+
* releases EVERY one of them rather than only the last (P1-7).
|
|
333
|
+
*/
|
|
334
|
+
backoff(ms) {
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
const done = () => {
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
this.pendingBackoffs.delete(done);
|
|
339
|
+
resolve();
|
|
340
|
+
};
|
|
341
|
+
const timer = setTimeout(done, ms);
|
|
342
|
+
timer.unref?.();
|
|
343
|
+
this.pendingBackoffs.add(done);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// -------------------------------------------------------------------------
|
|
347
|
+
// Abort / dispose
|
|
348
|
+
// -------------------------------------------------------------------------
|
|
349
|
+
/**
|
|
350
|
+
* Stop every child and settle every blocking wait.
|
|
351
|
+
*
|
|
352
|
+
* Idempotent, and safe to call when nothing is running — `AgentController.abort()`
|
|
353
|
+
* calls it unconditionally.
|
|
354
|
+
*/
|
|
355
|
+
abortAll() {
|
|
356
|
+
if (!this.busy)
|
|
357
|
+
return;
|
|
358
|
+
this.aborted = true;
|
|
359
|
+
for (const handle of this.live) {
|
|
360
|
+
try {
|
|
361
|
+
handle.agent.abort();
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// A child that is already gone must not stop the others being aborted.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.bus?.cancelAllWaits();
|
|
368
|
+
// Every in-flight retry backoff, not just the last one registered: a
|
|
369
|
+
// provider-wide failure puts up to `maxConcurrent` children here at once.
|
|
370
|
+
for (const release of [...this.pendingBackoffs])
|
|
371
|
+
release();
|
|
372
|
+
}
|
|
373
|
+
/** Process-exit path: abort everything and clear timers (R-15). */
|
|
374
|
+
dispose() {
|
|
375
|
+
this.abortAll();
|
|
376
|
+
if (this.dispatchTimer)
|
|
377
|
+
clearTimeout(this.dispatchTimer);
|
|
378
|
+
this.dispatchTimer = null;
|
|
379
|
+
this.listeners.clear();
|
|
380
|
+
}
|
|
381
|
+
// -------------------------------------------------------------------------
|
|
382
|
+
// Events
|
|
383
|
+
// -------------------------------------------------------------------------
|
|
384
|
+
publish(dispatchId, run) {
|
|
385
|
+
const copy = { ...run, filesTouched: [...run.filesTouched] };
|
|
386
|
+
if (this.snapshotState && this.snapshotState.dispatchId === dispatchId) {
|
|
387
|
+
this.snapshotState = {
|
|
388
|
+
...this.snapshotState,
|
|
389
|
+
runs: this.snapshotState.runs.map((r) => (r.label === copy.label ? copy : r)),
|
|
390
|
+
messageCount: this.bus?.messageCount() ?? this.snapshotState.messageCount,
|
|
391
|
+
...(this.bus?.lastMessage() ? { lastMessage: this.bus.lastMessage() } : {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
this.emit({ type: 'agent_update', dispatchId, run: copy });
|
|
395
|
+
}
|
|
396
|
+
emit(event) {
|
|
397
|
+
for (const listener of [...this.listeners]) {
|
|
398
|
+
try {
|
|
399
|
+
listener(event);
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// One bad subscriber must not take the dispatch down with it.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|