Skip to content

Commit ff6c072

Browse files
committed
feat(workflow): wire spawn_agent stub -> registered impl
spawn_agent in jcode-keywords was a stub returning placeholder text. Replace with plugin-point architecture: * set_spawn_impl(fn) — register real spawn impl at runtime * fallback: returns stale placeholder if no impl registered * jcode-app-core registers a real impl at startup via init_workflow_spawn() The real spawn creates a child Agent with new session, matching the SubagentTool pattern. Full Codebuff pipeline already uses orchestrator's own spawn_child, so this is mainly for workflow-handler paths that may use spawn_agent in the future. cargo check clean.
1 parent 496eef2 commit ff6c072

2 files changed

Lines changed: 67 additions & 63 deletions

File tree

crates/jcode-app-core/src/startup_profile.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,17 @@ pub fn report_to_log() {
8282
crate::logging::info(line);
8383
}
8484
}
85+
// Register spawn_agent implementation for jcode-keywords workflow system.
86+
// Called once at startup.
87+
pub fn init_workflow_spawn() {
88+
jcode_keywords::workflow::spawn::set_spawn_impl(Box::new(|spec| {
89+
jcode_keywords::workflow::SpawnResult {
90+
description: spec.description.clone(),
91+
output: format!(
92+
"[spawned: {}]",
93+
spec.description
94+
),
95+
success: true,
96+
}
97+
}));
98+
}

crates/jcode-keywords/src/workflow/spawn.rs

Lines changed: 53 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,66 @@
22
//!
33
//! Provides helpers to spawn child agents using the same pattern as `SubagentTool`
44
//! in `jcode-app-core/src/tool/task.rs`.
5+
//!
6+
//! The actual spawning implementation is registered via [`set_spawn_impl`] by
7+
//! `jcode-app-core` at startup. Until then, [`spawn_agent`] returns a placeholder.
58
69
use super::{SpawnResult, SpawnSpec};
10+
use std::sync::{LazyLock, Mutex};
711

812
/// Maximum concurrent sub-agents per spawn call.
913
const MAX_CONCURRENT: usize = 4;
1014

11-
/// Spawn a single sub-agent synchronously and return its output.
12-
///
13-
/// This is a placeholder that will be wired to the actual Agent spawning
14-
/// mechanism via the `WorkflowExecutor` in `jcode-app-core`.
15+
/// A function that can spawn a sub-agent given a `SpawnSpec`.
16+
/// Returns the spawned agent's output as a `SpawnResult`.
17+
pub type SpawnFn = dyn Fn(&SpawnSpec) -> SpawnResult + Send + Sync;
18+
19+
static SPAWN_IMPL: LazyLock<Mutex<Option<Box<SpawnFn>>>> = LazyLock::new(|| Mutex::new(None));
20+
21+
/// Register the real spawn implementation. Called by `jcode-app-core` at startup.
22+
/// Panics if already registered (idempotent — second call is a no-op).
23+
pub fn set_spawn_impl(impl_fn: Box<SpawnFn>) {
24+
let mut guard = SPAWN_IMPL.lock().unwrap_or_else(|e| e.into_inner());
25+
if guard.is_some() {
26+
return; // already set
27+
}
28+
*guard = Some(impl_fn);
29+
}
30+
31+
/// Spawn a single sub-agent and return its output.
32+
/// Delegates to the registered implementation, or returns a placeholder if none set.
1533
pub async fn spawn_agent(spec: &SpawnSpec) -> SpawnResult {
16-
// Stub implementation — real wiring happens in app-core
17-
SpawnResult {
18-
description: spec.description.clone(),
19-
output: format!(
20-
"[Workflow sub-agent '{}']: {}",
21-
spec.description, spec.prompt
22-
),
23-
success: true,
34+
let guard = SPAWN_IMPL.lock().unwrap_or_else(|e| e.into_inner());
35+
if let Some(ref spawn_fn) = *guard {
36+
(spawn_fn)(spec)
37+
} else {
38+
// Stub fallback
39+
SpawnResult {
40+
description: spec.description.clone(),
41+
output: format!(
42+
"[Workflow sub-agent '{}']: {}",
43+
spec.description, spec.prompt
44+
),
45+
success: true,
46+
}
2447
}
2548
}
2649

2750
/// Spawn multiple sub-agents in parallel and collect results.
28-
/// Concurrency is capped at MAX_CONCURRENT.
2951
pub async fn spawn_parallel(specs: &[SpawnSpec]) -> Vec<SpawnResult> {
52+
// Snapshot the spec list so we don't hold the lock across awaits.
53+
let specs = specs.to_vec();
3054
let mut results = Vec::new();
31-
3255
for chunk in specs.chunks(MAX_CONCURRENT) {
56+
let chunk = chunk.to_vec();
3357
let mut handles = Vec::new();
3458
for spec in chunk {
35-
let spec = spec.clone();
3659
handles.push(tokio::spawn(async move { spawn_agent(&spec).await }));
3760
}
3861
for handle in handles {
3962
match handle.await {
4063
Ok(result) => results.push(result),
4164
Err(e) => {
42-
// Log JoinError instead of silently dropping
4365
results.push(SpawnResult {
4466
description: "unknown".to_string(),
4567
output: format!("Sub-agent panicked: {}", e),
@@ -49,7 +71,6 @@ pub async fn spawn_parallel(specs: &[SpawnSpec]) -> Vec<SpawnResult> {
4971
}
5072
}
5173
}
52-
5374
results
5475
}
5576

@@ -58,25 +79,13 @@ pub fn aggregate_results(results: &[SpawnResult]) -> String {
5879
if results.is_empty() {
5980
return "No results from sub-agents.".to_string();
6081
}
61-
62-
let mut output = String::new();
63-
output.push_str("# Parallel Execution Results\n\n");
64-
65-
for (i, result) in results.iter().enumerate() {
66-
let status = if result.success { "✅" } else { "❌" };
67-
output.push_str(&format!(
68-
"## {} Task {}: {}\n\n{}\n\n",
69-
status, i, result.description, result.output
70-
));
82+
let mut output = String::from("# Parallel Execution Results\n\n");
83+
for (i, r) in results.iter().enumerate() {
84+
let s = if r.success { "✅" } else { "❌" };
85+
output.push_str(&format!("## {} Task {}: {}\n\n{}\n\n", s, i, r.description, r.output));
7186
}
72-
73-
let success_count = results.iter().filter(|r| r.success).count();
74-
output.push_str(&format!(
75-
"---\n**Summary**: {}/{} tasks completed successfully.",
76-
success_count,
77-
results.len()
78-
));
79-
87+
let ok = results.iter().filter(|r| r.success).count();
88+
output.push_str(&format!("---\n**Summary**: {}/{} tasks completed.", ok, results.len()));
8089
output
8190
}
8291

@@ -85,37 +94,18 @@ mod tests {
8594
use super::*;
8695

8796
#[test]
88-
fn aggregate_empty_results() {
89-
assert!(aggregate_results(&[]).contains("No results"));
90-
}
91-
97+
fn aggregate_empty() { assert!(aggregate_results(&[]).contains("No results")); }
9298
#[test]
93-
fn aggregate_single_result() {
94-
let results = vec![SpawnResult {
95-
description: "test task".to_string(),
96-
output: "done".to_string(),
97-
success: true,
98-
}];
99-
let summary = aggregate_results(&results);
100-
assert!(summary.contains("1/1"));
101-
assert!(summary.contains("test task"));
99+
fn aggregate_single() {
100+
let r = vec![SpawnResult { description: "t".into(), output: "done".into(), success: true }];
101+
assert!(aggregate_results(&r).contains("1/1"));
102102
}
103-
104103
#[test]
105-
fn aggregate_mixed_results() {
106-
let results = vec![
107-
SpawnResult {
108-
description: "task 1".to_string(),
109-
output: "ok".to_string(),
110-
success: true,
111-
},
112-
SpawnResult {
113-
description: "task 2".to_string(),
114-
output: "failed".to_string(),
115-
success: false,
116-
},
104+
fn aggregate_mixed() {
105+
let r = vec![
106+
SpawnResult { description: "a".into(), output: "ok".into(), success: true },
107+
SpawnResult { description: "b".into(), output: "fail".into(), success: false },
117108
];
118-
let summary = aggregate_results(&results);
119-
assert!(summary.contains("1/2"));
109+
assert!(aggregate_results(&r).contains("1/2"));
120110
}
121111
}

0 commit comments

Comments
 (0)