@cruxy/cli 0.22.1 → 0.24.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/dist/agent/loop.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/approval/classify.js +18 -0
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +104 -21
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +27 -4
- package/dist/cli/session-factory.js +234 -58
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +51 -0
- package/dist/errors/constructors.js +97 -0
- package/dist/errors/types.d.ts +28 -0
- package/dist/errors/types.js +38 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +81 -1
- package/dist/subagent/orchestrator.js +204 -17
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +27 -0
- package/dist/subagent/semaphore.js +56 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +121 -9
- package/dist/tools/file/paths.js +164 -10
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +43 -4
- package/dist/tools/types.d.ts +74 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
5
5
|
import type { Router } from "../routing/index.js";
|
|
6
6
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
7
7
|
import type { SandboxService } from "../sandbox/index.js";
|
|
8
|
+
import { Workspace } from "../workspace/index.js";
|
|
8
9
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
9
10
|
/**
|
|
10
11
|
* Everything a spawn needs from the surrounding session, injected by the
|
|
@@ -24,6 +25,13 @@ export interface SubagentOrchestratorDeps {
|
|
|
24
25
|
/** The parent's registry — the ceiling every child scope derives from. */
|
|
25
26
|
parentRegistry: ToolRegistry;
|
|
26
27
|
cwd: string;
|
|
28
|
+
/**
|
|
29
|
+
* The session's declared workspace (C.26). Threaded onto the child ctx so a
|
|
30
|
+
* subagent sees the SAME roots as the main loop — without it the child would
|
|
31
|
+
* fall back to a single-root workspace over `cwd` and its fan tools would go
|
|
32
|
+
* silently primary-only (a split-brain WITHIN one session).
|
|
33
|
+
*/
|
|
34
|
+
workspace: Workspace;
|
|
27
35
|
logger: ToolContext["logger"];
|
|
28
36
|
git?: {
|
|
29
37
|
branch: string;
|
|
@@ -45,6 +53,13 @@ export interface SubagentOrchestratorDeps {
|
|
|
45
53
|
* one checkpoint, C.32).
|
|
46
54
|
*/
|
|
47
55
|
makeChildApproval(): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
56
|
+
/**
|
|
57
|
+
* Whether per-root checkpointing is active for the run (C.26 step 3). Threaded
|
|
58
|
+
* onto the child ctx so a subagent's non-primary writes lift the JC-1 refusal on
|
|
59
|
+
* the SAME condition as the parent — and, because `makeChildApproval` wraps the
|
|
60
|
+
* same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
|
|
61
|
+
*/
|
|
62
|
+
checkpointsActive?: boolean;
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
50
65
|
* Spawns subagents (C.14): the existing agent loop re-driven over isolated
|
|
@@ -61,7 +76,19 @@ export interface SubagentOrchestratorDeps {
|
|
|
61
76
|
*/
|
|
62
77
|
export declare class SubagentOrchestrator {
|
|
63
78
|
private readonly deps;
|
|
79
|
+
/**
|
|
80
|
+
* The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
|
|
81
|
+
* session and reused by every {@link spawnMany} call (nested spawns reuse this
|
|
82
|
+
* same orchestrator instance), so `subagent.maxConcurrency` caps the number of
|
|
83
|
+
* concurrently-executing subagents across the WHOLE session — not per parent.
|
|
84
|
+
*/
|
|
85
|
+
private readonly sem;
|
|
64
86
|
constructor(deps: SubagentOrchestratorDeps);
|
|
87
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
88
|
+
get concurrency(): {
|
|
89
|
+
available: number;
|
|
90
|
+
waiting: number;
|
|
91
|
+
};
|
|
65
92
|
/**
|
|
66
93
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
67
94
|
* main agent is 0); spawning past `subagent.maxDepth` throws
|
|
@@ -74,7 +101,60 @@ export declare class SubagentOrchestrator {
|
|
|
74
101
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
75
102
|
* not a way to swallow it).
|
|
76
103
|
*/
|
|
77
|
-
spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
|
|
104
|
+
spawn(spec: SubagentSpec, parentDepth: number, opts?: SpawnOptions): Promise<SubagentResult>;
|
|
105
|
+
/**
|
|
106
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
107
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
108
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
109
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
110
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
111
|
+
* stays deadlock-free.
|
|
112
|
+
*
|
|
113
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
114
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
115
|
+
*
|
|
116
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
117
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
118
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
119
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
120
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
121
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
122
|
+
* throw propagates — never a detached, still-running sibling.
|
|
123
|
+
*/
|
|
124
|
+
spawnMany(specs: readonly SubagentSpec[], parentDepth: number, opts?: {
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
}): Promise<SubagentResult[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
129
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
130
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
131
|
+
*/
|
|
132
|
+
private childScope;
|
|
133
|
+
/**
|
|
134
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
135
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
136
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
137
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
138
|
+
* children never conflict.
|
|
139
|
+
*
|
|
140
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
141
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
142
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
143
|
+
* the error message is explicit about.
|
|
144
|
+
*/
|
|
145
|
+
private assertDisjointWriteScopes;
|
|
78
146
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
79
147
|
private toResult;
|
|
80
148
|
}
|
|
149
|
+
/** Per-spawn options: cancellation + a per-child render tag for a fan-out. */
|
|
150
|
+
interface SpawnOptions {
|
|
151
|
+
/** Cooperative cancellation; threaded into `runAgent` and `ctx.signal`. */
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
/**
|
|
154
|
+
* A short per-child label for a parallel fan-out (JC-F), e.g. `"2/3"`, so
|
|
155
|
+
* interleaved trail notes are attributable to the child that emitted them.
|
|
156
|
+
* Undefined for a single sequential spawn — chrome is byte-identical to C.14.
|
|
157
|
+
*/
|
|
158
|
+
slot?: string;
|
|
159
|
+
}
|
|
160
|
+
export {};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, } from "../errors/index.js";
|
|
3
|
+
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { Workspace } from "../workspace/index.js";
|
|
4
5
|
import { Budget, resolveBudget } from "./budget.js";
|
|
5
|
-
import { scopeRegistry } from "./registry-scope.js";
|
|
6
|
+
import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
|
|
7
|
+
import { Semaphore } from "./semaphore.js";
|
|
6
8
|
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
7
9
|
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
8
10
|
const LABEL_MAX = 60;
|
|
@@ -21,8 +23,20 @@ const LABEL_MAX = 60;
|
|
|
21
23
|
*/
|
|
22
24
|
export class SubagentOrchestrator {
|
|
23
25
|
deps;
|
|
26
|
+
/**
|
|
27
|
+
* The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
|
|
28
|
+
* session and reused by every {@link spawnMany} call (nested spawns reuse this
|
|
29
|
+
* same orchestrator instance), so `subagent.maxConcurrency` caps the number of
|
|
30
|
+
* concurrently-executing subagents across the WHOLE session — not per parent.
|
|
31
|
+
*/
|
|
32
|
+
sem;
|
|
24
33
|
constructor(deps) {
|
|
25
34
|
this.deps = deps;
|
|
35
|
+
this.sem = new Semaphore(deps.config.subagent.maxConcurrency);
|
|
36
|
+
}
|
|
37
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
38
|
+
get concurrency() {
|
|
39
|
+
return { available: this.sem.available, waiting: this.sem.waiting };
|
|
26
40
|
}
|
|
27
41
|
/**
|
|
28
42
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
@@ -36,7 +50,7 @@ export class SubagentOrchestrator {
|
|
|
36
50
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
37
51
|
* not a way to swallow it).
|
|
38
52
|
*/
|
|
39
|
-
async spawn(spec, parentDepth) {
|
|
53
|
+
async spawn(spec, parentDepth, opts = {}) {
|
|
40
54
|
const { deps } = this;
|
|
41
55
|
const { maxDepth, defaultBudget } = deps.config.subagent;
|
|
42
56
|
if (parentDepth >= maxDepth) {
|
|
@@ -45,31 +59,44 @@ export class SubagentOrchestrator {
|
|
|
45
59
|
const childDepth = parentDepth + 1;
|
|
46
60
|
// Scoped-down registry (throws on a tool the parent lacks — the spawn tool
|
|
47
61
|
// surfaces that to the model), plus a depth-bound spawn tool only while
|
|
48
|
-
// nesting is still allowed.
|
|
62
|
+
// nesting is still allowed. (Only the SEQUENTIAL spawn is re-added; parallel
|
|
63
|
+
// fan-out stays a depth-0 capability, so a child cannot nest a fan-out.)
|
|
49
64
|
const registry = scopeRegistry(deps.parentRegistry, spec.tools);
|
|
50
65
|
if (childDepth < maxDepth) {
|
|
51
66
|
registry.register(makeSpawnSubagentTool(this, childDepth));
|
|
52
67
|
}
|
|
53
68
|
const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
|
|
69
|
+
// Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
|
|
70
|
+
// that ONE root (its writes land there and nowhere else). Omitted → the full
|
|
71
|
+
// session workspace, unchanged from C.14. An unknown name fails loud here
|
|
72
|
+
// (CRUXY_E_ROOT_UNKNOWN), which the spawn tool surfaces to the model.
|
|
73
|
+
const scope = this.childScope(spec.root);
|
|
54
74
|
// Fresh gate per child (own allowlist), recording what it approves so the
|
|
55
|
-
// result can name the artifacts without ever shipping the transcript.
|
|
75
|
+
// result can name the artifacts without ever shipping the transcript. The
|
|
76
|
+
// gate itself is serialized by the shared approval mutex (wired at the
|
|
77
|
+
// session factory), so concurrent siblings never prompt or checkpoint at once.
|
|
56
78
|
const approve = deps.makeChildApproval();
|
|
57
79
|
const artifacts = new Set();
|
|
58
80
|
const ctx = {
|
|
59
|
-
cwd:
|
|
81
|
+
cwd: scope.cwd,
|
|
82
|
+
workspace: scope.workspace,
|
|
60
83
|
config: deps.config,
|
|
61
84
|
logger: deps.logger,
|
|
62
85
|
requestApproval: async (action) => {
|
|
63
86
|
const decision = await approve(action);
|
|
64
87
|
if (decision.allow)
|
|
65
|
-
recordArtifacts(action, artifacts,
|
|
88
|
+
recordArtifacts(action, artifacts, scope.cwd);
|
|
66
89
|
return decision;
|
|
67
90
|
},
|
|
91
|
+
checkpointsActive: deps.checkpointsActive,
|
|
68
92
|
sandbox: deps.sandbox,
|
|
93
|
+
signal: opts.signal,
|
|
69
94
|
};
|
|
70
95
|
const label = taskLabel(spec.task);
|
|
96
|
+
const tag = opts.slot; // per-child render label for a fan-out (JC-F)
|
|
97
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
71
98
|
if (deps.renderer) {
|
|
72
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.play}
|
|
99
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
|
|
73
100
|
}
|
|
74
101
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
75
102
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
@@ -85,7 +112,7 @@ export class SubagentOrchestrator {
|
|
|
85
112
|
config: deps.config,
|
|
86
113
|
ctx,
|
|
87
114
|
renderer: deps.renderer
|
|
88
|
-
? new SubagentRenderer(deps.renderer, label)
|
|
115
|
+
? new SubagentRenderer(deps.renderer, label, tag)
|
|
89
116
|
: undefined,
|
|
90
117
|
git: deps.git,
|
|
91
118
|
projectInstructions: deps.projectInstructions,
|
|
@@ -93,6 +120,7 @@ export class SubagentOrchestrator {
|
|
|
93
120
|
budget,
|
|
94
121
|
router: deps.router,
|
|
95
122
|
taskClass: spec.taskClass ?? "subagent",
|
|
123
|
+
signal: opts.signal,
|
|
96
124
|
});
|
|
97
125
|
}
|
|
98
126
|
catch (err) {
|
|
@@ -102,7 +130,7 @@ export class SubagentOrchestrator {
|
|
|
102
130
|
throw err;
|
|
103
131
|
}
|
|
104
132
|
if (deps.renderer) {
|
|
105
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.failure}
|
|
133
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
|
|
106
134
|
}
|
|
107
135
|
deps.renderer?.setPhase(null);
|
|
108
136
|
return {
|
|
@@ -115,13 +143,140 @@ export class SubagentOrchestrator {
|
|
|
115
143
|
};
|
|
116
144
|
}
|
|
117
145
|
deps.renderer?.setPhase(null);
|
|
118
|
-
const result = this.toResult(run, artifacts, label);
|
|
119
|
-
deps.logger.debug(
|
|
146
|
+
const result = this.toResult(run, artifacts, label, noun);
|
|
147
|
+
deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
120
148
|
`${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
|
|
121
149
|
return result;
|
|
122
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
153
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
154
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
155
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
156
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
157
|
+
* stays deadlock-free.
|
|
158
|
+
*
|
|
159
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
160
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
161
|
+
*
|
|
162
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
163
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
164
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
165
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
166
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
167
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
168
|
+
* throw propagates — never a detached, still-running sibling.
|
|
169
|
+
*/
|
|
170
|
+
async spawnMany(specs, parentDepth, opts = {}) {
|
|
171
|
+
const { maxDepth } = this.deps.config.subagent;
|
|
172
|
+
if (parentDepth >= maxDepth) {
|
|
173
|
+
throw subagentDepthExceeded(parentDepth, maxDepth);
|
|
174
|
+
}
|
|
175
|
+
if (specs.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
// Refuse overlapping write scope BEFORE any child is dispatched.
|
|
178
|
+
this.assertDisjointWriteScopes(specs);
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const onExternalAbort = () => controller.abort();
|
|
181
|
+
if (opts.signal) {
|
|
182
|
+
if (opts.signal.aborted)
|
|
183
|
+
controller.abort();
|
|
184
|
+
else
|
|
185
|
+
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
186
|
+
}
|
|
187
|
+
const results = new Array(specs.length);
|
|
188
|
+
const total = specs.length;
|
|
189
|
+
try {
|
|
190
|
+
const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
|
|
191
|
+
// Already cancelled (a fatal sibling or Ctrl-C fired first): record an
|
|
192
|
+
// honest cancelled result instead of starting a doomed run.
|
|
193
|
+
if (controller.signal.aborted) {
|
|
194
|
+
results[i] = cancelledResult();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
results[i] = await this.spawn(spec, parentDepth, {
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
slot: `${i + 1}/${total}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// A fatal throw (non-interactive default-deny) cancels the whole
|
|
205
|
+
// fan-out — no sibling is left running — then propagates.
|
|
206
|
+
controller.abort();
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
})));
|
|
210
|
+
// Any child that was aborted mid-flight (returned stop:"aborted") is folded
|
|
211
|
+
// as cancelled by toResult; a fatal throw surfaces here after all settled.
|
|
212
|
+
const fatal = settled.find((s) => s.status === "rejected");
|
|
213
|
+
if (fatal && fatal.status === "rejected")
|
|
214
|
+
throw fatal.reason;
|
|
215
|
+
// Backfill any slot a cancelled-before-dispatch child left (defensive: the
|
|
216
|
+
// sem callback always assigns, but never ship a hole as success).
|
|
217
|
+
for (let i = 0; i < results.length; i++) {
|
|
218
|
+
if (results[i] === undefined)
|
|
219
|
+
results[i] = cancelledResult();
|
|
220
|
+
}
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
229
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
230
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
231
|
+
*/
|
|
232
|
+
childScope(rootName) {
|
|
233
|
+
if (rootName === undefined) {
|
|
234
|
+
return { cwd: this.deps.cwd, workspace: this.deps.workspace };
|
|
235
|
+
}
|
|
236
|
+
const root = this.deps.workspace.rootByName(rootName); // fail-loud on unknown
|
|
237
|
+
return {
|
|
238
|
+
cwd: root.absPath,
|
|
239
|
+
workspace: new Workspace([
|
|
240
|
+
{ name: root.name, absPath: root.absPath, primary: true },
|
|
241
|
+
]),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
246
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
247
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
248
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
249
|
+
* children never conflict.
|
|
250
|
+
*
|
|
251
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
252
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
253
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
254
|
+
* the error message is explicit about.
|
|
255
|
+
*/
|
|
256
|
+
assertDisjointWriteScopes(specs) {
|
|
257
|
+
const primaryName = this.deps.workspace.primary().name;
|
|
258
|
+
const byRoot = new Map(); // root → writing-child labels
|
|
259
|
+
for (const spec of specs) {
|
|
260
|
+
if (!isWriter(spec))
|
|
261
|
+
continue;
|
|
262
|
+
// Validate the named root exists (fail-loud, same as spawn) before claiming.
|
|
263
|
+
const rootName = spec.root === undefined
|
|
264
|
+
? primaryName
|
|
265
|
+
: this.deps.workspace.rootByName(spec.root).name;
|
|
266
|
+
const claimants = byRoot.get(rootName);
|
|
267
|
+
if (claimants)
|
|
268
|
+
claimants.push(taskLabel(spec.task));
|
|
269
|
+
else
|
|
270
|
+
byRoot.set(rootName, [taskLabel(spec.task)]);
|
|
271
|
+
}
|
|
272
|
+
const conflicts = [...byRoot.entries()]
|
|
273
|
+
.filter(([, tasks]) => tasks.length > 1)
|
|
274
|
+
.map(([root, tasks]) => ({ root, tasks }));
|
|
275
|
+
if (conflicts.length > 0)
|
|
276
|
+
throw subagentScopeOverlap(conflicts);
|
|
277
|
+
}
|
|
123
278
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
124
|
-
toResult(run, artifacts, label) {
|
|
279
|
+
toResult(run, artifacts, label, noun) {
|
|
125
280
|
const summary = lastAssistantText(run.messages);
|
|
126
281
|
const base = {
|
|
127
282
|
summary,
|
|
@@ -132,9 +287,21 @@ export class SubagentOrchestrator {
|
|
|
132
287
|
if (run.stop === "completed") {
|
|
133
288
|
const r = this.deps.renderer;
|
|
134
289
|
if (r)
|
|
135
|
-
r.note(`${r.theme.glyph.success}
|
|
290
|
+
r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
|
|
136
291
|
return { status: "done", ...base };
|
|
137
292
|
}
|
|
293
|
+
// Cancellation (C.33): a fatal sibling failure or Ctrl-C stopped this child
|
|
294
|
+
// at a turn boundary. An honest partial result — never a fabricated success.
|
|
295
|
+
if (run.stop === "aborted") {
|
|
296
|
+
const r = this.deps.renderer;
|
|
297
|
+
if (r)
|
|
298
|
+
r.note(`${r.theme.glyph.failure} ${noun} cancelled: ${label}`);
|
|
299
|
+
return {
|
|
300
|
+
status: "cancelled",
|
|
301
|
+
...base,
|
|
302
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before completion`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
138
305
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
139
306
|
// result with the reason — informational, never fatal (the parent decides
|
|
140
307
|
// what to do with it).
|
|
@@ -143,7 +310,7 @@ export class SubagentOrchestrator {
|
|
|
143
310
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
144
311
|
const r = this.deps.renderer;
|
|
145
312
|
if (r)
|
|
146
|
-
r.note(`${r.theme.glyph.failure}
|
|
313
|
+
r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
|
|
147
314
|
return {
|
|
148
315
|
status: "budget-exceeded",
|
|
149
316
|
...base,
|
|
@@ -156,6 +323,22 @@ function taskLabel(task) {
|
|
|
156
323
|
const flat = task.replace(/\s+/g, " ").trim();
|
|
157
324
|
return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
|
|
158
325
|
}
|
|
326
|
+
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
327
|
+
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
328
|
+
function isWriter(spec) {
|
|
329
|
+
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
330
|
+
}
|
|
331
|
+
/** The honest result for a child cancelled before it could produce anything —
|
|
332
|
+
* used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
|
|
333
|
+
function cancelledResult() {
|
|
334
|
+
return {
|
|
335
|
+
status: "cancelled",
|
|
336
|
+
summary: "",
|
|
337
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before dispatch`,
|
|
338
|
+
iterations: 0,
|
|
339
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
159
342
|
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
160
343
|
function artifactsField(artifacts) {
|
|
161
344
|
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
|
@@ -210,12 +393,16 @@ class SubagentRenderer {
|
|
|
210
393
|
inner;
|
|
211
394
|
label;
|
|
212
395
|
prefix;
|
|
213
|
-
constructor(inner, label) {
|
|
396
|
+
constructor(inner, label, tag) {
|
|
214
397
|
this.inner = inner;
|
|
215
398
|
this.label = label;
|
|
216
399
|
this.caps = inner.caps;
|
|
217
400
|
this.theme = inner.theme;
|
|
218
|
-
|
|
401
|
+
// A fan-out child carries its slot in the prefix (`subagent[2/3] · …`) so
|
|
402
|
+
// interleaved trail notes stay attributable per-subagent (JC-F); a lone
|
|
403
|
+
// sequential spawn keeps the byte-identical C.14 `subagent · …` prefix.
|
|
404
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
405
|
+
this.prefix = `${noun} ${inner.theme.glyph.sep} `;
|
|
219
406
|
}
|
|
220
407
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
221
408
|
beginTurn() { }
|
|
@@ -8,6 +8,19 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
8
8
|
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
9
|
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
10
|
export declare const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/** The parallel fan-out tool's name (C.33). Stripped from every child scope:
|
|
12
|
+
* parallel fan-out is a depth-0 capability only, so a child can never obtain it
|
|
13
|
+
* (which is also what keeps the concurrency semaphore deadlock-free — no permit
|
|
14
|
+
* holder ever nests a second fan-out). A child may still spawn ONE sequential
|
|
15
|
+
* subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
|
|
16
|
+
export declare const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
17
|
+
/**
|
|
18
|
+
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
|
+
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
+
* roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
|
|
21
|
+
* side-effecting tool set (file writes, shell/test, VCS).
|
|
22
|
+
*/
|
|
23
|
+
export declare const SUBAGENT_WRITE_TOOLS: ReadonlySet<string>;
|
|
11
24
|
/**
|
|
12
25
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
26
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -8,6 +8,28 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
8
8
|
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
9
|
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
10
|
export const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/** The parallel fan-out tool's name (C.33). Stripped from every child scope:
|
|
12
|
+
* parallel fan-out is a depth-0 capability only, so a child can never obtain it
|
|
13
|
+
* (which is also what keeps the concurrency semaphore deadlock-free — no permit
|
|
14
|
+
* holder ever nests a second fan-out). A child may still spawn ONE sequential
|
|
15
|
+
* subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
|
|
16
|
+
export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
17
|
+
/**
|
|
18
|
+
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
|
+
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
+
* roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
|
|
21
|
+
* side-effecting tool set (file writes, shell/test, VCS).
|
|
22
|
+
*/
|
|
23
|
+
export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
24
|
+
"write_file",
|
|
25
|
+
"edit_file",
|
|
26
|
+
"apply_patch",
|
|
27
|
+
"run_command",
|
|
28
|
+
"run_tests",
|
|
29
|
+
"git_commit",
|
|
30
|
+
"git_branch",
|
|
31
|
+
"open_pr",
|
|
32
|
+
]);
|
|
11
33
|
/**
|
|
12
34
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
35
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -45,14 +67,18 @@ export function scopeRegistry(parent, requested) {
|
|
|
45
67
|
return child;
|
|
46
68
|
}
|
|
47
69
|
for (const name of new Set(requested)) {
|
|
48
|
-
|
|
70
|
+
// Neither spawn tool is grantable to a child: nesting (sequential) is the
|
|
71
|
+
// orchestrator's depth-capped decision, and parallel fan-out is depth-0 only.
|
|
72
|
+
if (name === SPAWN_SUBAGENT_TOOL_NAME ||
|
|
73
|
+
name === SPAWN_SUBAGENTS_TOOL_NAME) {
|
|
49
74
|
continue;
|
|
75
|
+
}
|
|
50
76
|
const tool = parent.get(name);
|
|
51
77
|
if (!tool) {
|
|
52
78
|
const available = parent
|
|
53
79
|
.list()
|
|
54
80
|
.map((t) => t.name)
|
|
55
|
-
.filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME)
|
|
81
|
+
.filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME && n !== SPAWN_SUBAGENTS_TOOL_NAME)
|
|
56
82
|
.join(", ");
|
|
57
83
|
throw new Error(`tool "${name}" is not available to grant a subagent (a subagent's tools ` +
|
|
58
84
|
`must be a subset of yours). Available: ${available}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counting semaphore (C.33): bounds how many subagent runs execute at once.
|
|
3
|
+
* FIFO — waiters are served in arrival order, so a fan-out's results stay
|
|
4
|
+
* dispatch-order-fair — and the permit is handed directly from a releaser to
|
|
5
|
+
* the next waiter, so the live count never transiently exceeds the cap.
|
|
6
|
+
*
|
|
7
|
+
* Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
|
|
8
|
+
* depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
|
|
9
|
+
* permit is held for a child's whole lifetime — including any *sequential*
|
|
10
|
+
* nested spawn beneath it, which is deliberately un-permitted. Because no permit
|
|
11
|
+
* holder ever blocks trying to acquire a second permit, the semaphore cannot be
|
|
12
|
+
* part of a wait cycle: it is deadlock-free by construction (see the C.33 design
|
|
13
|
+
* doc's deadlock argument).
|
|
14
|
+
*/
|
|
15
|
+
export declare class Semaphore {
|
|
16
|
+
private permits;
|
|
17
|
+
private readonly queue;
|
|
18
|
+
constructor(permits: number);
|
|
19
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
20
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
21
|
+
/** Permits currently available (inspection/tests). */
|
|
22
|
+
get available(): number;
|
|
23
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
24
|
+
get waiting(): number;
|
|
25
|
+
private acquire;
|
|
26
|
+
private release;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counting semaphore (C.33): bounds how many subagent runs execute at once.
|
|
3
|
+
* FIFO — waiters are served in arrival order, so a fan-out's results stay
|
|
4
|
+
* dispatch-order-fair — and the permit is handed directly from a releaser to
|
|
5
|
+
* the next waiter, so the live count never transiently exceeds the cap.
|
|
6
|
+
*
|
|
7
|
+
* Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
|
|
8
|
+
* depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
|
|
9
|
+
* permit is held for a child's whole lifetime — including any *sequential*
|
|
10
|
+
* nested spawn beneath it, which is deliberately un-permitted. Because no permit
|
|
11
|
+
* holder ever blocks trying to acquire a second permit, the semaphore cannot be
|
|
12
|
+
* part of a wait cycle: it is deadlock-free by construction (see the C.33 design
|
|
13
|
+
* doc's deadlock argument).
|
|
14
|
+
*/
|
|
15
|
+
export class Semaphore {
|
|
16
|
+
permits;
|
|
17
|
+
queue = [];
|
|
18
|
+
constructor(permits) {
|
|
19
|
+
// A non-positive cap would wedge every run; clamp to at least 1.
|
|
20
|
+
this.permits = Math.max(1, Math.floor(permits));
|
|
21
|
+
}
|
|
22
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
23
|
+
async run(fn) {
|
|
24
|
+
await this.acquire();
|
|
25
|
+
try {
|
|
26
|
+
return await fn();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
this.release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Permits currently available (inspection/tests). */
|
|
33
|
+
get available() {
|
|
34
|
+
return this.permits;
|
|
35
|
+
}
|
|
36
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
37
|
+
get waiting() {
|
|
38
|
+
return this.queue.length;
|
|
39
|
+
}
|
|
40
|
+
acquire() {
|
|
41
|
+
if (this.permits > 0) {
|
|
42
|
+
this.permits--;
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve) => this.queue.push(resolve));
|
|
46
|
+
}
|
|
47
|
+
release() {
|
|
48
|
+
const next = this.queue.shift();
|
|
49
|
+
// Hand the permit straight to the next waiter (never bump the count above
|
|
50
|
+
// the cap); only when nobody waits does the count grow back.
|
|
51
|
+
if (next)
|
|
52
|
+
next();
|
|
53
|
+
else
|
|
54
|
+
this.permits++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -26,4 +26,61 @@ declare const parameters: z.ZodObject<{
|
|
|
26
26
|
}>;
|
|
27
27
|
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
28
28
|
export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
|
|
29
|
+
declare const batchParameters: z.ZodObject<{
|
|
30
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
31
|
+
task: z.ZodString;
|
|
32
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
|
|
33
|
+
root: z.ZodOptional<z.ZodString>;
|
|
34
|
+
maxIterations: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
task: string;
|
|
38
|
+
root?: string | undefined;
|
|
39
|
+
maxTokens?: number | undefined;
|
|
40
|
+
maxIterations?: number | undefined;
|
|
41
|
+
tools?: [string, ...string[]] | undefined;
|
|
42
|
+
}, {
|
|
43
|
+
task: string;
|
|
44
|
+
root?: string | undefined;
|
|
45
|
+
maxTokens?: number | undefined;
|
|
46
|
+
maxIterations?: number | undefined;
|
|
47
|
+
tools?: [string, ...string[]] | undefined;
|
|
48
|
+
}>, "atleastone">;
|
|
49
|
+
}, "strip", z.ZodTypeAny, {
|
|
50
|
+
tasks: [{
|
|
51
|
+
task: string;
|
|
52
|
+
root?: string | undefined;
|
|
53
|
+
maxTokens?: number | undefined;
|
|
54
|
+
maxIterations?: number | undefined;
|
|
55
|
+
tools?: [string, ...string[]] | undefined;
|
|
56
|
+
}, ...{
|
|
57
|
+
task: string;
|
|
58
|
+
root?: string | undefined;
|
|
59
|
+
maxTokens?: number | undefined;
|
|
60
|
+
maxIterations?: number | undefined;
|
|
61
|
+
tools?: [string, ...string[]] | undefined;
|
|
62
|
+
}[]];
|
|
63
|
+
}, {
|
|
64
|
+
tasks: [{
|
|
65
|
+
task: string;
|
|
66
|
+
root?: string | undefined;
|
|
67
|
+
maxTokens?: number | undefined;
|
|
68
|
+
maxIterations?: number | undefined;
|
|
69
|
+
tools?: [string, ...string[]] | undefined;
|
|
70
|
+
}, ...{
|
|
71
|
+
task: string;
|
|
72
|
+
root?: string | undefined;
|
|
73
|
+
maxTokens?: number | undefined;
|
|
74
|
+
maxIterations?: number | undefined;
|
|
75
|
+
tools?: [string, ...string[]] | undefined;
|
|
76
|
+
}[]];
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
|
|
80
|
+
* `depth`. One tool call dispatches N independent, internally-sequential children
|
|
81
|
+
* concurrently (JC-A) under the shared concurrency semaphore, and returns their
|
|
82
|
+
* results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
|
|
83
|
+
* so fan-out never nests (which keeps the semaphore deadlock-free).
|
|
84
|
+
*/
|
|
85
|
+
export declare function makeSpawnSubagentsTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof batchParameters>;
|
|
29
86
|
export {};
|