| 23 |
65
|
|
pub struct ChildWorkerResult {
|
| 24 |
66
|
|
pub id: usize,
|
| 25 |
67
|
|
pub success: bool,
|
|
68
|
+ |
/// The child's answer when it succeeded, or why it did not.
|
| 26 |
69
|
|
pub output: String,
|
| 27 |
70
|
|
pub duration_ms: u128,
|
|
71
|
+ |
/// The operating system process, for a child that is one. `ox-alpha` runs
|
|
72
|
+ |
/// in this process and reports `None`; what it starts are its `shell` tool
|
|
73
|
+ |
/// subprocesses.
|
|
74
|
+ |
pub pid: Option<u32>,
|
|
75
|
+ |
pub workspace: Option<PathBuf>,
|
|
76
|
+ |
/// Set when the child did not answer, so a caller does not have to read
|
|
77
|
+ |
/// `output` to find out.
|
|
78
|
+ |
pub failure: Option<String>,
|
|
79
|
+ |
}
|
|
80
|
+ |
|
|
81
|
+ |
/// What a child reports while it works.
|
|
82
|
+ |
#[derive(Debug, Clone)]
|
|
83
|
+ |
pub enum ChildEvent {
|
|
84
|
+ |
Started {
|
|
85
|
+ |
id: usize,
|
|
86
|
+ |
lane: String,
|
|
87
|
+ |
workspace: String,
|
|
88
|
+ |
pid: Option<u32>,
|
|
89
|
+ |
},
|
|
90
|
+ |
/// A piece of what the child wrote, exactly as it arrived.
|
|
91
|
+ |
Output { id: usize, text: String },
|
|
92
|
+ |
/// Something the child did that is not its answer: a tool call, a token
|
|
93
|
+ |
/// count, a session id.
|
|
94
|
+ |
Activity { id: usize, text: String },
|
|
95
|
+ |
Finished(Box<ChildWorkerResult>),
|
|
96
|
+ |
}
|
|
97
|
+ |
|
|
98
|
+ |
/// Which harness and model a child runs on.
|
|
99
|
+ |
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
100
|
+ |
pub enum ChildLane {
|
|
101
|
+ |
/// This process, on the OpenAgents inference proxy, with this session's
|
|
102
|
+ |
/// tools.
|
|
103
|
+ |
OxAlpha,
|
|
104
|
+ |
/// The `opencode` CLI on this machine, with its own tools.
|
|
105
|
+ |
Opencode { model: String },
|
|
106
|
+ |
/// The Devin CLI as an ACP server.
|
|
107
|
+ |
Devin,
|
|
108
|
+ |
/// The Claude Code CLI in print mode.
|
|
109
|
+ |
Claude,
|
|
110
|
+ |
/// The OpenAI Codex CLI in exec mode.
|
|
111
|
+ |
Codex,
|
|
112
|
+ |
}
|
|
113
|
+ |
|
|
114
|
+ |
impl ChildLane {
|
|
115
|
+ |
pub fn parse(name: &str) -> Self {
|
|
116
|
+ |
let lowered = name.trim().to_lowercase();
|
|
117
|
+ |
match lowered.as_str() {
|
|
118
|
+ |
"gemini" | "gemini-flash" => ChildLane::Opencode {
|
|
119
|
+ |
model: "gemini-3.7-flash".to_string(),
|
|
120
|
+ |
},
|
|
121
|
+ |
"devin" => ChildLane::Devin,
|
|
122
|
+ |
"claude" => ChildLane::Claude,
|
|
123
|
+ |
"codex" => ChildLane::Codex,
|
|
124
|
+ |
"ox-alpha" | "ox" | "openagents" => ChildLane::OxAlpha,
|
|
125
|
+ |
other if other.starts_with("opencode/") => ChildLane::Opencode {
|
|
126
|
+ |
model: other.trim_start_matches("opencode/").to_string(),
|
|
127
|
+ |
},
|
|
128
|
+ |
// An unknown name used to fall through to `ox-alpha` in silence, so
|
|
129
|
+ |
// a typo spent this account's budget on a lane the caller did not
|
|
130
|
+ |
// ask for. It still runs there, but the caller is told.
|
|
131
|
+ |
_ => ChildLane::OxAlpha,
|
|
132
|
+ |
}
|
|
133
|
+ |
}
|
|
134
|
+ |
|
|
135
|
+ |
/// Whether [`ChildLane::parse`] recognised the name it was given.
|
|
136
|
+ |
pub fn known(name: &str) -> bool {
|
|
137
|
+ |
let lowered = name.trim().to_lowercase();
|
|
138
|
+ |
matches!(
|
|
139
|
+ |
lowered.as_str(),
|
|
140
|
+ |
"gemini" | "gemini-flash" | "devin" | "claude" | "codex" | "ox-alpha" | "ox" | "openagents"
|
|
141
|
+ |
) || lowered.starts_with("opencode/")
|
|
142
|
+ |
}
|
|
143
|
+ |
|
|
144
|
+ |
pub fn label(&self) -> String {
|
|
145
|
+ |
match self {
|
|
146
|
+ |
ChildLane::OxAlpha => "ox-alpha (this process, the OpenAgents proxy)".to_string(),
|
|
147
|
+ |
ChildLane::Opencode { model } => format!("opencode ({model})"),
|
|
148
|
+ |
ChildLane::Devin => "devin (ACP over the Devin CLI)".to_string(),
|
|
149
|
+ |
ChildLane::Claude => "claude (Claude Code print mode)".to_string(),
|
|
150
|
+ |
ChildLane::Codex => "codex (Codex exec)".to_string(),
|
|
151
|
+ |
}
|
|
152
|
+ |
}
|
|
153
|
+ |
|
|
154
|
+ |
/// The binary this lane needs on `PATH`, if it needs one.
|
|
155
|
+ |
pub fn binary(&self) -> Option<&'static str> {
|
|
156
|
+ |
match self {
|
|
157
|
+ |
ChildLane::OxAlpha => None,
|
|
158
|
+ |
ChildLane::Opencode { .. } => Some("opencode"),
|
|
159
|
+ |
ChildLane::Devin => Some("devin"),
|
|
160
|
+ |
ChildLane::Claude => Some("claude"),
|
|
161
|
+ |
ChildLane::Codex => Some("codex"),
|
|
162
|
+ |
}
|
|
163
|
+ |
}
|
| 28 |
164
|
|
}
|
| 29 |
165
|
|
|
| 30 |
166
|
|
pub struct DelegationSupervisor {
|
| 31 |
167
|
|
pub count: usize,
|
| 32 |
168
|
|
pub lane: String,
|
| 33 |
169
|
|
pub user_token: Option<String>,
|
|
170
|
+ |
/// How much of a directory each child gets to itself.
|
|
171
|
+ |
pub isolation: Isolation,
|
|
172
|
+ |
/// How many children run at once. Defaults to all of them.
|
|
173
|
+ |
pub max_parallel: usize,
|
|
174
|
+ |
/// Leave the children's worktrees on disk when the fan-out is over, so
|
|
175
|
+ |
/// what they wrote can be read or merged.
|
|
176
|
+ |
pub keep_workspaces: bool,
|
| 34 |
177
|
|
}
|
| 35 |
178
|
|
|
| 36 |
179
|
|
impl DelegationSupervisor {
|
| 37 |
180
|
|
pub fn new(count: usize, lane: &str, user_token: Option<String>) -> Self {
|
|
181
|
+ |
let count = count.clamp(1, MAX_DELEGATE_COUNT);
|
| 38 |
182
|
|
Self {
|
| 39 |
183
|
|
count,
|
| 40 |
184
|
|
lane: lane.to_string(),
|
| 41 |
185
|
|
user_token,
|
|
186
|
+ |
isolation: Isolation::Worktree,
|
|
187
|
+ |
max_parallel: count,
|
|
188
|
+ |
keep_workspaces: false,
|
| 42 |
189
|
|
}
|
| 43 |
190
|
|
}
|
| 44 |
191
|
|
|
|
192
|
+ |
pub fn with_isolation(mut self, isolation: Isolation) -> Self {
|
|
193
|
+ |
self.isolation = isolation;
|
|
194
|
+ |
self
|
|
195
|
+ |
}
|
|
196
|
+ |
|
|
197
|
+ |
pub fn with_max_parallel(mut self, max_parallel: usize) -> Self {
|
|
198
|
+ |
self.max_parallel = max_parallel.clamp(1, self.count);
|
|
199
|
+ |
self
|
|
200
|
+ |
}
|
|
201
|
+ |
|
|
202
|
+ |
pub fn keeping_workspaces(mut self, keep: bool) -> Self {
|
|
203
|
+ |
self.keep_workspaces = keep;
|
|
204
|
+ |
self
|
|
205
|
+ |
}
|
|
206
|
+ |
|
|
207
|
+ |
/// Run the fan-out and return every child's outcome.
|
|
208
|
+ |
///
|
|
209
|
+ |
/// Convenience over [`DelegationSupervisor::dispatch_streaming`] for a
|
|
210
|
+ |
/// caller that has nowhere to stream to.
|
| 45 |
211
|
|
pub async fn dispatch(&self, prompt: &str) -> Vec<ChildWorkerResult> {
|
| 46 |
|
- |
let mut handles = Vec::new();
|
| 47 |
|
- |
for id in 1..=self.count {
|
|
212
|
+ |
let (events, mut drain) = mpsc::unbounded_channel();
|
|
213
|
+ |
let sink = tokio::spawn(async move { while drain.recv().await.is_some() {} });
|
|
214
|
+ |
let (_stop, cancel) = watch::channel(false);
|
|
215
|
+ |
let results = self.dispatch_streaming(prompt, events, cancel).await;
|
|
216
|
+ |
let _ = sink.await;
|
|
217
|
+ |
results.unwrap_or_default()
|
|
218
|
+ |
}
|
|
219
|
+ |
|
|
220
|
+ |
/// Run the fan-out, reporting each child as it goes.
|
|
221
|
+ |
///
|
|
222
|
+ |
/// Returns `Err` only when no child could be started at all — a workspace
|
|
223
|
+ |
/// that could not be prepared. A child that fails is a result with
|
|
224
|
+ |
/// `success: false`, because the other children's answers are still worth
|
|
225
|
+ |
/// having.
|
|
226
|
+ |
pub async fn dispatch_streaming(
|
|
227
|
+ |
&self,
|
|
228
|
+ |
prompt: &str,
|
|
229
|
+ |
events: mpsc::UnboundedSender<ChildEvent>,
|
|
230
|
+ |
cancel: watch::Receiver<bool>,
|
|
231
|
+ |
) -> Result<Vec<ChildWorkerResult>, String> {
|
|
232
|
+ |
let lane = ChildLane::parse(&self.lane);
|
|
233
|
+ |
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
234
|
+ |
let plan = WorkspacePlan::resolve(cwd, self.isolation).await;
|
|
235
|
+ |
let workspaces = plan.prepare(self.count).await?;
|
|
236
|
+ |
|
|
237
|
+ |
let gate = Arc::new(Semaphore::new(self.max_parallel.max(1)));
|
|
238
|
+ |
let mut handles = Vec::with_capacity(self.count);
|
|
239
|
+ |
|
|
240
|
+ |
for workspace in workspaces.clone() {
|
| 48 |
241
|
|
let task = ChildWorkerTask {
|
| 49 |
|
- |
id,
|
| 50 |
|
- |
prompt: prompt.to_string(),
|
|
242
|
+ |
id: workspace.id,
|
|
243
|
+ |
prompt: identify(prompt, workspace.id, self.count),
|
| 51 |
244
|
|
lane: self.lane.clone(),
|
| 52 |
|
- |
worktree_path: None,
|
|
245
|
+ |
worktree_path: Some(workspace.path.clone()),
|
| 53 |
246
|
|
};
|
|
247
|
+ |
let lane = lane.clone();
|
| 54 |
248
|
|
let token = self.user_token.clone();
|
|
249
|
+ |
let events = events.clone();
|
|
250
|
+ |
let cancel = cancel.clone();
|
|
251
|
+ |
let gate = Arc::clone(&gate);
|
|
252
|
+ |
|
| 55 |
253
|
|
handles.push(tokio::spawn(async move {
|
| 56 |
|
- |
Self::execute_worker(task, token).await
|
|
254
|
+ |
// The cap is here rather than around the spawn so a child that
|
|
255
|
+ |
// is waiting for a slot still exists and still reports.
|
|
256
|
+ |
let _slot = gate.acquire().await;
|
|
257
|
+ |
let result = run_child(task, lane, workspace, token, &events, cancel).await;
|
|
258
|
+ |
let _ = events.send(ChildEvent::Finished(Box::new(result.clone())));
|
|
259
|
+ |
result
|
| 57 |
260
|
|
}));
|
| 58 |
261
|
|
}
|
| 59 |
262
|
|
|
| 60 |
|
- |
let mut results = Vec::new();
|
| 61 |
|
- |
for handle in join_all(handles).await {
|
| 62 |
|
- |
if let Ok(res) = handle {
|
| 63 |
|
- |
results.push(res);
|
|
263
|
+ |
let mut results = Vec::with_capacity(handles.len());
|
|
264
|
+ |
for handle in handles {
|
|
265
|
+ |
match handle.await {
|
|
266
|
+ |
Ok(result) => results.push(result),
|
|
267
|
+ |
// A panic in a child's task is a failed child, not a lost one.
|
|
268
|
+ |
Err(error) => results.push(ChildWorkerResult {
|
|
269
|
+ |
id: 0,
|
|
270
|
+ |
success: false,
|
|
271
|
+ |
output: format!("the child's task ended abnormally: {error}"),
|
|
272
|
+ |
duration_ms: 0,
|
|
273
|
+ |
pid: None,
|
|
274
|
+ |
workspace: None,
|
|
275
|
+ |
failure: Some(format!("the child's task ended abnormally: {error}")),
|
|
276
|
+ |
}),
|
| 64 |
277
|
|
}
|
| 65 |
278
|
|
}
|
| 66 |
|
- |
results
|
| 67 |
|
- |
}
|
| 68 |
|
- |
|
| 69 |
|
- |
async fn execute_worker(task: ChildWorkerTask, user_token: Option<String>) -> ChildWorkerResult {
|
| 70 |
|
- |
let start = Instant::now();
|
| 71 |
|
- |
let lane_str = task.lane.to_lowercase();
|
| 72 |
|
- |
|
| 73 |
|
- |
let (success, output) = match lane_str.as_str() {
|
| 74 |
|
- |
"claude" => run_claude_cli(&task.prompt).await,
|
| 75 |
|
- |
"codex" => run_codex_cli(&task.prompt).await,
|
| 76 |
|
- |
"gemini" => run_opencode_cli(&task.prompt, "gemini-3.7-flash").await,
|
| 77 |
|
- |
"devin" => run_devin_cli(&task.prompt).await,
|
| 78 |
|
- |
_ => {
|
| 79 |
|
- |
// Default ox-alpha via live CoderRuntimeSession
|
| 80 |
|
- |
let tools = HarnessToolRegistry::new(None);
|
| 81 |
|
- |
let mut runtime = CoderRuntimeSession::new(Lane::OxAlpha, None, user_token, tools);
|
| 82 |
|
- |
match runtime.execute_turn(&task.prompt, |_| {}).await {
|
| 83 |
|
- |
Ok(out) => (true, out),
|
| 84 |
|
- |
Err(e) => (false, format!("Inference error: {}", e)),
|
|
279
|
+ |
results.sort_by_key(|result| result.id);
|
|
280
|
+ |
|
|
281
|
+ |
if !self.keep_workspaces {
|
|
282
|
+ |
for workspace in &workspaces {
|
|
283
|
+ |
if let Some(problem) = workspace.release().await {
|
|
284
|
+ |
let _ = events.send(ChildEvent::Activity {
|
|
285
|
+ |
id: workspace.id,
|
|
286
|
+ |
text: problem,
|
|
287
|
+ |
});
|
| 85 |
288
|
|
}
|
| 86 |
289
|
|
}
|
| 87 |
|
- |
};
|
|
290
|
+ |
}
|
|
291
|
+ |
|
|
292
|
+ |
Ok(results)
|
|
293
|
+ |
}
|
|
294
|
+ |
}
|
|
295
|
+ |
|
|
296
|
+ |
/// Tell a child which of the fan-out it is.
|
|
297
|
+ |
///
|
|
298
|
+ |
/// Every child gets the same prompt, so a prompt that says "your own file"
|
|
299
|
+ |
/// otherwise has no way to mean anything and the whole fleet writes the same
|
|
300
|
+ |
/// one. A single child is told nothing, because there is nothing to
|
|
301
|
+ |
/// distinguish.
|
|
302
|
+ |
pub fn identify(prompt: &str, index: usize, count: usize) -> String {
|
|
303
|
+ |
if count == 1 {
|
|
304
|
+ |
return prompt.to_string();
|
|
305
|
+ |
}
|
|
306
|
+ |
format!("You are child {index} of {count}.\n\n{prompt}")
|
|
307
|
+ |
}
|
|
308
|
+ |
|
|
309
|
+ |
async fn run_child(
|
|
310
|
+ |
task: ChildWorkerTask,
|
|
311
|
+ |
lane: ChildLane,
|
|
312
|
+ |
workspace: ChildWorkspace,
|
|
313
|
+ |
user_token: Option<String>,
|
|
314
|
+ |
events: &mpsc::UnboundedSender<ChildEvent>,
|
|
315
|
+ |
cancel: watch::Receiver<bool>,
|
|
316
|
+ |
) -> ChildWorkerResult {
|
|
317
|
+ |
let start = Instant::now();
|
|
318
|
+ |
let id = task.id;
|
| 88 |
319
|
|
|
| 89 |
|
- |
ChildWorkerResult {
|
| 90 |
|
- |
id: task.id,
|
| 91 |
|
- |
success,
|
| 92 |
|
- |
output,
|
| 93 |
|
- |
duration_ms: start.elapsed().as_millis(),
|
|
320
|
+ |
let outcome = match &lane {
|
|
321
|
+ |
ChildLane::OxAlpha => {
|
|
322
|
+ |
let _ = events.send(ChildEvent::Started {
|
|
323
|
+ |
id,
|
|
324
|
+ |
lane: lane.label(),
|
|
325
|
+ |
workspace: workspace.describe(),
|
|
326
|
+ |
pid: None,
|
|
327
|
+ |
});
|
|
328
|
+ |
run_proxy_child(&task, &workspace, user_token, events, cancel).await
|
|
329
|
+ |
}
|
|
330
|
+ |
ChildLane::Devin => {
|
|
331
|
+ |
run_devin_child(&task, &lane, &workspace, events, cancel).await
|
|
332
|
+ |
}
|
|
333
|
+ |
ChildLane::Claude | ChildLane::Codex | ChildLane::Opencode { .. } => {
|
|
334
|
+ |
run_cli_child(&task, &lane, &workspace, events, cancel).await
|
| 94 |
335
|
|
}
|
|
336
|
+ |
};
|
|
337
|
+ |
|
|
338
|
+ |
let duration_ms = start.elapsed().as_millis();
|
|
339
|
+ |
match outcome {
|
|
340
|
+ |
Ok(ChildAnswer { text, pid }) => ChildWorkerResult {
|
|
341
|
+ |
id,
|
|
342
|
+ |
success: true,
|
|
343
|
+ |
output: clip(&text),
|
|
344
|
+ |
duration_ms,
|
|
345
|
+ |
pid,
|
|
346
|
+ |
workspace: Some(workspace.path.clone()),
|
|
347
|
+ |
failure: None,
|
|
348
|
+ |
},
|
|
349
|
+ |
Err(ChildFailure { why, pid }) => ChildWorkerResult {
|
|
350
|
+ |
id,
|
|
351
|
+ |
success: false,
|
|
352
|
+ |
output: why.clone(),
|
|
353
|
+ |
duration_ms,
|
|
354
|
+ |
pid,
|
|
355
|
+ |
workspace: Some(workspace.path.clone()),
|
|
356
|
+ |
failure: Some(why),
|
|
357
|
+ |
},
|
| 95 |
358
|
|
}
|
| 96 |
359
|
|
}
|
| 97 |
360
|
|
|
| 98 |
|
- |
async fn run_claude_cli(prompt: &str) -> (bool, String) {
|
| 99 |
|
- |
let mut cmd = Command::new("claude");
|
| 100 |
|
- |
cmd.args(["-p", prompt]);
|
| 101 |
|
- |
cmd.stdout(Stdio::piped());
|
| 102 |
|
- |
cmd.stderr(Stdio::piped());
|
|
361
|
+ |
struct ChildAnswer {
|
|
362
|
+ |
text: String,
|
|
363
|
+ |
pid: Option<u32>,
|
|
364
|
+ |
}
|
|
365
|
+ |
|
|
366
|
+ |
struct ChildFailure {
|
|
367
|
+ |
why: String,
|
|
368
|
+ |
pid: Option<u32>,
|
|
369
|
+ |
}
|
|
370
|
+ |
|
|
371
|
+ |
/// A child on this process's own runtime, over the inference proxy.
|
|
372
|
+ |
///
|
|
373
|
+ |
/// It gets a tool registry rooted at its own directory, so its `shell` tool
|
|
374
|
+ |
/// runs there and what it writes lands there. It does not get the `delegate`
|
|
375
|
+ |
/// tool: a fan-out whose children fan out is a fan-out with no ceiling.
|
|
376
|
+ |
async fn run_proxy_child(
|
|
377
|
+ |
task: &ChildWorkerTask,
|
|
378
|
+ |
workspace: &ChildWorkspace,
|
|
379
|
+ |
user_token: Option<String>,
|
|
380
|
+ |
events: &mpsc::UnboundedSender<ChildEvent>,
|
|
381
|
+ |
mut cancel: watch::Receiver<bool>,
|
|
382
|
+ |
) -> Result<ChildAnswer, ChildFailure> {
|
|
383
|
+ |
let tools = HarnessToolRegistry::child(Some(workspace.path.clone()));
|
|
384
|
+ |
let mut runtime = CoderRuntimeSession::new(Lane::OxAlpha, None, user_token, tools);
|
|
385
|
+ |
|
|
386
|
+ |
let id = task.id;
|
|
387
|
+ |
let sink = events.clone();
|
|
388
|
+ |
let turn = runtime.execute_turn(&task.prompt, move |chunk| {
|
|
389
|
+ |
let _ = sink.send(ChildEvent::Output {
|
|
390
|
+ |
id,
|
|
391
|
+ |
text: chunk.to_string(),
|
|
392
|
+ |
});
|
|
393
|
+ |
});
|
| 103 |
394
|
|
|
| 104 |
|
- |
match cmd.output().await {
|
| 105 |
|
- |
Ok(res) => (res.status.success(), String::from_utf8_lossy(&res.stdout).trim().to_string()),
|
| 106 |
|
- |
Err(e) => (false, format!("Failed to spawn claude: {}", e)),
|
|
395
|
+ |
tokio::select! {
|
|
396
|
+ |
biased;
|
|
397
|
+ |
_ = cancel.changed() => Err(ChildFailure {
|
|
398
|
+ |
why: "stopped before finishing".to_string(),
|
|
399
|
+ |
pid: None,
|
|
400
|
+ |
}),
|
|
401
|
+ |
answered = turn => match answered {
|
|
402
|
+ |
Ok(text) => Ok(ChildAnswer { text, pid: None }),
|
|
403
|
+ |
Err(error) => Err(ChildFailure { why: error.to_string(), pid: None }),
|
|
404
|
+ |
},
|
| 107 |
405
|
|
}
|
| 108 |
406
|
|
}
|
| 109 |
407
|
|
|
| 110 |
|
- |
async fn run_codex_cli(prompt: &str) -> (bool, String) {
|
| 111 |
|
- |
let mut cmd = Command::new("codex");
|
| 112 |
|
- |
cmd.args(["exec", prompt]);
|
| 113 |
|
- |
cmd.stdout(Stdio::piped());
|
| 114 |
|
- |
cmd.stderr(Stdio::piped());
|
|
408
|
+ |
/// A child on the Devin CLI, over the Agent Client Protocol.
|
|
409
|
+ |
async fn run_devin_child(
|
|
410
|
+ |
task: &ChildWorkerTask,
|
|
411
|
+ |
lane: &ChildLane,
|
|
412
|
+ |
workspace: &ChildWorkspace,
|
|
413
|
+ |
events: &mpsc::UnboundedSender<ChildEvent>,
|
|
414
|
+ |
mut cancel: watch::Receiver<bool>,
|
|
415
|
+ |
) -> Result<ChildAnswer, ChildFailure> {
|
|
416
|
+ |
let id = task.id;
|
|
417
|
+ |
let _ = events.send(ChildEvent::Started {
|
|
418
|
+ |
id,
|
|
419
|
+ |
lane: lane.label(),
|
|
420
|
+ |
workspace: workspace.describe(),
|
|
421
|
+ |
pid: None,
|
|
422
|
+ |
});
|
| 115 |
423
|
|
|
| 116 |
|
- |
match cmd.output().await {
|
| 117 |
|
- |
Ok(res) => (res.status.success(), String::from_utf8_lossy(&res.stdout).trim().to_string()),
|
| 118 |
|
- |
Err(e) => (false, format!("Failed to spawn codex: {}", e)),
|
|
424
|
+ |
let harness = AcpHarness {
|
|
425
|
+ |
command: harness_binary(lane),
|
|
426
|
+ |
mode: Some(PermissionMode::Dangerous),
|
|
427
|
+ |
..AcpHarness::default()
|
|
428
|
+ |
};
|
|
429
|
+ |
let sink = events.clone();
|
|
430
|
+ |
let answered = harness
|
|
431
|
+ |
.run(
|
|
432
|
+ |
&task.prompt,
|
|
433
|
+ |
&workspace.path,
|
|
434
|
+ |
move |event| {
|
|
435
|
+ |
let text = match event {
|
|
436
|
+ |
AcpEvent::Session { id: session } => format!("session {session}"),
|
|
437
|
+ |
AcpEvent::Tool { kind, title } => {
|
|
438
|
+ |
if title.is_empty() {
|
|
439
|
+ |
kind
|
|
440
|
+ |
} else {
|
|
441
|
+ |
title
|
|
442
|
+ |
}
|
|
443
|
+ |
}
|
|
444
|
+ |
AcpEvent::Tokens { input, output } => {
|
|
445
|
+ |
format!("{input} in / {output} out tokens")
|
|
446
|
+ |
}
|
|
447
|
+ |
AcpEvent::Text { chunk } => {
|
|
448
|
+ |
let _ = sink.send(ChildEvent::Output { id, text: chunk });
|
|
449
|
+ |
return;
|
|
450
|
+ |
}
|
|
451
|
+ |
};
|
|
452
|
+ |
let _ = sink.send(ChildEvent::Activity { id, text });
|
|
453
|
+ |
},
|
|
454
|
+ |
&mut cancel,
|
|
455
|
+ |
)
|
|
456
|
+ |
.await;
|
|
457
|
+ |
|
|
458
|
+ |
match answered {
|
|
459
|
+ |
Ok(text) => Ok(ChildAnswer { text, pid: None }),
|
|
460
|
+ |
Err(AcpFailure::Cancelled) => Err(ChildFailure {
|
|
461
|
+ |
why: "stopped before finishing".to_string(),
|
|
462
|
+ |
pid: None,
|
|
463
|
+ |
}),
|
|
464
|
+ |
Err(other) => Err(ChildFailure {
|
|
465
|
+ |
why: other.to_string(),
|
|
466
|
+ |
pid: None,
|
|
467
|
+ |
}),
|
| 119 |
468
|
|
}
|
| 120 |
469
|
|
}
|
| 121 |
470
|
|
|
| 122 |
|
- |
async fn run_opencode_cli(prompt: &str, model: &str) -> (bool, String) {
|
| 123 |
|
- |
let mut cmd = Command::new("opencode");
|
| 124 |
|
- |
cmd.args(["run", "--model", model, prompt]);
|
| 125 |
|
- |
cmd.stdout(Stdio::piped());
|
| 126 |
|
- |
cmd.stderr(Stdio::piped());
|
|
471
|
+ |
/// A child on another coding CLI: `claude`, `codex`, or `opencode`.
|
|
472
|
+ |
///
|
|
473
|
+ |
/// The child is spawned into a process group of its own, its two output
|
|
474
|
+ |
/// streams are read as they are written rather than at the end, and both are
|
|
475
|
+ |
/// forwarded upward line by line. The answer is pulled out of the harness's
|
|
476
|
+ |
/// own event stream where the harness has one, and is the tail of what it
|
|
477
|
+ |
/// printed where it does not.
|
|
478
|
+ |
async fn run_cli_child(
|
|
479
|
+ |
task: &ChildWorkerTask,
|
|
480
|
+ |
lane: &ChildLane,
|
|
481
|
+ |
workspace: &ChildWorkspace,
|
|
482
|
+ |
events: &mpsc::UnboundedSender<ChildEvent>,
|
|
483
|
+ |
mut cancel: watch::Receiver<bool>,
|
|
484
|
+ |
) -> Result<ChildAnswer, ChildFailure> {
|
|
485
|
+ |
let id = task.id;
|
|
486
|
+ |
let (command, args) = harness_command(lane, &task.prompt, &workspace.path);
|
|
487
|
+ |
|
|
488
|
+ |
let mut child = match Command::new(&command)
|
|
489
|
+ |
.args(&args)
|
|
490
|
+ |
.current_dir(&workspace.path)
|
|
491
|
+ |
// No terminal, so a harness that would prompt gets end-of-file rather
|
|
492
|
+ |
// than a wait nobody can see.
|
|
493
|
+ |
.stdin(Stdio::null())
|
|
494
|
+ |
.stdout(Stdio::piped())
|
|
495
|
+ |
.stderr(Stdio::piped())
|
|
496
|
+ |
.process_group(0)
|
|
497
|
+ |
.spawn()
|
|
498
|
+ |
{
|
|
499
|
+ |
Ok(child) => child,
|
|
500
|
+ |
Err(error) => {
|
|
501
|
+ |
let why = if error.kind() == std::io::ErrorKind::NotFound {
|
|
502
|
+ |
format!("the `{command}` command is not on PATH")
|
|
503
|
+ |
} else {
|
|
504
|
+ |
format!("the `{command}` command would not start: {error}")
|
|
505
|
+ |
};
|
|
506
|
+ |
let _ = events.send(ChildEvent::Started {
|
|
507
|
+ |
id,
|
|
508
|
+ |
lane: lane.label(),
|
|
509
|
+ |
workspace: workspace.describe(),
|
|
510
|
+ |
pid: None,
|
|
511
|
+ |
});
|
|
512
|
+ |
return Err(ChildFailure { why, pid: None });
|
|
513
|
+ |
}
|
|
514
|
+ |
};
|
|
515
|
+ |
|
|
516
|
+ |
let pid = child.id();
|
|
517
|
+ |
let _ = events.send(ChildEvent::Started {
|
|
518
|
+ |
id,
|
|
519
|
+ |
lane: lane.label(),
|
|
520
|
+ |
workspace: workspace.describe(),
|
|
521
|
+ |
pid,
|
|
522
|
+ |
});
|
| 127 |
523
|
|
|
| 128 |
|
- |
match cmd.output().await {
|
| 129 |
|
- |
Ok(res) => (res.status.success(), String::from_utf8_lossy(&res.stdout).trim().to_string()),
|
| 130 |
|
- |
Err(e) => (false, format!("Failed to spawn opencode: {}", e)),
|
|
524
|
+ |
// Both streams are drained by their own task and merged here, so neither
|
|
525
|
+ |
// can fill its pipe and stall the child while the other is being read.
|
|
526
|
+ |
let (lines_tx, mut lines_rx) = mpsc::unbounded_channel::<(bool, String)>();
|
|
527
|
+ |
if let Some(stdout) = child.stdout.take() {
|
|
528
|
+ |
let tx = lines_tx.clone();
|
|
529
|
+ |
tokio::spawn(async move {
|
|
530
|
+ |
let mut reader = BufReader::new(stdout).lines();
|
|
531
|
+ |
while let Ok(Some(line)) = reader.next_line().await {
|
|
532
|
+ |
if tx.send((true, line)).is_err() {
|
|
533
|
+ |
break;
|
|
534
|
+ |
}
|
|
535
|
+ |
}
|
|
536
|
+ |
});
|
|
537
|
+ |
}
|
|
538
|
+ |
if let Some(stderr) = child.stderr.take() {
|
|
539
|
+ |
let tx = lines_tx.clone();
|
|
540
|
+ |
tokio::spawn(async move {
|
|
541
|
+ |
let mut reader = BufReader::new(stderr).lines();
|
|
542
|
+ |
while let Ok(Some(line)) = reader.next_line().await {
|
|
543
|
+ |
if tx.send((false, line)).is_err() {
|
|
544
|
+ |
break;
|
|
545
|
+ |
}
|
|
546
|
+ |
}
|
|
547
|
+ |
});
|
| 131 |
548
|
|
}
|
|
549
|
+ |
drop(lines_tx);
|
|
550
|
+ |
|
|
551
|
+ |
let mut harvest = Harvest::new(lane);
|
|
552
|
+ |
let mut stopped = false;
|
|
553
|
+ |
|
|
554
|
+ |
loop {
|
|
555
|
+ |
tokio::select! {
|
|
556
|
+ |
biased;
|
|
557
|
+ |
_ = cancel.changed() => {
|
|
558
|
+ |
stopped = true;
|
|
559
|
+ |
stop_tree(&mut child).await;
|
|
560
|
+ |
break;
|
|
561
|
+ |
}
|
|
562
|
+ |
line = lines_rx.recv() => {
|
|
563
|
+ |
match line {
|
|
564
|
+ |
Some((from_stdout, line)) => {
|
|
565
|
+ |
if from_stdout {
|
|
566
|
+ |
// A harness with an event stream is forwarded as
|
|
567
|
+ |
// what it said, not as its wire format: five lines
|
|
568
|
+ |
// of `{"type":"assistant","message":{"content":…`
|
|
569
|
+ |
// is not a reader watching a child work.
|
|
570
|
+ |
for rendered in harvest.take(&line) {
|
|
571
|
+ |
let _ = events.send(match rendered {
|
|
572
|
+ |
Rendered::Text(text) => ChildEvent::Output { id, text },
|
|
573
|
+ |
Rendered::Note(text) => ChildEvent::Activity { id, text },
|
|
574
|
+ |
});
|
|
575
|
+ |
}
|
|
576
|
+ |
} else {
|
|
577
|
+ |
let _ = events.send(ChildEvent::Activity { id, text: line.clone() });
|
|
578
|
+ |
harvest.note_stderr(&line);
|
|
579
|
+ |
}
|
|
580
|
+ |
}
|
|
581
|
+ |
// Both streams are closed, so the child has finished
|
|
582
|
+ |
// writing even if it has not yet been reaped.
|
|
583
|
+ |
None => break,
|
|
584
|
+ |
}
|
|
585
|
+ |
}
|
|
586
|
+ |
}
|
|
587
|
+ |
}
|
|
588
|
+ |
|
|
589
|
+ |
if stopped {
|
|
590
|
+ |
return Err(ChildFailure {
|
|
591
|
+ |
why: "stopped before finishing".to_string(),
|
|
592
|
+ |
pid,
|
|
593
|
+ |
});
|
|
594
|
+ |
}
|
|
595
|
+ |
|
|
596
|
+ |
let status = match child.wait().await {
|
|
597
|
+ |
Ok(status) => status,
|
|
598
|
+ |
Err(error) => {
|
|
599
|
+ |
return Err(ChildFailure {
|
|
600
|
+ |
why: format!("the `{command}` child could not be reaped: {error}"),
|
|
601
|
+ |
pid,
|
|
602
|
+ |
})
|
|
603
|
+ |
}
|
|
604
|
+ |
};
|
|
605
|
+ |
|
|
606
|
+ |
if !status.success() {
|
|
607
|
+ |
let code = status.code().unwrap_or(-1);
|
|
608
|
+ |
return Err(ChildFailure {
|
|
609
|
+ |
why: format!(
|
|
610
|
+ |
"the `{command}` child exited with code {code}.\n\n{}",
|
|
611
|
+ |
harvest.tail()
|
|
612
|
+ |
),
|
|
613
|
+ |
pid,
|
|
614
|
+ |
});
|
|
615
|
+ |
}
|
|
616
|
+ |
|
|
617
|
+ |
if let Some(reported) = harvest.reported_error() {
|
|
618
|
+ |
return Err(ChildFailure {
|
|
619
|
+ |
why: format!("the `{command}` child reported an error: {reported}"),
|
|
620
|
+ |
pid,
|
|
621
|
+ |
});
|
|
622
|
+ |
}
|
|
623
|
+ |
|
|
624
|
+ |
Ok(ChildAnswer {
|
|
625
|
+ |
text: harvest.answer(),
|
|
626
|
+ |
pid,
|
|
627
|
+ |
})
|
| 132 |
628
|
|
}
|
| 133 |
629
|
|
|
| 134 |
|
- |
async fn run_devin_cli(prompt: &str) -> (bool, String) {
|
| 135 |
|
- |
let mut cmd = Command::new("devin");
|
| 136 |
|
- |
cmd.args(["--prompt", prompt]);
|
| 137 |
|
- |
cmd.stdout(Stdio::piped());
|
| 138 |
|
- |
cmd.stderr(Stdio::piped());
|
|
630
|
+ |
/// The binary a lane runs, or the stand-in a test points it at.
|
|
631
|
+ |
///
|
|
632
|
+ |
/// The TypeScript harnesses each take a `command` for the same reason: a test
|
|
633
|
+ |
/// that cannot substitute the agent can only assert against a real one, which
|
|
634
|
+ |
/// means it either costs money or does not run.
|
|
635
|
+ |
pub fn harness_binary(lane: &ChildLane) -> String {
|
|
636
|
+ |
let (variable, default) = match lane {
|
|
637
|
+ |
ChildLane::Claude => ("OA_CHILD_CLAUDE", "claude"),
|
|
638
|
+ |
ChildLane::Codex => ("OA_CHILD_CODEX", "codex"),
|
|
639
|
+ |
ChildLane::Opencode { .. } => ("OA_CHILD_OPENCODE", "opencode"),
|
|
640
|
+ |
ChildLane::Devin => ("OA_CHILD_DEVIN", "devin"),
|
|
641
|
+ |
ChildLane::OxAlpha => ("", ""),
|
|
642
|
+ |
};
|
|
643
|
+ |
std::env::var(variable)
|
|
644
|
+ |
.ok()
|
|
645
|
+ |
.filter(|value| !value.trim().is_empty())
|
|
646
|
+ |
.unwrap_or_else(|| default.to_string())
|
|
647
|
+ |
}
|
| 139 |
648
|
|
|
| 140 |
|
- |
match cmd.output().await {
|
| 141 |
|
- |
Ok(res) => (res.status.success(), String::from_utf8_lossy(&res.stdout).trim().to_string()),
|
| 142 |
|
- |
Err(e) => (false, format!("Failed to spawn devin: {}", e)),
|
|
649
|
+ |
/// The binary and arguments each CLI lane runs, following `coder-delegate.ts`.
|
|
650
|
+ |
fn harness_command(lane: &ChildLane, prompt: &str, cwd: &std::path::Path) -> (String, Vec<String>) {
|
|
651
|
+ |
match lane {
|
|
652
|
+ |
ChildLane::Claude => (
|
|
653
|
+ |
harness_binary(lane),
|
|
654
|
+ |
vec![
|
|
655
|
+ |
"-p".to_string(),
|
|
656
|
+ |
prompt.to_string(),
|
|
657
|
+ |
"--output-format".to_string(),
|
|
658
|
+ |
"stream-json".to_string(),
|
|
659
|
+ |
// `stream-json` requires it.
|
|
660
|
+ |
"--verbose".to_string(),
|
|
661
|
+ |
// A delegated child has nobody to ask.
|
|
662
|
+ |
"--permission-mode".to_string(),
|
|
663
|
+ |
"acceptEdits".to_string(),
|
|
664
|
+ |
],
|
|
665
|
+ |
),
|
|
666
|
+ |
ChildLane::Codex => (
|
|
667
|
+ |
harness_binary(lane),
|
|
668
|
+ |
vec![
|
|
669
|
+ |
"exec".to_string(),
|
|
670
|
+ |
"--json".to_string(),
|
|
671
|
+ |
// A child's worktree is a checkout but not one Codex has been
|
|
672
|
+ |
// told to trust, and without this it refuses before it starts.
|
|
673
|
+ |
"--skip-git-repo-check".to_string(),
|
|
674
|
+ |
// The child may edit the checkout it was pointed at and
|
|
675
|
+ |
// nothing outside it.
|
|
676
|
+ |
"--sandbox".to_string(),
|
|
677
|
+ |
"workspace-write".to_string(),
|
|
678
|
+ |
prompt.to_string(),
|
|
679
|
+ |
],
|
|
680
|
+ |
),
|
|
681
|
+ |
ChildLane::Opencode { model } => (
|
|
682
|
+ |
harness_binary(lane),
|
|
683
|
+ |
vec![
|
|
684
|
+ |
"run".to_string(),
|
|
685
|
+ |
"--format".to_string(),
|
|
686
|
+ |
"json".to_string(),
|
|
687
|
+ |
"--model".to_string(),
|
|
688
|
+ |
model.clone(),
|
|
689
|
+ |
"--dir".to_string(),
|
|
690
|
+ |
cwd.to_string_lossy().to_string(),
|
|
691
|
+ |
prompt.to_string(),
|
|
692
|
+ |
],
|
|
693
|
+ |
),
|
|
694
|
+ |
// Handled by their own runners; unreachable through this function.
|
|
695
|
+ |
ChildLane::OxAlpha => ("".to_string(), Vec::new()),
|
|
696
|
+ |
ChildLane::Devin => (harness_binary(lane), vec!["acp".to_string()]),
|
| 143 |
697
|
|
}
|
| 144 |
698
|
|
}
|
| 145 |
699
|
|
|
| 146 |
|
- |
pub async fn run_delegation(args: CoderArgs, user_token: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
| 147 |
|
- |
let count = args.count.max(1);
|
| 148 |
|
- |
let lane = args.lane.unwrap_or_else(|| "ox-alpha".to_string());
|
| 149 |
|
- |
let prompt = args.prompt.unwrap_or_else(|| "Analyze workspace and run tests".to_string());
|
|
700
|
+ |
/// Pulls a child's answer out of whatever its harness prints.
|
|
701
|
+ |
///
|
|
702
|
+ |
/// A harness with a JSON event stream is read as one; anything that is not
|
|
703
|
+ |
/// JSON, or is JSON of a shape this does not know, is kept as text. So a
|
|
704
|
+ |
/// harness that changes its schema degrades to "the tail of what it printed"
|
|
705
|
+ |
/// rather than to an empty answer that reads like a child with nothing to say.
|
|
706
|
+ |
/// What one line of a child's output is: its answer, or what it is doing.
|
|
707
|
+ |
enum Rendered {
|
|
708
|
+ |
Text(String),
|
|
709
|
+ |
Note(String),
|
|
710
|
+ |
}
|
| 150 |
711
|
|
|
| 151 |
|
- |
println!("Starting parallel delegation across {} child workers on lane {}...", count, lane);
|
| 152 |
|
- |
let supervisor = DelegationSupervisor::new(count, &lane, user_token);
|
| 153 |
|
- |
let results = supervisor.dispatch(&prompt).await;
|
|
712
|
+ |
struct Harvest {
|
|
713
|
+ |
lane: ChildLane,
|
|
714
|
+ |
assistant: String,
|
|
715
|
+ |
result: Option<String>,
|
|
716
|
+ |
error: Option<String>,
|
|
717
|
+ |
plain: String,
|
|
718
|
+ |
stderr_tail: String,
|
|
719
|
+ |
}
|
| 154 |
720
|
|
|
| 155 |
|
- |
for res in &results {
|
| 156 |
|
- |
println!("Child {}: status={}, duration={}ms, output={}", res.id, if res.success { "ok" } else { "err" }, res.duration_ms, res.output);
|
|
721
|
+ |
impl Harvest {
|
|
722
|
+ |
fn new(lane: &ChildLane) -> Self {
|
|
723
|
+ |
Self {
|
|
724
|
+ |
lane: lane.clone(),
|
|
725
|
+ |
assistant: String::new(),
|
|
726
|
+ |
result: None,
|
|
727
|
+ |
error: None,
|
|
728
|
+ |
plain: String::new(),
|
|
729
|
+ |
stderr_tail: String::new(),
|
|
730
|
+ |
}
|
|
731
|
+ |
}
|
|
732
|
+ |
|
|
733
|
+ |
/// Read one line and say what the reader should see of it.
|
|
734
|
+ |
fn take(&mut self, line: &str) -> Vec<Rendered> {
|
|
735
|
+ |
let trimmed = line.trim();
|
|
736
|
+ |
if trimmed.is_empty() {
|
|
737
|
+ |
return Vec::new();
|
|
738
|
+ |
}
|
|
739
|
+ |
// Not the harness's event stream. Whatever it is, it is what the child
|
|
740
|
+ |
// printed, so it is passed through as written.
|
|
741
|
+ |
let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) else {
|
|
742
|
+ |
push_bounded(&mut self.plain, line);
|
|
743
|
+ |
return vec![Rendered::Text(format!("{line}\n"))];
|
|
744
|
+ |
};
|
|
745
|
+ |
|
|
746
|
+ |
let mut shown: Vec<Rendered> = Vec::new();
|
|
747
|
+ |
|
|
748
|
+ |
// Claude Code print mode: `assistant` messages carry the text and the
|
|
749
|
+ |
// last event carries the whole result.
|
|
750
|
+ |
if let Some(text) = event
|
|
751
|
+ |
.get("message")
|
|
752
|
+ |
.and_then(|m| m.get("content"))
|
|
753
|
+ |
.and_then(|c| c.as_array())
|
|
754
|
+ |
{
|
|
755
|
+ |
for part in text {
|
|
756
|
+ |
if let Some(said) = part.get("text").and_then(|v| v.as_str()) {
|
|
757
|
+ |
self.assistant.push_str(said);
|
|
758
|
+ |
shown.push(Rendered::Text(said.to_string()));
|
|
759
|
+ |
}
|
|
760
|
+ |
// A tool call is what the child is doing, not what it said.
|
|
761
|
+ |
if part.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
|
|
762
|
+ |
shown.push(Rendered::Note(format!(
|
|
763
|
+ |
"tool {}",
|
|
764
|
+ |
part.get("name").and_then(|v| v.as_str()).unwrap_or("?")
|
|
765
|
+ |
)));
|
|
766
|
+ |
}
|
|
767
|
+ |
}
|
|
768
|
+ |
}
|
|
769
|
+ |
if let Some(result) = event.get("result").and_then(|v| v.as_str()) {
|
|
770
|
+ |
self.result = Some(result.to_string());
|
|
771
|
+ |
}
|
|
772
|
+ |
if event.get("is_error").and_then(|v| v.as_bool()) == Some(true) {
|
|
773
|
+ |
self.error = Some(
|
|
774
|
+ |
event
|
|
775
|
+ |
.get("result")
|
|
776
|
+ |
.and_then(|v| v.as_str())
|
|
777
|
+ |
.unwrap_or("the harness reported an error")
|
|
778
|
+ |
.to_string(),
|
|
779
|
+ |
);
|
|
780
|
+ |
}
|
|
781
|
+ |
|
|
782
|
+ |
// Codex exec: the final assistant message arrives as an item.
|
|
783
|
+ |
if let Some(item) = event.get("item") {
|
|
784
|
+ |
match item.get("type").and_then(|v| v.as_str()) {
|
|
785
|
+ |
Some("agent_message") => {
|
|
786
|
+ |
if let Some(said) = item.get("text").and_then(|v| v.as_str()) {
|
|
787
|
+ |
self.assistant.push_str(said);
|
|
788
|
+ |
shown.push(Rendered::Text(format!("{said}\n")));
|
|
789
|
+ |
}
|
|
790
|
+ |
}
|
|
791
|
+ |
Some("command_execution") => {
|
|
792
|
+ |
if let Some(command) = item.get("command").and_then(|v| v.as_str()) {
|
|
793
|
+ |
shown.push(Rendered::Note(format!("ran {command}")));
|
|
794
|
+ |
}
|
|
795
|
+ |
}
|
|
796
|
+ |
_ => {}
|
|
797
|
+ |
}
|
|
798
|
+ |
}
|
|
799
|
+ |
// Codex's older wire shape, and opencode's.
|
|
800
|
+ |
if let Some(msg) = event.get("msg") {
|
|
801
|
+ |
if let Some(said) = msg.get("message").and_then(|v| v.as_str()) {
|
|
802
|
+ |
self.assistant.push_str(said);
|
|
803
|
+ |
shown.push(Rendered::Text(format!("{said}\n")));
|
|
804
|
+ |
}
|
|
805
|
+ |
if msg.get("type").and_then(|v| v.as_str()) == Some("error") {
|
|
806
|
+ |
if let Some(said) = msg.get("message").and_then(|v| v.as_str()) {
|
|
807
|
+ |
self.error = Some(said.to_string());
|
|
808
|
+ |
}
|
|
809
|
+ |
}
|
|
810
|
+ |
}
|
|
811
|
+ |
if let Some(said) = event
|
|
812
|
+ |
.get("parts")
|
|
813
|
+ |
.and_then(|p| p.as_array())
|
|
814
|
+ |
.map(|parts| {
|
|
815
|
+ |
parts
|
|
816
|
+ |
.iter()
|
|
817
|
+ |
.filter_map(|part| part.get("text").and_then(|v| v.as_str()))
|
|
818
|
+ |
.collect::<Vec<_>>()
|
|
819
|
+ |
.join("")
|
|
820
|
+ |
})
|
|
821
|
+ |
.filter(|said| !said.is_empty())
|
|
822
|
+ |
{
|
|
823
|
+ |
self.assistant.push_str(&said);
|
|
824
|
+ |
shown.push(Rendered::Text(said));
|
|
825
|
+ |
}
|
|
826
|
+ |
|
|
827
|
+ |
if shown.is_empty() {
|
|
828
|
+ |
// A known wire format, an event this does not render. Named rather
|
|
829
|
+ |
// than dropped, so a silent stretch is a silent child and not a
|
|
830
|
+ |
// parser looking the other way.
|
|
831
|
+ |
if let Some(kind) = event
|
|
832
|
+ |
.get("type")
|
|
833
|
+ |
.or_else(|| event.get("msg").and_then(|m| m.get("type")))
|
|
834
|
+ |
.and_then(|v| v.as_str())
|
|
835
|
+ |
{
|
|
836
|
+ |
if kind != "result" {
|
|
837
|
+ |
shown.push(Rendered::Note(kind.to_string()));
|
|
838
|
+ |
}
|
|
839
|
+ |
}
|
|
840
|
+ |
}
|
|
841
|
+ |
shown
|
|
842
|
+ |
}
|
|
843
|
+ |
|
|
844
|
+ |
fn note_stderr(&mut self, line: &str) {
|
|
845
|
+ |
push_bounded(&mut self.stderr_tail, line);
|
|
846
|
+ |
}
|
|
847
|
+ |
|
|
848
|
+ |
fn reported_error(&self) -> Option<&str> {
|
|
849
|
+ |
self.error.as_deref()
|
|
850
|
+ |
}
|
|
851
|
+ |
|
|
852
|
+ |
fn answer(&self) -> String {
|
|
853
|
+ |
let said = self
|
|
854
|
+ |
.result
|
|
855
|
+ |
.clone()
|
|
856
|
+ |
.filter(|text| !text.trim().is_empty())
|
|
857
|
+ |
.or_else(|| Some(self.assistant.clone()).filter(|text| !text.trim().is_empty()))
|
|
858
|
+ |
.or_else(|| Some(self.plain.clone()).filter(|text| !text.trim().is_empty()));
|
|
859
|
+ |
match said {
|
|
860
|
+ |
Some(text) => text.trim().to_string(),
|
|
861
|
+ |
None => format!(
|
|
862
|
+ |
"The {} child finished and printed no answer this harness could read.",
|
|
863
|
+ |
match self.lane {
|
|
864
|
+ |
ChildLane::Claude => "claude",
|
|
865
|
+ |
ChildLane::Codex => "codex",
|
|
866
|
+ |
ChildLane::Opencode { .. } => "opencode",
|
|
867
|
+ |
ChildLane::Devin => "devin",
|
|
868
|
+ |
ChildLane::OxAlpha => "ox-alpha",
|
|
869
|
+ |
}
|
|
870
|
+ |
),
|
|
871
|
+ |
}
|
|
872
|
+ |
}
|
|
873
|
+ |
|
|
874
|
+ |
fn tail(&self) -> String {
|
|
875
|
+ |
let mut both = String::new();
|
|
876
|
+ |
if !self.plain.trim().is_empty() {
|
|
877
|
+ |
both.push_str(self.plain.trim());
|
|
878
|
+ |
}
|
|
879
|
+ |
if !self.stderr_tail.trim().is_empty() {
|
|
880
|
+ |
if !both.is_empty() {
|
|
881
|
+ |
both.push('\n');
|
|
882
|
+ |
}
|
|
883
|
+ |
both.push_str(self.stderr_tail.trim());
|
|
884
|
+ |
}
|
|
885
|
+ |
if both.is_empty() {
|
|
886
|
+ |
"The child printed nothing.".to_string()
|
|
887
|
+ |
} else {
|
|
888
|
+ |
both
|
|
889
|
+ |
}
|
|
890
|
+ |
}
|
|
891
|
+ |
}
|
|
892
|
+ |
|
|
893
|
+ |
/// Keep the last [`CHILD_RESULT_LIMIT`] characters, so an hour of build output
|
|
894
|
+ |
/// cannot grow without bound in memory.
|
|
895
|
+ |
fn push_bounded(buffer: &mut String, line: &str) {
|
|
896
|
+ |
buffer.push_str(line);
|
|
897
|
+ |
buffer.push('\n');
|
|
898
|
+ |
if buffer.len() > CHILD_RESULT_LIMIT * 2 {
|
|
899
|
+ |
let keep = buffer.len() - CHILD_RESULT_LIMIT;
|
|
900
|
+ |
let at = buffer
|
|
901
|
+ |
.char_indices()
|
|
902
|
+ |
.map(|(at, _)| at)
|
|
903
|
+ |
.find(|at| *at >= keep)
|
|
904
|
+ |
.unwrap_or(0);
|
|
905
|
+ |
buffer.drain(..at);
|
|
906
|
+ |
}
|
|
907
|
+ |
}
|
|
908
|
+ |
|
|
909
|
+ |
/// A child's answer, cut to what the reader is shown.
|
|
910
|
+ |
///
|
|
911
|
+ |
/// The cut says its own size. A child that reported ten findings and was shown
|
|
912
|
+ |
/// as three reads exactly like a child that found three.
|
|
913
|
+ |
fn clip(text: &str) -> String {
|
|
914
|
+ |
if text.len() <= CHILD_RESULT_LIMIT {
|
|
915
|
+ |
return text.to_string();
|
|
916
|
+ |
}
|
|
917
|
+ |
let mut at = CHILD_RESULT_LIMIT;
|
|
918
|
+ |
while at > 0 && !text.is_char_boundary(at) {
|
|
919
|
+ |
at -= 1;
|
|
920
|
+ |
}
|
|
921
|
+ |
format!(
|
|
922
|
+ |
"{}\n…[{} of {} characters cut from the end of this child's answer]",
|
|
923
|
+ |
&text[..at],
|
|
924
|
+ |
text.len() - at,
|
|
925
|
+ |
text.len()
|
|
926
|
+ |
)
|
|
927
|
+ |
}
|
|
928
|
+ |
|
|
929
|
+ |
/// Turns the event stream into prefixed lines on standard output.
|
|
930
|
+ |
///
|
|
931
|
+ |
/// A child's output arrives in whatever pieces the harness or the model
|
|
932
|
+ |
/// produced it in, which for a streamed model is a few characters at a time.
|
|
933
|
+ |
/// Each child gets its own buffer so a prefix is printed once per line rather
|
|
934
|
+ |
/// than once per chunk, and two children writing at once do not interleave
|
|
935
|
+ |
/// mid-word.
|
|
936
|
+ |
struct Printer {
|
|
937
|
+ |
pending: std::collections::BTreeMap<usize, String>,
|
|
938
|
+ |
}
|
|
939
|
+ |
|
|
940
|
+ |
impl Printer {
|
|
941
|
+ |
fn new() -> Self {
|
|
942
|
+ |
Self {
|
|
943
|
+ |
pending: std::collections::BTreeMap::new(),
|
|
944
|
+ |
}
|
|
945
|
+ |
}
|
|
946
|
+ |
|
|
947
|
+ |
fn feed(&mut self, id: usize, text: &str) {
|
|
948
|
+ |
let buffer = self.pending.entry(id).or_default();
|
|
949
|
+ |
buffer.push_str(text);
|
|
950
|
+ |
while let Some(at) = buffer.find('\n') {
|
|
951
|
+ |
let line: String = buffer.drain(..=at).collect();
|
|
952
|
+ |
println!("[child {id}] {}", line.trim_end_matches('\n'));
|
|
953
|
+ |
}
|
|
954
|
+ |
}
|
|
955
|
+ |
|
|
956
|
+ |
fn flush(&mut self, id: usize) {
|
|
957
|
+ |
if let Some(buffer) = self.pending.get_mut(&id) {
|
|
958
|
+ |
if !buffer.trim().is_empty() {
|
|
959
|
+ |
println!("[child {id}] {}", buffer.trim_end());
|
|
960
|
+ |
}
|
|
961
|
+ |
buffer.clear();
|
|
962
|
+ |
}
|
|
963
|
+ |
}
|
|
964
|
+ |
}
|
|
965
|
+ |
|
|
966
|
+ |
/// `oa coder --delegate`.
|
|
967
|
+ |
pub async fn run_delegation(
|
|
968
|
+ |
args: CoderArgs,
|
|
969
|
+ |
user_token: Option<String>,
|
|
970
|
+ |
) -> Result<(), Box<dyn std::error::Error>> {
|
|
971
|
+ |
let requested = args.count.max(1);
|
|
972
|
+ |
if requested > MAX_DELEGATE_COUNT {
|
|
973
|
+ |
fail(&format!(
|
|
974
|
+ |
"{requested} children were asked for and this command runs at most {MAX_DELEGATE_COUNT}."
|
|
975
|
+ |
));
|
|
976
|
+ |
}
|
|
977
|
+ |
let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
|
|
978
|
+ |
let prompt = args
|
|
979
|
+ |
.prompt
|
|
980
|
+ |
.clone()
|
|
981
|
+ |
.unwrap_or_else(|| "Analyze workspace and run tests".to_string());
|
|
982
|
+ |
|
|
983
|
+ |
if !ChildLane::known(&lane_name) {
|
|
984
|
+ |
fail(&format!(
|
|
985
|
+ |
"there is no `{lane_name}` lane. This command runs children on: ox-alpha, gemini, opencode/<model>, devin, claude, codex."
|
|
986
|
+ |
));
|
|
987
|
+ |
}
|
|
988
|
+ |
let lane = ChildLane::parse(&lane_name);
|
|
989
|
+ |
|
|
990
|
+ |
let isolation = match args.isolation.as_deref() {
|
|
991
|
+ |
None => Isolation::Worktree,
|
|
992
|
+ |
Some(named) => match Isolation::parse(named) {
|
|
993
|
+ |
Some(isolation) => isolation,
|
|
994
|
+ |
None => fail(&format!(
|
|
995
|
+ |
"`{named}` is not an isolation this command knows. Use worktree, directory, or none."
|
|
996
|
+ |
)),
|
|
997
|
+ |
},
|
|
998
|
+ |
};
|
|
999
|
+ |
|
|
1000
|
+ |
let supervisor = DelegationSupervisor::new(requested, &lane_name, user_token)
|
|
1001
|
+ |
.with_isolation(isolation)
|
|
1002
|
+ |
.with_max_parallel(args.max_parallel.unwrap_or(requested))
|
|
1003
|
+ |
.keeping_workspaces(args.keep_workspaces);
|
|
1004
|
+ |
|
|
1005
|
+ |
println!(
|
|
1006
|
+ |
"Delegating to {} {} on {}, {} at a time, isolation: {}.",
|
|
1007
|
+ |
supervisor.count,
|
|
1008
|
+ |
if supervisor.count == 1 { "child" } else { "children" },
|
|
1009
|
+ |
lane.label(),
|
|
1010
|
+ |
supervisor.max_parallel,
|
|
1011
|
+ |
isolation.name(),
|
|
1012
|
+ |
);
|
|
1013
|
+ |
|
|
1014
|
+ |
// `ctrl+c` is the only stop signal a running fan-out has. Without it a
|
|
1015
|
+ |
// reader who changed their mind had to kill the terminal, and the
|
|
1016
|
+ |
// children — which are their own process groups — carried on spending.
|
|
1017
|
+ |
let (stop, cancel) = watch::channel(false);
|
|
1018
|
+ |
let interrupt = tokio::spawn(async move {
|
|
1019
|
+ |
if tokio::signal::ctrl_c().await.is_ok() {
|
|
1020
|
+ |
eprintln!("\nStopping the fan-out; children are being signalled.");
|
|
1021
|
+ |
let _ = stop.send(true);
|
|
1022
|
+ |
}
|
|
1023
|
+ |
});
|
|
1024
|
+ |
|
|
1025
|
+ |
let (events, mut incoming) = mpsc::unbounded_channel();
|
|
1026
|
+ |
let printing = tokio::spawn(async move {
|
|
1027
|
+ |
let mut printer = Printer::new();
|
|
1028
|
+ |
while let Some(event) = incoming.recv().await {
|
|
1029
|
+ |
match event {
|
|
1030
|
+ |
ChildEvent::Started {
|
|
1031
|
+ |
id,
|
|
1032
|
+ |
lane,
|
|
1033
|
+ |
workspace,
|
|
1034
|
+ |
pid,
|
|
1035
|
+ |
} => {
|
|
1036
|
+ |
println!(
|
|
1037
|
+ |
"[child {id}] started on {lane} in {workspace}{}",
|
|
1038
|
+ |
match pid {
|
|
1039
|
+ |
Some(pid) => format!(" as pid {pid}"),
|
|
1040
|
+ |
None => " in this process".to_string(),
|
|
1041
|
+ |
}
|
|
1042
|
+ |
);
|
|
1043
|
+ |
}
|
|
1044
|
+ |
ChildEvent::Output { id, text } => printer.feed(id, &text),
|
|
1045
|
+ |
ChildEvent::Activity { id, text } => {
|
|
1046
|
+ |
printer.flush(id);
|
|
1047
|
+ |
println!("[child {id}] · {text}");
|
|
1048
|
+ |
}
|
|
1049
|
+ |
ChildEvent::Finished(result) => {
|
|
1050
|
+ |
printer.flush(result.id);
|
|
1051
|
+ |
println!(
|
|
1052
|
+ |
"[child {}] {} after {}ms",
|
|
1053
|
+ |
result.id,
|
|
1054
|
+ |
if result.success { "finished" } else { "FAILED" },
|
|
1055
|
+ |
result.duration_ms
|
|
1056
|
+ |
);
|
|
1057
|
+ |
}
|
|
1058
|
+ |
}
|
|
1059
|
+ |
}
|
|
1060
|
+ |
});
|
|
1061
|
+ |
|
|
1062
|
+ |
let results = match supervisor.dispatch_streaming(&prompt, events, cancel).await {
|
|
1063
|
+ |
Ok(results) => results,
|
|
1064
|
+ |
// No child ran at all, so there is nothing to report but the reason.
|
|
1065
|
+ |
Err(error) => fail(&format!("no children were started: {error}")),
|
|
1066
|
+ |
};
|
|
1067
|
+ |
let _ = printing.await;
|
|
1068
|
+ |
interrupt.abort();
|
|
1069
|
+ |
|
|
1070
|
+ |
println!();
|
|
1071
|
+ |
let succeeded = results.iter().filter(|result| result.success).count();
|
|
1072
|
+ |
for result in &results {
|
|
1073
|
+ |
println!(
|
|
1074
|
+ |
"child {}: {} in {}ms{}{}",
|
|
1075
|
+ |
result.id,
|
|
1076
|
+ |
if result.success { "ok" } else { "failed" },
|
|
1077
|
+ |
result.duration_ms,
|
|
1078
|
+ |
match result.pid {
|
|
1079
|
+ |
Some(pid) => format!(", pid {pid}"),
|
|
1080
|
+ |
None => String::new(),
|
|
1081
|
+ |
},
|
|
1082
|
+ |
match &result.workspace {
|
|
1083
|
+ |
Some(path) => format!(", in {}", path.display()),
|
|
1084
|
+ |
None => String::new(),
|
|
1085
|
+ |
}
|
|
1086
|
+ |
);
|
|
1087
|
+ |
if let Some(why) = &result.failure {
|
|
1088
|
+ |
println!(" {why}");
|
|
1089
|
+ |
}
|
|
1090
|
+ |
}
|
|
1091
|
+ |
println!(
|
|
1092
|
+ |
"{succeeded} of {} {} completed on {}.",
|
|
1093
|
+ |
results.len(),
|
|
1094
|
+ |
if results.len() == 1 { "child" } else { "children" },
|
|
1095
|
+ |
lane.label()
|
|
1096
|
+ |
);
|
|
1097
|
+ |
|
|
1098
|
+ |
if succeeded < results.len() {
|
|
1099
|
+ |
// A fan-out that lost a child is not a command that worked. This used
|
|
1100
|
+ |
// to print `2/2 children succeeded` and exit zero whatever happened.
|
|
1101
|
+ |
// Exit 1 rather than the 2 an input error gets: the command was asked
|
|
1102
|
+ |
// for correctly and the work is what did not finish.
|
|
1103
|
+ |
eprintln!(
|
|
1104
|
+ |
"oa: {} of {} children did not finish.",
|
|
1105
|
+ |
results.len() - succeeded,
|
|
1106
|
+ |
results.len()
|
|
1107
|
+ |
);
|
|
1108
|
+ |
std::process::exit(1);
|
| 157 |
1109
|
|
}
|
| 158 |
|
- |
println!("Delegation fan-out complete. {}/{} children succeeded.", results.iter().filter(|r| r.success).count(), results.len());
|
| 159 |
1110
|
|
Ok(())
|
| 160 |
1111
|
|
}
|
|
1112
|
+ |
|
|
1113
|
+ |
/// The `delegate` tool's fan-out, rendered for a model to read.
|
|
1114
|
+ |
///
|
|
1115
|
+ |
/// Awaited rather than launched and forgotten: a model told "three children
|
|
1116
|
+ |
/// are running" has nothing to say next and will either invent their findings
|
|
1117
|
+ |
/// or ask the reader to wait.
|
|
1118
|
+ |
///
|
|
1119
|
+ |
/// Returned as a boxed `Send` future on purpose. The call graph is a cycle —
|
|
1120
|
+ |
/// a session runs a tool, the `delegate` tool starts a child, and the child is
|
|
1121
|
+ |
/// a session that runs tools — and the compiler cannot infer `Send` around a
|
|
1122
|
+ |
/// cycle: it asks whether this future is `Send` in order to answer whether it
|
|
1123
|
+ |
/// is `Send`. Naming the bound here is what breaks it.
|
|
1124
|
+ |
pub fn fanout_for_tool(
|
|
1125
|
+ |
prompt: &str,
|
|
1126
|
+ |
count: usize,
|
|
1127
|
+ |
lane: &str,
|
|
1128
|
+ |
user_token: Option<String>,
|
|
1129
|
+ |
) -> std::pin::Pin<Box<dyn std::future::Future<Output = String> + Send>> {
|
|
1130
|
+ |
let prompt = prompt.to_string();
|
|
1131
|
+ |
let lane = lane.to_string();
|
|
1132
|
+ |
Box::pin(async move {
|
|
1133
|
+ |
let prompt = prompt.as_str();
|
|
1134
|
+ |
let lane = lane.as_str();
|
|
1135
|
+ |
let count = count.clamp(1, MAX_DELEGATE_COUNT);
|
|
1136
|
+ |
let supervisor = DelegationSupervisor::new(count, lane, user_token);
|
|
1137
|
+ |
let results = supervisor.dispatch(prompt).await;
|
|
1138
|
+ |
|
|
1139
|
+ |
let succeeded = results.iter().filter(|result| result.success).count();
|
|
1140
|
+ |
let mut lines = vec![format!(
|
|
1141
|
+ |
"{succeeded} of {} {} completed on {}.",
|
|
1142
|
+ |
results.len(),
|
|
1143
|
+ |
if results.len() == 1 { "child" } else { "children" },
|
|
1144
|
+ |
ChildLane::parse(lane).label()
|
|
1145
|
+ |
)];
|
|
1146
|
+ |
lines.push(String::new());
|
|
1147
|
+ |
for result in &results {
|
|
1148
|
+ |
if result.success {
|
|
1149
|
+ |
lines.push(format!(
|
|
1150
|
+ |
"child {} completed in {}ms:\n{}",
|
|
1151
|
+ |
result.id,
|
|
1152
|
+ |
result.duration_ms,
|
|
1153
|
+ |
if result.output.trim().is_empty() {
|
|
1154
|
+ |
"(no output)"
|
|
1155
|
+ |
} else {
|
|
1156
|
+ |
result.output.trim()
|
|
1157
|
+ |
}
|
|
1158
|
+ |
));
|
|
1159
|
+ |
} else {
|
|
1160
|
+ |
lines.push(format!("child {} failed: {}", result.id, result.output));
|
|
1161
|
+ |
}
|
|
1162
|
+ |
}
|
|
1163
|
+ |
lines.join("\n")
|
|
1164
|
+ |
})
|
|
1165
|
+ |
}
|