Skip to content

Commit 0259387

Browse files
committed
fix(swarm): gate recursive spawning on deep roots
1 parent fe9b866 commit 0259387

9 files changed

Lines changed: 167 additions & 74 deletions

File tree

crates/jcode-app-core/src/server/comm_session.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,7 @@ async fn ensure_spawn_coordinator_swarm(
12431243
swarm_id,
12441244
from_name,
12451245
is_root,
1246+
root_session_id,
12461247
coordinator_id,
12471248
coordinator_is_stale,
12481249
live_member_count,
@@ -1260,6 +1261,10 @@ async fn ensure_spawn_coordinator_swarm(
12601261
.get(req_session_id)
12611262
.and_then(|member| member.report_back_to_session_id.clone())
12621263
.is_none();
1264+
let root_session_id = super::swarm::swarm_ancestors(&members, req_session_id)
1265+
.last()
1266+
.cloned()
1267+
.unwrap_or_else(|| req_session_id.to_string());
12631268
// Count both all live members for the absolute hard cap and live spawned
12641269
// agents for the configurable RAM-safety cap. User-created roots do not
12651270
// consume worker slots; every recursively spawned descendant does.
@@ -1303,6 +1308,7 @@ async fn ensure_spawn_coordinator_swarm(
13031308
swarm_id,
13041309
from_name,
13051310
is_root,
1311+
root_session_id,
13061312
coordinator_id,
13071313
coordinator_is_stale,
13081314
live_member_count,
@@ -1319,6 +1325,26 @@ async fn ensure_spawn_coordinator_swarm(
13191325
return None;
13201326
};
13211327

1328+
// Light and ad hoc swarms are deliberately one-level fan-out: only the root
1329+
// session may create workers. Recursive spawning is an explicit deep-swarm
1330+
// capability, keyed from the root's effort rather than the requesting
1331+
// child's effort so a worker cannot opt itself into unbounded growth.
1332+
if !is_root {
1333+
let root_is_deep = crate::session_effort::session_effort(&root_session_id)
1334+
.as_deref()
1335+
.is_some_and(crate::prompt::is_deep_swarm_effort);
1336+
if !root_is_deep {
1337+
let _ = client_event_tx.send(ServerEvent::Error {
1338+
id,
1339+
message: format!(
1340+
"Recursive swarm spawning is disabled for light and ad hoc swarms. Only the root session ({root_session_id}) may spawn agents unless that root is running in swarm-deep mode."
1341+
),
1342+
retry_after_secs: None,
1343+
});
1344+
return None;
1345+
}
1346+
}
1347+
13221348
// Keep an absolute hard ceiling even when the configurable limit is disabled.
13231349
if live_member_count >= super::MAX_SWARM_MEMBERS {
13241350
let _ = client_event_tx.send(ServerEvent::Error {
@@ -1333,7 +1359,7 @@ async fn ensure_spawn_coordinator_swarm(
13331359
}
13341360

13351361
// `swarm_max_concurrent_agents` is the machine-safety budget shared by
1336-
// run_plan and ad hoc recursive spawning. Previously only run_plan obeyed it,
1362+
// run_plan and deep recursive spawning. Previously only run_plan obeyed it,
13371363
// so nested agents could grow to the 1000-member hard cap and exhaust RAM.
13381364
let live_agent_limit = (configured_live_agent_limit > 0)
13391365
.then(|| configured_live_agent_limit.min(super::MAX_SWARM_MEMBERS));
@@ -1352,9 +1378,8 @@ async fn ensure_spawn_coordinator_swarm(
13521378
// Coordinator-slot election is now only about the swarm-level coordinator used
13531379
// for shared plan operations (propose/approve/assign). Only a root session
13541380
// (depth 0, no spawner) claims it, and only when the slot is empty or stale.
1355-
// Non-root spawners coordinate their own subtree via report-back ownership and
1356-
// never disturb the swarm-level coordinator slot. Crucially, the presence of a
1357-
// live coordinator no longer blocks anyone from spawning.
1381+
// Authorized deep-swarm descendants coordinate their own subtree via
1382+
// report-back ownership and never disturb the swarm-level coordinator slot.
13581383
if is_root && coordinator_id.as_deref() != Some(req_session_id) {
13591384
let should_claim = coordinator_id.is_none() || coordinator_is_stale;
13601385
if should_claim {

crates/jcode-app-core/src/server/comm_session_tests.rs

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -816,32 +816,102 @@ async fn spawn_bootstraps_coordinator_when_swarm_has_none() {
816816
}
817817

818818
#[tokio::test]
819-
async fn nested_agent_can_spawn_while_live_coordinator_exists() {
820-
// Recursive spawning (option A): a spawned child (depth 1, owned by `coord`)
821-
// may spawn its own children even though a live swarm-level coordinator
822-
// exists. It must not steal the swarm-level coordinator slot.
819+
async fn nested_agent_cannot_spawn_when_root_is_light_or_normal() {
820+
// Both explicit light-swarm effort and ordinary ad hoc swarm use are
821+
// one-level fan-out. A spawned child cannot grow another generation.
822+
for (root_id, effort) in [
823+
("light-root-no-recursion", Some("swarm")),
824+
("normal-root-no-recursion", None),
825+
] {
826+
crate::session_effort::forget_session_effort(root_id);
827+
crate::session_effort::record_session_effort(root_id, effort);
828+
let swarm_id = format!("swarm-{root_id}");
829+
let child_id = format!("child-{root_id}");
830+
let swarm_members = Arc::new(RwLock::new(HashMap::new()));
831+
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
832+
swarm_id.clone(),
833+
HashSet::from([child_id.clone(), root_id.to_string()]),
834+
)])));
835+
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
836+
swarm_id.clone(),
837+
root_id.to_string(),
838+
)])));
839+
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
840+
let (mut child_member, _child_rx) = member(&child_id, Some(&swarm_id), "agent");
841+
child_member.report_back_to_session_id = Some(root_id.to_string());
842+
let (root_member, _root_rx) = member(root_id, Some(&swarm_id), "coordinator");
843+
let mut members = swarm_members.write().await;
844+
members.insert(child_id.clone(), child_member);
845+
members.insert(root_id.to_string(), root_member);
846+
drop(members);
847+
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
848+
849+
let refused = ensure_spawn_coordinator_swarm(
850+
2,
851+
&child_id,
852+
&client_event_tx,
853+
&swarm_members,
854+
&swarms_by_id,
855+
&swarm_coordinators,
856+
&swarm_plans,
857+
32,
858+
)
859+
.await;
860+
861+
crate::session_effort::forget_session_effort(root_id);
862+
assert!(refused.is_none());
863+
assert_eq!(
864+
swarm_coordinators
865+
.read()
866+
.await
867+
.get(&swarm_id)
868+
.map(String::as_str),
869+
Some(root_id)
870+
);
871+
assert_eq!(
872+
swarm_members
873+
.read()
874+
.await
875+
.get(&child_id)
876+
.map(|member| member.role.as_str()),
877+
Some("agent")
878+
);
879+
assert!(matches!(
880+
client_event_rx.recv().await,
881+
Some(ServerEvent::Error { message, .. })
882+
if message.contains("Recursive swarm spawning is disabled")
883+
&& message.contains(&format!("Only the root session ({root_id}) may spawn agents"))
884+
));
885+
}
886+
}
887+
888+
#[tokio::test]
889+
async fn nested_agent_can_spawn_when_root_is_deep() {
890+
let root_id = "deep-root-recursive";
891+
crate::session_effort::record_session_effort(root_id, Some("swarm-deep"));
892+
823893
let swarm_members = Arc::new(RwLock::new(HashMap::new()));
824894
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
825-
"swarm-1".to_string(),
826-
HashSet::from(["child".to_string(), "coord".to_string()]),
895+
"swarm-deep".to_string(),
896+
HashSet::from(["deep-child".to_string(), root_id.to_string()]),
827897
)])));
828898
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
829-
"swarm-1".to_string(),
830-
"coord".to_string(),
899+
"swarm-deep".to_string(),
900+
root_id.to_string(),
831901
)])));
832902
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
833-
let (mut child_member, _child_rx) = member("child", Some("swarm-1"), "agent");
834-
child_member.report_back_to_session_id = Some("coord".to_string());
835-
let (coord_member, _coord_rx) = member("coord", Some("swarm-1"), "coordinator");
903+
let (mut child_member, _child_rx) = member("deep-child", Some("swarm-deep"), "agent");
904+
child_member.report_back_to_session_id = Some(root_id.to_string());
905+
let (root_member, _root_rx) = member(root_id, Some("swarm-deep"), "coordinator");
836906
let mut members = swarm_members.write().await;
837-
members.insert("child".to_string(), child_member);
838-
members.insert("coord".to_string(), coord_member);
907+
members.insert("deep-child".to_string(), child_member);
908+
members.insert(root_id.to_string(), root_member);
839909
drop(members);
840910
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
841911

842-
let swarm_id = ensure_spawn_coordinator_swarm(
843-
2,
844-
"child",
912+
let allowed = ensure_spawn_coordinator_swarm(
913+
3,
914+
"deep-child",
845915
&client_event_tx,
846916
&swarm_members,
847917
&swarms_by_id,
@@ -851,46 +921,29 @@ async fn nested_agent_can_spawn_while_live_coordinator_exists() {
851921
)
852922
.await;
853923

854-
assert_eq!(swarm_id.as_deref(), Some("swarm-1"));
855-
// The swarm-level coordinator slot is untouched.
856-
assert_eq!(
857-
swarm_coordinators
858-
.read()
859-
.await
860-
.get("swarm-1")
861-
.map(String::as_str),
862-
Some("coord")
863-
);
864-
// The child keeps its agent role; it coordinates its own subtree via
865-
// report-back ownership, not the swarm-level coordinator slot.
866-
assert_eq!(
867-
swarm_members
868-
.read()
869-
.await
870-
.get("child")
871-
.map(|member| member.role.as_str()),
872-
Some("agent")
873-
);
924+
crate::session_effort::forget_session_effort(root_id);
925+
assert_eq!(allowed.as_deref(), Some("swarm-deep"));
874926
assert!(client_event_rx.try_recv().is_err());
875927
}
876928

877929
#[tokio::test]
878930
async fn spawn_allowed_at_arbitrary_depth_without_depth_cap() {
879-
// Build a deep chain root -> a -> b -> c -> d -> e -> f. There is no depth
880-
// cap anymore, so even a deeply nested agent may still spawn.
931+
// Deep-swarm mode still allows recursive decomposition at arbitrary depth.
932+
let root_id = "deep-root-arbitrary-depth";
933+
crate::session_effort::record_session_effort(root_id, Some("swarm-deep"));
881934
let swarm_members = Arc::new(RwLock::new(HashMap::new()));
882935
let swarms_by_id = Arc::new(RwLock::new(HashMap::new()));
883936
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
884937
"swarm-1".to_string(),
885-
"root".to_string(),
938+
root_id.to_string(),
886939
)])));
887940
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
888941
{
889942
let mut members = swarm_members.write().await;
890-
let (root, _rx) = member("root", Some("swarm-1"), "coordinator");
891-
members.insert("root".to_string(), root);
943+
let (root, _rx) = member(root_id, Some("swarm-1"), "coordinator");
944+
members.insert(root_id.to_string(), root);
892945
let chain = [
893-
("a", "root"),
946+
("a", root_id),
894947
("b", "a"),
895948
("c", "b"),
896949
("d", "c"),
@@ -918,6 +971,7 @@ async fn spawn_allowed_at_arbitrary_depth_without_depth_cap() {
918971
32,
919972
)
920973
.await;
974+
crate::session_effort::forget_session_effort(root_id);
921975
assert_eq!(allowed.as_deref(), Some("swarm-1"));
922976
}
923977

crates/jcode-app-core/src/server/swarm.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ fn status_age_secs(last_status_change: Instant) -> u64 {
2525

2626
/// Maximum number of live members (agents) in a single swarm. Re-exported from
2727
/// `jcode_swarm_core` so the server, tools, and prompts all agree on the one
28-
/// runaway-prevention cap for the task-graph model. There is intentionally no
29-
/// spawn-depth limit and no per-node fan-out limit: the spawn tree may nest and
30-
/// fan out freely until the swarm reaches this many live members, at which point
31-
/// further spawns are refused.
28+
/// runaway-prevention cap for the task-graph model. Normal and light swarms are
29+
/// root-only, one-level fan-out. Deep-swarm roots may create recursive trees with
30+
/// no depth limit, but both the configurable live-worker budget and this absolute
31+
/// cap still apply.
3232
pub(super) use jcode_swarm_core::MAX_SWARM_MEMBERS;
3333

3434
/// Walk the `report_back_to_session_id` chain upward from `session_id`,

crates/jcode-app-core/src/tool/communicate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1787,7 +1787,7 @@ pub struct CommunicateTool {
17871787

17881788
impl CommunicateTool {
17891789
pub fn new() -> Self {
1790-
const BASE_DESCRIPTION: &str = "Coordinate agents. Any agent can spawn child agents, and those children can spawn their own, forming a recursive spawn tree with no depth limit (growth is bounded only by the total swarm member cap). For spawn, prefer providing a prompt so the new agent starts with a concrete task instead of idling. Spawned/assigned agents automatically report their final response back to the agent that spawned them; you can stop any agent in the subtree you spawned.\n\nCommunication: prefer structural dataflow (task-graph artifacts via complete_node) over chat, and DMs for point-to-point coordination. broadcast reaches only your spawned subtree (whole swarm for the coordinator) and should be rare; channels and shared-context are discouraged legacy primitives.";
1790+
const BASE_DESCRIPTION: &str = "Coordinate agents. In light-swarm and normal ad hoc use, only the root session may spawn agents, keeping execution to one level of fan-out. Recursive spawning is enabled only when the root session is running in swarm-deep mode; deep descendants may then spawn their own children, bounded by the configured live-agent limit and the absolute swarm member cap. For spawn, prefer providing a prompt so the new agent starts with a concrete task instead of idling. Spawned/assigned agents automatically report their final response back to the agent that spawned them; you can stop any agent in the subtree you spawned.\n\nCommunication: prefer structural dataflow (task-graph artifacts via complete_node) over chat, and DMs for point-to-point coordination. broadcast reaches only your spawned subtree (whole swarm for the coordinator) and should be rare; channels and shared-context are discouraged legacy primitives.";
17911791
let swarm_prompt = crate::prompt::load_swarm_prompt(None);
17921792
let description = if swarm_prompt.is_empty() {
17931793
BASE_DESCRIPTION.to_string()

crates/jcode-app-core/src/tool/communicate_tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,14 @@ fn description_includes_swarm_prompt_guidance() {
10971097
description.contains("Swarm prompt"),
10981098
"description should embed the swarm prompt section"
10991099
);
1100+
assert!(
1101+
description.contains("only the root session may spawn agents"),
1102+
"description should advertise the enforced light/ad hoc spawn boundary"
1103+
);
1104+
assert!(
1105+
description.contains("Recursive spawning is enabled only when the root session is running in swarm-deep mode"),
1106+
"description should reserve recursive spawning for deep-swarm roots"
1107+
);
11001108
}
11011109

11021110
#[test]

crates/jcode-base/src/prompt/swarm_prompt.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,9 @@ Structure guidance for spawned swarm agents:
2121
- Always pass `label` when spawning (e.g. `label: "api reviewer"`) so the swarm
2222
UI shows what each agent is for. The explicit `spawn` action rejects missing or
2323
blank labels.
24-
- Any agent may spawn children; the spawner owns them (children report back to
25-
it, and it may stop them). There is no special "manager" role: a manager is
26-
just an agent whose prompt tells it to decompose work, delegate via spawn,
27-
and synthesize the reports.
28-
- When you are a worker with focused work of your own and want to delegate more
29-
than 2-3 subtasks, do not fan them out directly. Spawn one manager agent with
30-
a prompt like "own X: decompose it, spawn workers for the pieces, synthesize
31-
their reports, and report back", and let it own that subtree. This keeps your
32-
own context on your task and keeps report-back traffic structured.
24+
- In normal and light-swarm mode, only the root session may spawn agents. Workers
25+
must complete their assigned task directly and report back rather than creating
26+
another generation.
27+
- Recursive spawning is reserved for a root running in `swarm-deep` mode. In that
28+
mode the spawner owns its children, and manager-style decomposition may create
29+
deeper subtrees when it materially improves coverage.

crates/jcode-base/src/prompt_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ fn test_default_swarm_prompt_mentions_model_and_list_models() {
281281
assert!(DEFAULT_SWARM_PROMPT.contains("list_models"));
282282
assert!(DEFAULT_SWARM_PROMPT.contains("model"));
283283
assert!(DEFAULT_SWARM_PROMPT.contains("effort"));
284+
assert!(DEFAULT_SWARM_PROMPT.contains("only the root session may spawn agents"));
285+
assert!(DEFAULT_SWARM_PROMPT.contains("swarm-deep"));
284286
}
285287

286288
#[test]

docs/SWARM_ARCHITECTURE.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@ coordinate, plan, communicate, and integrate work with optional git worktrees.
1919

2020
## Roles
2121

22-
### Recursive spawning (unbounded-depth tree)
22+
### Mode-gated spawning
2323

24-
Spawning is recursive. Any swarm member can spawn child agents, and those
25-
children can spawn their own children, forming a spawn tree. There is no depth
26-
cap: growth is bounded only by the total swarm member cap. The root session
27-
that first spawns in a repo is depth 0.
24+
Normal ad hoc swarms and light-swarm mode are one-level fan-out: only the root
25+
session may spawn agents. Workers report their result to the root and cannot
26+
create another generation. This keeps opportunistic swarm use bounded by
27+
construction.
28+
29+
Recursive spawning is reserved for roots running in `swarm-deep` mode. Their
30+
descendants may spawn children at arbitrary depth, subject to the configurable
31+
live-worker budget and the absolute swarm member cap. The root session that first
32+
spawns in a repo is depth 0.
2833

2934
The spawn/parent edge is encoded by `report_back_to_session_id`: a child spawned
3035
by `P` reports back to `P`. Walking that chain reconstructs ancestry and depth,
@@ -42,7 +47,8 @@ report-back coherent across member churn.
4247
The single per-swarm "coordinator" slot still exists, but only for shared,
4348
swarm-level plan operations (propose/approve/assign/task-control on the one
4449
shared plan). Only a root session claims that slot, and only when it is empty or
45-
stale. A live coordinator no longer blocks anyone else from spawning.
50+
stale. Authorized deep descendants do not claim or disturb this slot when they
51+
spawn.
4652

4753
Nested owners coordinate their own subtree through spawn prompts, direct
4854
messages, and stop, not through the shared plan. Plan/task operations
@@ -76,10 +82,9 @@ concurrently would make the shared plan incoherent.
7682
- Propose plan updates when they discover issues or new requirements.
7783
- Coordinate directly with other agents via DM or channels.
7884
- Emit lifecycle events when they start, finish, or stop unexpectedly.
79-
- May spawn their own child agents (no depth cap; bounded only by the total
80-
swarm member cap) and stop any
81-
agent in the subtree they spawned. Stopping agents outside their own subtree
82-
still requires `force=true`.
85+
- In a deep swarm, may spawn child agents and stop any agent in the subtree they
86+
spawned. In light and ad hoc swarms, workers cannot spawn. Stopping agents
87+
outside their own subtree still requires `force=true`.
8388

8489
## Agent Lifecycle States
8590

docs/SWARM_TASK_GRAPH.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ the scheduler or dataflow.
7575
a modest quality bump, without going extreme. Goal is just to run independent
7676
units in parallel. Mostly flat (one level of fan-out); decomposition is optional
7777
(agent's choice); the critique/verify gate is off, or at most a single optional
78-
final check; recursion is discouraged/disabled; the handoff artifact is
78+
final check; only the root may spawn agents, so recursive growth is disabled;
79+
the handoff artifact is
7980
lightweight and may be free-form. Small worker cap (e.g. 4-16). Low cost and
8081
latency. This is essentially today's spawn-and-fan-out behavior kept cheap.
8182
Examples: "run these 5 independent edits in parallel".
@@ -149,9 +150,10 @@ flowchart TB
149150
Dj --> E[Task E downstream of D]
150151
```
151152

152-
Recursion is bounded only by a single **total-member cap** (1000 agents per swarm,
153-
section 10). There is intentionally no depth limit and no per-node fan-out limit:
154-
the spawn tree may nest and fan out freely until the swarm hits the member cap.
153+
In **deep mode**, recursion has no fixed depth or per-node fan-out limit. Growth
154+
is bounded by the configurable live-worker budget and the absolute total-member
155+
cap (1000 agents per swarm, section 10). Light mode disables recursive spawning:
156+
only its root may create the flat worker fan-out.
155157

156158
---
157159

0 commit comments

Comments
 (0)