@cruxy/cli 0.23.0 → 0.25.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/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -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/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +62 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +145 -0
- package/dist/cli/session-factory.d.ts +24 -10
- package/dist/cli/session-factory.js +179 -135
- package/dist/config/schema.d.ts +110 -0
- package/dist/config/schema.js +50 -0
- package/dist/errors/constructors.d.ts +41 -0
- package/dist/errors/constructors.js +87 -0
- package/dist/errors/types.d.ts +21 -0
- package/dist/errors/types.js +33 -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/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- 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/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 +76 -2
- package/dist/subagent/orchestrator.js +208 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +56 -0
- package/dist/subagent/semaphore.js +53 -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 +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- 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
|
@@ -23,6 +23,10 @@ const RememberSchema = z.object({
|
|
|
23
23
|
.enum(["user", "project"])
|
|
24
24
|
.optional()
|
|
25
25
|
.describe("'user' (default) saves to your cross-project memory; 'project' saves to this repo's memory (shared with the repo)."),
|
|
26
|
+
root: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Which declared workspace root a 'project' note belongs to, by name. Defaults to the primary root; ignored for 'user' scope. In a single-root session, omit."),
|
|
26
30
|
});
|
|
27
31
|
export const rememberTool = {
|
|
28
32
|
name: "remember",
|
|
@@ -30,8 +34,14 @@ export const rememberTool = {
|
|
|
30
34
|
parameters: RememberSchema,
|
|
31
35
|
async execute(input, ctx) {
|
|
32
36
|
try {
|
|
37
|
+
// A named `root` targets that root's project memory (default: primary). A
|
|
38
|
+
// bad name throws CRUXY_E_ROOT_UNKNOWN, surfaced to the model below. User
|
|
39
|
+
// memory is global, so `root` never changes where it lands.
|
|
40
|
+
const cwd = input.root && ctx.workspace
|
|
41
|
+
? ctx.workspace.rootByName(input.root).absPath
|
|
42
|
+
: ctx.cwd;
|
|
33
43
|
const service = new MemoryService({
|
|
34
|
-
cwd
|
|
44
|
+
cwd,
|
|
35
45
|
config: ctx.config.memory,
|
|
36
46
|
});
|
|
37
47
|
const entry = service.remember({
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -30,11 +30,20 @@ export function buildPolicy(cfg, cwd, opts = {}) {
|
|
|
30
30
|
const siblingRoots = (opts.siblingRoots ?? [])
|
|
31
31
|
.map((r) => resolvePath(r))
|
|
32
32
|
.filter((r) => r !== resolvePath(cwd)) // the workdir is never a sibling
|
|
33
|
-
.map((source) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
.map((source) => {
|
|
34
|
+
// A declared root is user input too — hold it to the SAME escape-hatch
|
|
35
|
+
// refusal as `sandbox.mounts` (JC-G): never mount the docker socket, the
|
|
36
|
+
// cruxy credential home, or `$HOME`/`/` even if declared as a root.
|
|
37
|
+
const forbidden = forbiddenMountSource(source);
|
|
38
|
+
if (forbidden) {
|
|
39
|
+
throw configInvalid(`refusing to mount workspace root ${forbidden}: "${source}"`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
source,
|
|
43
|
+
target: source, // identical path, like the workdir, for path coherence
|
|
44
|
+
readonly: !writable.has(source), // RO unless explicitly escalated (R5)
|
|
45
|
+
};
|
|
46
|
+
});
|
|
38
47
|
return {
|
|
39
48
|
image: cfg.image,
|
|
40
49
|
network: cfg.network,
|
|
@@ -10,8 +10,15 @@ export interface SandboxReporter {
|
|
|
10
10
|
}
|
|
11
11
|
export interface SandboxServiceDeps {
|
|
12
12
|
config: CruxyConfig;
|
|
13
|
-
/** The
|
|
13
|
+
/** The command's own workspace root — mounted read-write as the workdir. */
|
|
14
14
|
cwd: string;
|
|
15
|
+
/** The OTHER declared roots in a multi-repo session (C.26, R5). Each is
|
|
16
|
+
* mounted READ-ONLY so cross-repo reads work but a command can't silently
|
|
17
|
+
* write a sibling. Empty/absent in a single-root session. */
|
|
18
|
+
siblingRoots?: readonly string[];
|
|
19
|
+
/** Sibling roots granted an approved cross-root-write escalation (R5): each
|
|
20
|
+
* named one flips to read-write, and ONLY that one — never blanket. */
|
|
21
|
+
writableRoots?: readonly string[];
|
|
15
22
|
/** Execution runtime seam (defaults to Docker). */
|
|
16
23
|
runtime?: SandboxRuntime;
|
|
17
24
|
/** Capability probe seam (defaults to real docker detection). */
|
package/dist/sandbox/service.js
CHANGED
|
@@ -34,7 +34,10 @@ export class SandboxService {
|
|
|
34
34
|
if (!capability.available) {
|
|
35
35
|
throw sandboxUnavailable(capability.runtime, capability.detail);
|
|
36
36
|
}
|
|
37
|
-
const policy = buildPolicy(deps.config.sandbox, deps.cwd
|
|
37
|
+
const policy = buildPolicy(deps.config.sandbox, deps.cwd, {
|
|
38
|
+
siblingRoots: deps.siblingRoots,
|
|
39
|
+
writableRoots: deps.writableRoots,
|
|
40
|
+
});
|
|
38
41
|
return new SandboxService(runtime, policy, deps.reporter);
|
|
39
42
|
}
|
|
40
43
|
/** The runtime backing this service (e.g. "docker") — for logging. */
|
package/dist/subagent/index.d.ts
CHANGED
package/dist/subagent/index.js
CHANGED
|
@@ -5,7 +5,8 @@ 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
|
|
8
|
+
import { Workspace } from "../workspace/index.js";
|
|
9
|
+
import { Semaphore } from "./semaphore.js";
|
|
9
10
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
10
11
|
/**
|
|
11
12
|
* Everything a spawn needs from the surrounding session, injected by the
|
|
@@ -60,6 +61,14 @@ export interface SubagentOrchestratorDeps {
|
|
|
60
61
|
* same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
|
|
61
62
|
*/
|
|
62
63
|
checkpointsActive?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The ONE shared execution semaphore (C.28). When set, parallel fan-out draws
|
|
66
|
+
* permits from THIS instance — the same one background jobs use — so the
|
|
67
|
+
* `subagent.maxConcurrency` cap bounds subagents AND jobs COMBINED, never one
|
|
68
|
+
* cap each. Omitted → the orchestrator constructs its own (C.33 behaviour,
|
|
69
|
+
* used by tests that exercise the orchestrator in isolation).
|
|
70
|
+
*/
|
|
71
|
+
executionSemaphore?: Semaphore;
|
|
63
72
|
}
|
|
64
73
|
/**
|
|
65
74
|
* Spawns subagents (C.14): the existing agent loop re-driven over isolated
|
|
@@ -76,7 +85,19 @@ export interface SubagentOrchestratorDeps {
|
|
|
76
85
|
*/
|
|
77
86
|
export declare class SubagentOrchestrator {
|
|
78
87
|
private readonly deps;
|
|
88
|
+
/**
|
|
89
|
+
* The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
|
|
90
|
+
* session and reused by every {@link spawnMany} call (nested spawns reuse this
|
|
91
|
+
* same orchestrator instance), so `subagent.maxConcurrency` caps the number of
|
|
92
|
+
* concurrently-executing subagents across the WHOLE session — not per parent.
|
|
93
|
+
*/
|
|
94
|
+
private readonly sem;
|
|
79
95
|
constructor(deps: SubagentOrchestratorDeps);
|
|
96
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
97
|
+
get concurrency(): {
|
|
98
|
+
available: number;
|
|
99
|
+
waiting: number;
|
|
100
|
+
};
|
|
80
101
|
/**
|
|
81
102
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
82
103
|
* main agent is 0); spawning past `subagent.maxDepth` throws
|
|
@@ -89,7 +110,60 @@ export declare class SubagentOrchestrator {
|
|
|
89
110
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
90
111
|
* not a way to swallow it).
|
|
91
112
|
*/
|
|
92
|
-
spawn(spec: SubagentSpec, parentDepth: number): Promise<SubagentResult>;
|
|
113
|
+
spawn(spec: SubagentSpec, parentDepth: number, opts?: SpawnOptions): Promise<SubagentResult>;
|
|
114
|
+
/**
|
|
115
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
116
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
117
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
118
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
119
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
120
|
+
* stays deadlock-free.
|
|
121
|
+
*
|
|
122
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
123
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
124
|
+
*
|
|
125
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
126
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
127
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
128
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
129
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
130
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
131
|
+
* throw propagates — never a detached, still-running sibling.
|
|
132
|
+
*/
|
|
133
|
+
spawnMany(specs: readonly SubagentSpec[], parentDepth: number, opts?: {
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
}): Promise<SubagentResult[]>;
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
138
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
139
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
140
|
+
*/
|
|
141
|
+
private childScope;
|
|
142
|
+
/**
|
|
143
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
144
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
145
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
146
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
147
|
+
* children never conflict.
|
|
148
|
+
*
|
|
149
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
150
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
151
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
152
|
+
* the error message is explicit about.
|
|
153
|
+
*/
|
|
154
|
+
private assertDisjointWriteScopes;
|
|
93
155
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
94
156
|
private toResult;
|
|
95
157
|
}
|
|
158
|
+
/** Per-spawn options: cancellation + a per-child render tag for a fan-out. */
|
|
159
|
+
interface SpawnOptions {
|
|
160
|
+
/** Cooperative cancellation; threaded into `runAgent` and `ctx.signal`. */
|
|
161
|
+
signal?: AbortSignal;
|
|
162
|
+
/**
|
|
163
|
+
* A short per-child label for a parallel fan-out (JC-F), e.g. `"2/3"`, so
|
|
164
|
+
* interleaved trail notes are attributable to the child that emitted them.
|
|
165
|
+
* Undefined for a single sequential spawn — chrome is byte-identical to C.14.
|
|
166
|
+
*/
|
|
167
|
+
slot?: string;
|
|
168
|
+
}
|
|
169
|
+
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,25 @@ 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
|
+
// Share the session's ONE execution semaphore when injected (C.28) so
|
|
36
|
+
// subagents and background jobs contend for the same permits; fall back to a
|
|
37
|
+
// private one at the same cap when running the orchestrator in isolation.
|
|
38
|
+
this.sem =
|
|
39
|
+
deps.executionSemaphore ??
|
|
40
|
+
new Semaphore(deps.config.subagent.maxConcurrency);
|
|
41
|
+
}
|
|
42
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
43
|
+
get concurrency() {
|
|
44
|
+
return { available: this.sem.available, waiting: this.sem.waiting };
|
|
26
45
|
}
|
|
27
46
|
/**
|
|
28
47
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
@@ -36,7 +55,7 @@ export class SubagentOrchestrator {
|
|
|
36
55
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
37
56
|
* not a way to swallow it).
|
|
38
57
|
*/
|
|
39
|
-
async spawn(spec, parentDepth) {
|
|
58
|
+
async spawn(spec, parentDepth, opts = {}) {
|
|
40
59
|
const { deps } = this;
|
|
41
60
|
const { maxDepth, defaultBudget } = deps.config.subagent;
|
|
42
61
|
if (parentDepth >= maxDepth) {
|
|
@@ -45,33 +64,44 @@ export class SubagentOrchestrator {
|
|
|
45
64
|
const childDepth = parentDepth + 1;
|
|
46
65
|
// Scoped-down registry (throws on a tool the parent lacks — the spawn tool
|
|
47
66
|
// surfaces that to the model), plus a depth-bound spawn tool only while
|
|
48
|
-
// nesting is still allowed.
|
|
67
|
+
// nesting is still allowed. (Only the SEQUENTIAL spawn is re-added; parallel
|
|
68
|
+
// fan-out stays a depth-0 capability, so a child cannot nest a fan-out.)
|
|
49
69
|
const registry = scopeRegistry(deps.parentRegistry, spec.tools);
|
|
50
70
|
if (childDepth < maxDepth) {
|
|
51
71
|
registry.register(makeSpawnSubagentTool(this, childDepth));
|
|
52
72
|
}
|
|
53
73
|
const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
|
|
74
|
+
// Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
|
|
75
|
+
// that ONE root (its writes land there and nowhere else). Omitted → the full
|
|
76
|
+
// session workspace, unchanged from C.14. An unknown name fails loud here
|
|
77
|
+
// (CRUXY_E_ROOT_UNKNOWN), which the spawn tool surfaces to the model.
|
|
78
|
+
const scope = this.childScope(spec.root);
|
|
54
79
|
// Fresh gate per child (own allowlist), recording what it approves so the
|
|
55
|
-
// result can name the artifacts without ever shipping the transcript.
|
|
80
|
+
// result can name the artifacts without ever shipping the transcript. The
|
|
81
|
+
// gate itself is serialized by the shared approval mutex (wired at the
|
|
82
|
+
// session factory), so concurrent siblings never prompt or checkpoint at once.
|
|
56
83
|
const approve = deps.makeChildApproval();
|
|
57
84
|
const artifacts = new Set();
|
|
58
85
|
const ctx = {
|
|
59
|
-
cwd:
|
|
60
|
-
workspace:
|
|
86
|
+
cwd: scope.cwd,
|
|
87
|
+
workspace: scope.workspace,
|
|
61
88
|
config: deps.config,
|
|
62
89
|
logger: deps.logger,
|
|
63
90
|
requestApproval: async (action) => {
|
|
64
91
|
const decision = await approve(action);
|
|
65
92
|
if (decision.allow)
|
|
66
|
-
recordArtifacts(action, artifacts,
|
|
93
|
+
recordArtifacts(action, artifacts, scope.cwd);
|
|
67
94
|
return decision;
|
|
68
95
|
},
|
|
69
96
|
checkpointsActive: deps.checkpointsActive,
|
|
70
97
|
sandbox: deps.sandbox,
|
|
98
|
+
signal: opts.signal,
|
|
71
99
|
};
|
|
72
100
|
const label = taskLabel(spec.task);
|
|
101
|
+
const tag = opts.slot; // per-child render label for a fan-out (JC-F)
|
|
102
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
73
103
|
if (deps.renderer) {
|
|
74
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.play}
|
|
104
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
|
|
75
105
|
}
|
|
76
106
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
77
107
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
@@ -87,7 +117,7 @@ export class SubagentOrchestrator {
|
|
|
87
117
|
config: deps.config,
|
|
88
118
|
ctx,
|
|
89
119
|
renderer: deps.renderer
|
|
90
|
-
? new SubagentRenderer(deps.renderer, label)
|
|
120
|
+
? new SubagentRenderer(deps.renderer, label, tag)
|
|
91
121
|
: undefined,
|
|
92
122
|
git: deps.git,
|
|
93
123
|
projectInstructions: deps.projectInstructions,
|
|
@@ -95,6 +125,7 @@ export class SubagentOrchestrator {
|
|
|
95
125
|
budget,
|
|
96
126
|
router: deps.router,
|
|
97
127
|
taskClass: spec.taskClass ?? "subagent",
|
|
128
|
+
signal: opts.signal,
|
|
98
129
|
});
|
|
99
130
|
}
|
|
100
131
|
catch (err) {
|
|
@@ -104,7 +135,7 @@ export class SubagentOrchestrator {
|
|
|
104
135
|
throw err;
|
|
105
136
|
}
|
|
106
137
|
if (deps.renderer) {
|
|
107
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.failure}
|
|
138
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
|
|
108
139
|
}
|
|
109
140
|
deps.renderer?.setPhase(null);
|
|
110
141
|
return {
|
|
@@ -117,13 +148,140 @@ export class SubagentOrchestrator {
|
|
|
117
148
|
};
|
|
118
149
|
}
|
|
119
150
|
deps.renderer?.setPhase(null);
|
|
120
|
-
const result = this.toResult(run, artifacts, label);
|
|
121
|
-
deps.logger.debug(
|
|
151
|
+
const result = this.toResult(run, artifacts, label, noun);
|
|
152
|
+
deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
122
153
|
`${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
|
|
123
154
|
return result;
|
|
124
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
158
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
159
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
160
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
161
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
162
|
+
* stays deadlock-free.
|
|
163
|
+
*
|
|
164
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
165
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
166
|
+
*
|
|
167
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
168
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
169
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
170
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
171
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
172
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
173
|
+
* throw propagates — never a detached, still-running sibling.
|
|
174
|
+
*/
|
|
175
|
+
async spawnMany(specs, parentDepth, opts = {}) {
|
|
176
|
+
const { maxDepth } = this.deps.config.subagent;
|
|
177
|
+
if (parentDepth >= maxDepth) {
|
|
178
|
+
throw subagentDepthExceeded(parentDepth, maxDepth);
|
|
179
|
+
}
|
|
180
|
+
if (specs.length === 0)
|
|
181
|
+
return [];
|
|
182
|
+
// Refuse overlapping write scope BEFORE any child is dispatched.
|
|
183
|
+
this.assertDisjointWriteScopes(specs);
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
const onExternalAbort = () => controller.abort();
|
|
186
|
+
if (opts.signal) {
|
|
187
|
+
if (opts.signal.aborted)
|
|
188
|
+
controller.abort();
|
|
189
|
+
else
|
|
190
|
+
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
191
|
+
}
|
|
192
|
+
const results = new Array(specs.length);
|
|
193
|
+
const total = specs.length;
|
|
194
|
+
try {
|
|
195
|
+
const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
|
|
196
|
+
// Already cancelled (a fatal sibling or Ctrl-C fired first): record an
|
|
197
|
+
// honest cancelled result instead of starting a doomed run.
|
|
198
|
+
if (controller.signal.aborted) {
|
|
199
|
+
results[i] = cancelledResult();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
results[i] = await this.spawn(spec, parentDepth, {
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
slot: `${i + 1}/${total}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// A fatal throw (non-interactive default-deny) cancels the whole
|
|
210
|
+
// fan-out — no sibling is left running — then propagates.
|
|
211
|
+
controller.abort();
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
})));
|
|
215
|
+
// Any child that was aborted mid-flight (returned stop:"aborted") is folded
|
|
216
|
+
// as cancelled by toResult; a fatal throw surfaces here after all settled.
|
|
217
|
+
const fatal = settled.find((s) => s.status === "rejected");
|
|
218
|
+
if (fatal && fatal.status === "rejected")
|
|
219
|
+
throw fatal.reason;
|
|
220
|
+
// Backfill any slot a cancelled-before-dispatch child left (defensive: the
|
|
221
|
+
// sem callback always assigns, but never ship a hole as success).
|
|
222
|
+
for (let i = 0; i < results.length; i++) {
|
|
223
|
+
if (results[i] === undefined)
|
|
224
|
+
results[i] = cancelledResult();
|
|
225
|
+
}
|
|
226
|
+
return results;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
234
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
235
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
236
|
+
*/
|
|
237
|
+
childScope(rootName) {
|
|
238
|
+
if (rootName === undefined) {
|
|
239
|
+
return { cwd: this.deps.cwd, workspace: this.deps.workspace };
|
|
240
|
+
}
|
|
241
|
+
const root = this.deps.workspace.rootByName(rootName); // fail-loud on unknown
|
|
242
|
+
return {
|
|
243
|
+
cwd: root.absPath,
|
|
244
|
+
workspace: new Workspace([
|
|
245
|
+
{ name: root.name, absPath: root.absPath, primary: true },
|
|
246
|
+
]),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
251
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
252
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
253
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
254
|
+
* children never conflict.
|
|
255
|
+
*
|
|
256
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
257
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
258
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
259
|
+
* the error message is explicit about.
|
|
260
|
+
*/
|
|
261
|
+
assertDisjointWriteScopes(specs) {
|
|
262
|
+
const primaryName = this.deps.workspace.primary().name;
|
|
263
|
+
const byRoot = new Map(); // root → writing-child labels
|
|
264
|
+
for (const spec of specs) {
|
|
265
|
+
if (!isWriter(spec))
|
|
266
|
+
continue;
|
|
267
|
+
// Validate the named root exists (fail-loud, same as spawn) before claiming.
|
|
268
|
+
const rootName = spec.root === undefined
|
|
269
|
+
? primaryName
|
|
270
|
+
: this.deps.workspace.rootByName(spec.root).name;
|
|
271
|
+
const claimants = byRoot.get(rootName);
|
|
272
|
+
if (claimants)
|
|
273
|
+
claimants.push(taskLabel(spec.task));
|
|
274
|
+
else
|
|
275
|
+
byRoot.set(rootName, [taskLabel(spec.task)]);
|
|
276
|
+
}
|
|
277
|
+
const conflicts = [...byRoot.entries()]
|
|
278
|
+
.filter(([, tasks]) => tasks.length > 1)
|
|
279
|
+
.map(([root, tasks]) => ({ root, tasks }));
|
|
280
|
+
if (conflicts.length > 0)
|
|
281
|
+
throw subagentScopeOverlap(conflicts);
|
|
282
|
+
}
|
|
125
283
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
126
|
-
toResult(run, artifacts, label) {
|
|
284
|
+
toResult(run, artifacts, label, noun) {
|
|
127
285
|
const summary = lastAssistantText(run.messages);
|
|
128
286
|
const base = {
|
|
129
287
|
summary,
|
|
@@ -134,9 +292,21 @@ export class SubagentOrchestrator {
|
|
|
134
292
|
if (run.stop === "completed") {
|
|
135
293
|
const r = this.deps.renderer;
|
|
136
294
|
if (r)
|
|
137
|
-
r.note(`${r.theme.glyph.success}
|
|
295
|
+
r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
|
|
138
296
|
return { status: "done", ...base };
|
|
139
297
|
}
|
|
298
|
+
// Cancellation (C.33): a fatal sibling failure or Ctrl-C stopped this child
|
|
299
|
+
// at a turn boundary. An honest partial result — never a fabricated success.
|
|
300
|
+
if (run.stop === "aborted") {
|
|
301
|
+
const r = this.deps.renderer;
|
|
302
|
+
if (r)
|
|
303
|
+
r.note(`${r.theme.glyph.failure} ${noun} cancelled: ${label}`);
|
|
304
|
+
return {
|
|
305
|
+
status: "cancelled",
|
|
306
|
+
...base,
|
|
307
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before completion`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
140
310
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
141
311
|
// result with the reason — informational, never fatal (the parent decides
|
|
142
312
|
// what to do with it).
|
|
@@ -145,7 +315,7 @@ export class SubagentOrchestrator {
|
|
|
145
315
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
146
316
|
const r = this.deps.renderer;
|
|
147
317
|
if (r)
|
|
148
|
-
r.note(`${r.theme.glyph.failure}
|
|
318
|
+
r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
|
|
149
319
|
return {
|
|
150
320
|
status: "budget-exceeded",
|
|
151
321
|
...base,
|
|
@@ -158,6 +328,22 @@ function taskLabel(task) {
|
|
|
158
328
|
const flat = task.replace(/\s+/g, " ").trim();
|
|
159
329
|
return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
|
|
160
330
|
}
|
|
331
|
+
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
332
|
+
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
333
|
+
function isWriter(spec) {
|
|
334
|
+
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
335
|
+
}
|
|
336
|
+
/** The honest result for a child cancelled before it could produce anything —
|
|
337
|
+
* used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
|
|
338
|
+
function cancelledResult() {
|
|
339
|
+
return {
|
|
340
|
+
status: "cancelled",
|
|
341
|
+
summary: "",
|
|
342
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before dispatch`,
|
|
343
|
+
iterations: 0,
|
|
344
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
345
|
+
};
|
|
346
|
+
}
|
|
161
347
|
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
162
348
|
function artifactsField(artifacts) {
|
|
163
349
|
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
|
@@ -212,12 +398,16 @@ class SubagentRenderer {
|
|
|
212
398
|
inner;
|
|
213
399
|
label;
|
|
214
400
|
prefix;
|
|
215
|
-
constructor(inner, label) {
|
|
401
|
+
constructor(inner, label, tag) {
|
|
216
402
|
this.inner = inner;
|
|
217
403
|
this.label = label;
|
|
218
404
|
this.caps = inner.caps;
|
|
219
405
|
this.theme = inner.theme;
|
|
220
|
-
|
|
406
|
+
// A fan-out child carries its slot in the prefix (`subagent[2/3] · …`) so
|
|
407
|
+
// interleaved trail notes stay attributable per-subagent (JC-F); a lone
|
|
408
|
+
// sequential spawn keeps the byte-identical C.14 `subagent · …` prefix.
|
|
409
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
410
|
+
this.prefix = `${noun} ${inner.theme.glyph.sep} `;
|
|
221
411
|
}
|
|
222
412
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
223
413
|
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}`);
|